Vite を使って Server Side Rendering (SSR) を体感するハンズオン
書籍「Fluent React」に SSR を簡易的に実装するパートがあるのですが、同様のことを人気のViteを使って実装し、動かしてみることで react の SSR の理解を深めるための記事です。
はじめに
利用するツール
この記事で利用しているツールは以下の通りです。
- Node.js
- pnpm
- Docker Desktop
対象読者
何となくReactは知っているが、Server Side Rendering (SSR)のことはよく分からないという方向けです。
Vite で Client Side Rendering (CSR)
まず、SSRとの比較のため、 Vite で CSR実装を動かします。
やることはテンプレートをダウンロードして動かすだけです。
以下を実行します。
pnpm create vite react-ssr-hands-on --template react-swc-ts
cd react-ssr-hands-on
pnpm install
pnpm run dev
pnpm create vite
についてはScaffolding Your First Vite Projectを参照ください。
http://localhost:5173/ にアクセスすると、以下のような画面が表示されます(画像左側)。
Chrome DevTools の Networkタブで取得したHTMLを内容を見ると、body要素配下の <div id="root"></div> 配下には何もありません(画像右側)。
しかし、Elementsタブを参照すると、<div id="root"></div> 配下に src/App.tsx
の内容がレンダリングされていることがわかります。
上記のことから、ブラウザに「"内容"を持たないHTML」が配信されてから、ブラウザ(Client-side)でReact(JavaScript)が実行されることにより"内容"部分のHTMLが作成(レンダリング)され、特定の要素(ここでは<div id="root"></div>)配下に付加されていることが分かります。
Vite で build して、 Nginx で配信する
後述のSSRと比較できるように実際にBuildしてNginxでHTML等を配信します。
Vite で Build
Buildするには、package.json の "build" scriptを実行します。
pnpm build
Buildすると、以下のように dist
ディレクトリが出力されます。(画像左側)。
先ほど Chrome DevTools の Networkタブで確認したHTMLと同様に<div id="root"></div> 配下には何もありません(画像右側)。
Nginx で配信
プロジェクト・フォルダ(react-ssr-hands-on
)直下に以下の2つのファイルを作成します。
やりたいことは「dist
フォルダ配下を配信する」だけです。
- docker-compose.yml
- nginx/nginx.conf
version: "3.9"
services:
nginx:
image: nginx:latest
container_name: static-server
ports:
- "8080:80"
volumes:
- ./dist:/usr/share/nginx/html
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
}
}
ファイル作成後、以下のコマンドを実行します。
docker compose up
http://localhost:8080/ にアクセスすると、pnpm dev
を実行した時と同様の画面を見られます。
動作実験のため、 JavaScript を除外
敢えて、dist/index.html の script要素をコメントアウトして http://localhost:8080/ をリロードしてください。
React(JavaScript)が実行されないので、Client Side Rendering (CSR) の実装では"内容"が<div id="root"></div>に付加されず、画面には何も表示されません。
Vite で Server Side Rendering (SSR)
SSRの実装では、Server-sideでReact要素をレンダリングしてHTMLを完成させ、Client-sideでhydrate(ハイドレート)します。
Server Side Rendering
CSRではブラウザに「"内容"を持たないHTML」を配信しますが、SSRでは「"内容"を持つHTML」を配信します。
そのために、Server-side にて、Reactで書いたコンポーネントをレンダリングしてHTMLに埋め込みます。
React要素をレンダリングして文字列化
React要素をレンダリングして文字列化するには、Server API の renderToString を使います。
src 配下に以下の内容で main-at-server.tsx を作成します。
import { StrictMode } from "react";
import { renderToString } from "react-dom/server";
import App from "./App";
export function render() {
return renderToString(
<StrictMode>
<App />
</StrictMode>
);
}
package.json の"scripts"に以下のコマンド("build:server"
)を追加します。
"scripts": {
"build:server": "vite build --ssr src/main-at-server.tsx --outDir dist/server",
},
以下を実行し、ビルドします。
pnpm build:server
JSファイル /dist/server/main-at-server.js
が出力されます。
これで、React要素をレンダリングして文字列化する JavaScript関数を用意できました。
文字列化した"内容"をHTMLに埋め込む
先ほど作成したJavaScript関数を実行することによって得られる文字列を、HTMLに埋め込みます。
プロジェクト・フォルダ直下に以下の内容で insert-app.ts
を作成します。
import fs from "node:fs/promises";
import { render } from "./dist/server/main-at-server.js";
const appHtml = render();
const template = await fs.readFile("./dist/index.html", "utf-8");
await fs.writeFile("./dist/index.html", template.replace("🦜", appHtml));
./dist/index.html の "🦜" を「文字列化した"内容"」で置き換えています。
./dist/index.html の <div id="root"></div>の子Nodeに文字列「🦜」を加えます。
<!doctype html>
<html lang="en">
<head>
(中略)
</head>
<body>
<div id="root">🦜</div>
</body>
</html>
package.json の"scripts"に insert-app.ts を実行するコマンド("build:server"
)を追加し実行します。
"scripts": {
"build:insert": "pnpm dlx tsx ./insert-app.ts",
},
pnpm build:insert
insert-app.ts を実行し、./dist/index.html を参照すると、"🦜" が<App />
コンポーネントの内容で置き換えられていることが分かります。
http://localhost:8080/ にアクセスすると、script要素をコメントアウトしている状態であっても、"内容"が表示されることが分かります。
Hydration (ハイドレーション)
現状、script要素をコメントアウトしており、JavaScript が無い状態なのでカウンター(「count is 0」の部分)をクリックしても数値がインクリメントされません。
SSRしたHTMLでReactを動作させるには、「Hydration (ハイドレーション)」なるものを Client-side で行います。
Reactにおいてハイドレーションとは、書籍「Fluent React」によると「サーバ上で生成され、クライアントに送信される静的HTMLに、イベントリスナーやその他のJavaScript機能をアタッチするプロセスを説明するために使用される用語」(AI訳)とのことです。
src/main.tsx を、ハイドレーションを行う実装に変更します。
client API hydrateRoot を利用します。
import { StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
hydrateRoot(document.getElementById('root')!,
<StrictMode>
<App />
</StrictMode>,
)
先ほどはハンズオンの都合でbuild後の dist/index.html に 🦜 を加えましたが、今度はプロジェクト・ディレクトリ直下のbuild前の index.html に 🦜 を加えます。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root">🦜</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
これまで用意した script を pnpm build
で一度に実行するようにして、実行します。
"scripts": {
"build": "tsc -b && vite build && pnpm run build:server && pnpm run build:insert",
},
pnpm build
http://localhost:8080/ にアクセスすると、今度はカウンターをインクリメントすることができます。
また、Chrome DevTools の Networkタブで配信されたHTMLを見ると、<div id="root"></div> 配下にAppコンポーネントの内容が埋め込まれており、CSRとの違いを見てとることができます。
おわりに
実際のコード
コードは以下にアップロードしています。
参考サイト
Vite を利用した SSR に関する詳細な内容ついては公式ページ「Server-Side Rendering」を参照ください。
余談
hydrateRoot にせずに createRoot のままでも動作するにはするようです。
Discussion