はじめに
Viteを利用してReactアプリを作成していましたが、SPAの難点(SEOやページリロード時)を解決するために、SSGでの出力を可能にしました。
忘備録と参考のために手順をまとめておこうと思います。
SPAやSSGのメリットデメリットはこちらの記事なんかが役に立つと思います。
バージョン一覧
使用技術 | バージョン |
---|---|
Vite | 4.4.5 |
React | 18.2.0 |
react-router-dom | 6.17.0 |
SSG出力を行うための手順
大きな流れとしては
- entry-server.tsxの作成
- entry-client.tsxの作成
- prerender.jsの作成
になります。
他にはpackage.jsonやvite.config、index.htmlの修正も行います。
ディレクトリ構成
以下のような構成を想定しています。
※ ファイル名は参考程度
project-root/
│
├── src/
│ │
│ ├── components/ # コンポーネント
│ │
│ ├── pages/ # ページコンポーネント (ルーティング対象)
│ │ ├── home.tsx
│ │ ├── about.tsx
│ │ └── ...
│ │
│ ├── App.tsx # アプリケーションのルートコンポーネント
│ │
│ ├── entry-client.tsx # クライアントサイドのエントリーポイント
│ │
│ └── entry-server.tsx # サーバーサイドレンダリングのエントリーポイント
│
│
├── dist/ # ビルド後のファイルが生成されるディレクトリ
│ ├── static/ # SSGで生成されるサイドビルドファイル
│
├── index.html
│
├── vite.config.ts # Viteの設定ファイル
|
├── prerender.js # SSGを行うファイル
│
└── package.json # プロジェクトのメタデータと依存関係
1. entry-server.tsxの作成
src内でentry-server.tsxを作成します。
以下をターミナルで実行します。
cd src
touch entry-server.tsx
cd ../
そして、作成したentry-server.tsxを以下のようにします。
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { Location } from "react-router-dom";
import App from "./App";
// render関数を定義
export async function render(url: string | Partial<Location<any>>) {
// AppをHTML文字列としてレンダリング
const appHtml = ReactDOMServer.renderToString(
<StaticRouter location={url}>
<App />
</StaticRouter>
);
// HTML文字列となったAppを返す
return appHtml;
}
このコードでは、AppをHTML文字列として変換し、appHtmlとして返すrenderという名前の関数を定義しています。
このappHtmlを埋め込む箇所をindex.htmlに作成する必要があるのでindex.htmlを修正します。
今回はoutletというコメントアウトされた文字列をappHtmlの埋め込み場所として指定します。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Viteで作成したReactアプリをSSGで出力出来るように</title>
</head>
<body>
<!-- outletを追加 -->
<div id="root"><!--outlet--></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
以上でAppを静的なHTML文字列に変換し、index.htmlに挿入する下準備が完了しました。
次のentry-client.tsxでは作成されたHTML文字列に動きを付け加えるための処理を行います。
2. entry-client.tsxの作成
src内でentry-client.tsxを作成します。
ターミナルにて以下を実行。
cd src
touch entry-client.tsx
cd ../
そしてentry-client.tsxを以下のように変更します。
import App from "./App";
import { BrowserRouter } from "react-router-dom";
import { hydrateRoot } from "react-dom/client";
import "./index.css";
// index.htmlからid = rootを取得
const container = document.getElementById("root");
// hydrateRootを実行することで静的なファイルに動きを追加
if (container) {
hydrateRoot(
container,
<BrowserRouter
>
<App />
</BrowserRouter>
);
}
このコードでは、先程entry-server.tsxにて作成された静的なHTML文字列に対してhydrateRootを行うことで動きを加えることを目的としています。
詳しくはこちらの公式ページを参照ください。
また、Github Pagesなどを利用している場合は、basenameの設定も忘れずに行う必要があります。
続いて、今作成したentry-client.tsxをindex.htmlで読み込むためにindex.htmlを修正します。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Viteで作成したReactアプリをSSGで出力出来るように</title>
</head>
<body>
<div id="root"><!--outlet--></div>
<!--これを削除 or コメントアウト-->
<!-- <script type="module" src="/src/main.tsx"></script> -->
<!--以下のscriptタグを追加-->
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>
3. prerender.jsの作成
最後にprerender.jsを作成していきます。
ターミナルにて以下を実行します。
touch prerender.js
作成されたprerender.jsは以下のようにします
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "url";
// prerender.jsの絶対パスを取得
// 後のファイル読み書きに使う
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const toAbsolute = (p) => path.resolve(__dirname, p);
// src/pagesにあるファイルをそれぞれ読み取ってルート名を保持
const routesToPrerender = fs
.readdirSync(toAbsolute("src/pages"))
.map((file) => {
// index.tsxの場合はページを追加する必要はない
if (file == "index.tsx") {
return;
}
// 拡張子のtsxを消去
const name = file.replace(/\.tsx$/, "");
// Homeは各々のrootになるpage名を指定
return name === "Home" ? `/` : `/${name}`;
});
// 先程作成したrenderを呼び出す
// build後のファイルから呼び出すため、以下のpathになっている
const { render } = await import("./dist/server/entry-server.js");
(async () => {
// それぞれのルートをHTMLファイルとして書き出す
for (const url of routesToPrerender) {
// 空のurlがあった場合は何も行わない
if (!url) {
return;
}
// index.htmlをtemplateとして利用
const template = fs.readFileSync(
toAbsolute("dist/static/index.html"),
"utf-8"
);
// renderからappHtmlを受け取り、index.htmlで指定した<!--outlet-->の部分に挿入
const appHtml = await render(url);
const html = template.replace(`<!--outlet-->`, appHtml);
// ファイルを書き出す先のパス指定とファイルの名前指定
const filePath = `dist/static${url === "/" ? "/index" : url}.html`;
// 先程指定したファイルパスにファイルを書き出し
fs.writeFileSync(toAbsolute(filePath), html);
}
})();
少し長々としたコードですが、全体としては、src/pagesにあるpageそれぞれをdist/staticにHTMLファイルとして書き出しているということです。
大きな注意点はありませんが、Homeをrootとして指定している部分に関しては、各々のrootパスに指定されているページ名に名前を変えて指定する必要があります。
以上でほとんどの工程は終了しました。
最後にpackage.jsonとvite.configを修正してbuildとpreviewを行いましょう。
package.jsonとvite.configの修正
package.json
buildを行うためのscriptをscripts内に追加してください。
"build:server": "tsc && vite build --ssr src/entry-server.tsx --outDir dist/server",
"generate": "vite build --outDir dist/static && npm run build:server && node prerender"
vite.config
previewを行うディレクトリを指定するために、vite.configには以下を追記します。
build: {
outDir: "dist/static",
}
以上でSSG出力を行うための手順は終了です!
SSGとして出力する
ターミナルにて以下のコマンドを実行します。
npm run generate
これによって、以下のような構成のdistディレクトリの作成が確認できたと思います。
project-root/
│
└── dist/
|
├── server
│
└── static/ # デプロイなどには、このディレクトリ以下を使用
|
├── assets # jsとcssのファイルが格納される
│
├── index.html
|
└── ... # 利用するpageの数だけhtmlファイルが生成される
最後にターミナルにてpreviewを実行しましょう。
npm run preview
指定されたURL(デフォルトでは http://localhost:4173)
を開くとSSGによって出力されたHTMLが問題なく動作すると思います!
最後に
読んでいただいた方はありがとうございます。
自身の忘備録としての記事ではありますが、SPAから移行したいな〜と悩んでいる人や、そうでなくても、SSGの中身がどうなってるか気になっている人の役にも立てると考えています。
また、今回の記事ではSEOに関する記述はありませんが、react-helmet-asyncの利用と、今回作成したファイルを少し修正することで、headのタグを動的に変化させることが可能です。
気が向いたら、それに関する記事も作成したいと思います。
この記事が誰かの役に立てれば光栄です!
宣伝
Konwalk(コンウォーク) という 「歩く時間に英単語を覚える」 をコンセプトにしたWeb英単語帳を運営しています!
ぜひ興味を少しでも持っていただいた方は見てやってください🙇