🦜

Vite を使って Server Side Rendering (SSR) を体感するハンズオン

2025/01/05に公開

書籍「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
docker-compose.yml
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
nginx/nginx.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 を作成します。

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")を追加します。

package.json
  "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関数を用意できました。

https://github.com/fneco/react-ssr-hands-on-with-vite/compare/static-server...hands-on-1

文字列化した"内容"をHTMLに埋め込む

先ほど作成したJavaScript関数を実行することによって得られる文字列を、HTMLに埋め込みます。
プロジェクト・フォルダ直下に以下の内容で insert-app.ts を作成します。

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に文字列「🦜」を加えます。

./dist/index.html
<!doctype html>
<html lang="en">
  <head>
    (中略)
  </head>
  <body>
    <div id="root">🦜</div>
  </body>
</html>

package.json の"scripts"に insert-app.ts を実行するコマンド("build:server")を追加し実行します。

package.json
  "scripts": {
    "build:insert": "pnpm dlx tsx ./insert-app.ts",
  },
pnpm build:insert

insert-app.ts を実行し、./dist/index.html を参照すると、"🦜" が<App />コンポーネントの内容で置き換えられていることが分かります。

http://localhost:8080/ にアクセスすると、script要素をコメントアウトしている状態であっても、"内容"が表示されることが分かります。

https://github.com/fneco/react-ssr-hands-on-with-vite/compare/hands-on-1...hands-on-2

Hydration (ハイドレーション)

現状、script要素をコメントアウトしており、JavaScript が無い状態なのでカウンター(「count is 0」の部分)をクリックしても数値がインクリメントされません。

SSRしたHTMLでReactを動作させるには、「Hydration (ハイドレーション)」なるものを Client-side で行います。
Reactにおいてハイドレーションとは、書籍「Fluent React」によると「サーバ上で生成され、クライアントに送信される静的HTMLに、イベントリスナーやその他のJavaScript機能をアタッチするプロセスを説明するために使用される用語」(AI訳)とのことです。

src/main.tsx を、ハイドレーションを行う実装に変更します。
client API hydrateRoot を利用します。

src/main.tsx
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 に 🦜 を加えます。

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 で一度に実行するようにして、実行します。

package.json
  "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との違いを見てとることができます。

https://github.com/fneco/react-ssr-hands-on-with-vite/compare/hands-on-2...hands-on-3

おわりに

実際のコード

コードは以下にアップロードしています。

https://github.com/fneco/react-ssr-hands-on-with-vite

参考サイト

Vite を利用した SSR に関する詳細な内容ついては公式ページ「Server-Side Rendering」を参照ください。

余談

hydrateRoot にせずに createRoot のままでも動作するにはするようです。

https://github.com/fneco/react-ssr-hands-on-with-vite/compare/hands-on-3...hands-on-😳

Discussion