Viteで作成したReactアプリをSSGで出力出来るように変更

# はじめに

Viteを利用してReactアプリを作成していましたが、SPAの難点(SEOやページリロード時)を解決するために、SSGでの出力を可能にしました。

忘備録と参考のために手順をまとめておこうと思います。

SPAやSSGのメリットデメリットは[こちら](https://qiita.com/manabito76/items/fe91eefe11a74dcf5126)の記事なんかが役に立つと思います。

<br>

***バージョン一覧***
| 使用技術  | バージョン  |
|:-:|:-:|
| Vite  | 4.4.5  |
| React  | 18.2.0  |
| react-router-dom  | 6.17.0  |

# SSG出力を行うための手順

大きな流れとしては

1. ***entry-server.tsx***の作成
1. ***entry-client.tsx***の作成 
1. ***prerender.js***の作成

になります。

他には***package.json***や***vite.config***、***index.html***の修正も行います。

<br>

***ディレクトリ構成***

以下のような構成を想定しています。
※ ファイル名は参考程度

```bash
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***を以下のようにします。

```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の埋め込み場所として指定します。

```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>
    <!-- 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***を以下のように変更します。


```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***を行うことで動きを加えることを目的としています。

詳しくは[こちらの公式ページ](https://ja.react.dev/reference/react-dom/client/hydrateRoot)を参照ください。

また、Github Pagesなどを利用している場合は、***basename***の設定も忘れずに行う必要があります。
<br>

続いて、今作成した***entry-client.tsx***を***index.html***で読み込むために***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を作成していきます。

ターミナルにて以下を実行します。

```bash
touch prerender.js
```

作成された***prerender.js***は以下のようにします

```prerenser.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***内に追加してください。

```json
"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には以下を追記します。

```ts
build: {
    outDir: "dist/static",
}
```

以上でSSG出力を行うための手順は終了です！

# SSGとして出力する

ターミナルにて以下のコマンドを実行します。

```bash
npm run generate
```

これによって、以下のような構成のdistディレクトリの作成が確認できたと思います。

```bash
project-root/
│
└── dist/  
  |
  ├── server     
  │
  └── static/           # デプロイなどには、このディレクトリ以下を使用          
     |
     ├── assets         # jsとcssのファイルが格納される
     │
     ├── index.html
     |
     └── ...            # 利用するpageの数だけhtmlファイルが生成される
```

最後にターミナルにてpreviewを実行しましょう。
```bash
npm run preview
```

指定されたURL(デフォルトでは http://localhost:4173)
を開くとSSGによって出力されたHTMLが問題なく動作すると思います！

# 最後に

読んでいただいた方はありがとうございます。

自身の忘備録としての記事ではありますが、SPAから移行したいな〜と悩んでいる人や、そうでなくても、SSGの中身がどうなってるか気になっている人の役にも立てると考えています。

また、今回の記事ではSEOに関する記述はありませんが、react-helmet-asyncの利用と、今回作成したファイルを少し修正することで、headのタグを動的に変化させることが可能です。
気が向いたら、それに関する記事も作成したいと思います。

この記事が誰かの役に立てれば光栄です！

# 宣伝

***Konwalk(コンウォーク)*** という ***「歩く時間に英単語を覚える」*** をコンセプトにしたWeb英単語帳を運営しています！

https://konwalk.jp

ぜひ興味を少しでも持っていただいた方は見てやってください🙇