SSRは何をしているのか②

ReactSSR

はじめに

前回の続きで、今回は以下をやっていこうと思います。

  1. ルーティングを追加する
  2. ルートごとにコード分割する
  3. ページ遷移をスムーズにする
  4. キャッシュを効かせる
Loading...

1. Routerコンポーネントを作成する

Navigation APIを使用したRouterコンポーネントを作成し、シンプルなルーティング機能を実装しようと思います。

まず、HomeAboutページのコンポーネントを作成します。

Home.tsx
export const Home: React.FC = () => { return ( <div> <h1>Home</h1> <a href="/about">About</a> </div> ); };
About.tsx
import { Content } from "./Content"; export const About: React.FC = () => { return ( <div> <h1>About</h1> <a href="/">Home</a> <Content /> </div> ); };

それぞれ<a>タグを持ち、お互いのページを行き来できるようにします。

そして、以下のようなRouterコンポーネントを作成します。

Router.tsx
import { useState, useEffect } from "react"; import { Home } from "./Home"; import { About } from "./About"; function shouldNotIntercept(navigationEvent: NavigateEvent): boolean { return ( !navigationEvent.canIntercept || navigationEvent.hashChange || navigationEvent.downloadRequest !== null || navigationEvent.formData !== null ); } type Route = { path: string; component: React.ComponentType; }; export const routes: Route[] = [ { path: "/", component: Home }, { path: "/about", component: About }, ]; export function Router({ initialPath }: { initialPath: string }) { const [path, setPath] = useState(initialPath); useEffect(() => { const handler = (event: NavigateEvent) => { if (shouldNotIntercept(event)) return; const url = new URL(event.destination.url); const matched = routes.find((r) => r.path === url.pathname); if (!matched) return; event.intercept({ handler: () => { setPath(url.pathname); }, }); }; window.navigation.addEventListener("navigate", handler); return () => window.navigation.removeEventListener("navigate", handler); }, []); const matched = routes.find((r) => r.path === path); if (!matched) return null; const Component = matched.component; return <Component />; }

サーバーから受け取ったinitialPathをuseStateの初期値にします。useStateはSSR時に初期値をただの変数として代入した状態で計算されるため1pathinitialPathの状態で処理が進むことになります。

そして、useEffectの中身はクライアントでのみ実行されるため2、SSR時には処理がスキップされ、useEffectの外にあるconst matched = routes.find(...)以降の処理がSSR時にも評価されます。ルート/にアクセスした時は、routesHomeに一致するのでこのHomeコンポーネントを返します。

SSR後に<a>のリンクをクリックした後は、Navigation APIによるSPA遷移が行われます。

さらに、AppでRouterをインポートし、pathを受け取ります。

App.tsx
import { Router } from "./Router"; export function App({ path }: { path: string }) { return ( <html> <head> <meta charSet="utf-8" /> <title>SSR Playground</title> </head> <body> <Router initialPath={path} /> </body> </html> ); }

server.tsxで以下の修正をします。

  • routesにあった"/"のルートを削除してfetchに置き換える
  • reqからurlを受け取って<App>にpathnameを渡す
server.tsx
const server = Bun.serve({ routes: { "/main.js": () => new Response(clientFile, { headers: { "Content-Type": "application/javascript" }, }), }, async fetch(req) { const url = new URL(req.url); try { const stream = await renderToReadableStream(<App path={url.pathname} />, { bootstrapScripts: ["/main.js"], onError(error) { console.error("SSR streaming error:", error); }, }); const [stream1, stream2] = stream.tee(); const decoder = new TextDecoder(); logStream(stream2, decoder); return new Response(stream1, { headers: { "Content-Type": "text/html" }, }); } catch (error: unknown) { return new Response(`<h1>Something went wrong</h1>`, { status: 500, headers: { "Content-Type": "text/html" }, }); } }, });

client.tsxでは、ハイドレーションミスマッチ3が起きないようにwindowから取得したpathを渡します。

client.tsx
hydrateRoot(document, <App path={window.location.pathname} />);

これでSSR+SPA遷移を行えるようになりました。 実際に挙動を確認すると以下の順に処理が行われていることがわかります。

  1. /で初回アクセスすると以下のHTMLがchunk0として返ってくる
chunk 0: <!DOCTYPE html><html><head><meta charSet="utf-8"/><link rel="preload" as="script" fetchPriority="low" href="/main.js"/><title>SSR Playground</title></head><body><div><h1>Home</h1><a href="/about">About</a></div><script src="/main.js" id="_R_" async=""></script></body></html>
  1. ハイドレーション後のuseEffect内の処理が実行されて、navigateイベントが登録される
  2. Aboutリンクをクリックすると、Navigation APIのhandlerが実行されて、<a>に設定された/aboutsetPathに代入される
  3. routesAboutが一致したコンポーネントとして返却され、<body>の中身がAboutコンポーネントの中身に置き換わり実質的なページ遷移が行われる

そして、今回は全てのコンポーネントを静的importしているため、バンドラが全ての依存を辿って1つのmain.jsにまとめます。つまり、初回で/にアクセスした時も/aboutで使われるモジュールまで含めてダウンロード・評価されてしまいます。コンポーネントが増えるほどmain.jsのサイズは大きくなり、初回表示時のダウンロード時間が長くなります。また、その間はonClicknavigateイベントなどがアタッチされないため、ボタンやリンクはあるけど期待通りに動作し始めません。

そこで、次はこの1つの大きなファイルをルートコンポーネントごとに分割して、必要なものだけを読み込むようにしてみます。

2. ルートごとにコード分割する

Bun.buildsplitting: true + 動的importで、生成されるJSファイルをルートコンポーネントごとのチャンクに分割します。 「チャンク」とは分割された個々のJSファイルのことで、バンドラが1つの大きなファイルを複数のファイルに分割(code splitting)した時、それぞれの断片をチャンクと呼びます。

code splittingを行うために以下のようにコンポーネントを書き換えます。

Router.tsx
export const routes: Route[] = [ { path: "/", component: lazy(() => import("./Home")) }, { path: "/about", component: lazy(() => import("./About")) }, ] as const; // ... const Component = matched.component; return ( <Suspense fallback={<p>Loading...</p>}> <Component /> </Suspense> );

lazy(React.lazy)は動的importをReactのライフサイクルに組み込んで利用しやすくしたもので、コンポーネントの遅延読み込みを可能にし、Suspenseをトリガーしてローディング状態を管理します。また、一度読み込んだモジュールはキャッシュされます。4

lazyを使用するためには、HomeやAboutのコンポーネントをdefault exportする必要があります。

About.tsx
const About: React.FC = () => { return ( <div> <h1>About</h1> <a href="/">Home</a> <Content /> </div> ); }; export default About;

次に、server.tsxでビルドの設定を変えます。

server.tsx
await Bun.build({ entrypoints: ["./src/client.tsx"], outdir: "./dist", splitting: true, naming: { entry: "[name].[ext]", // client.jsなどで出力される chunk: "[name]-[hash].[ext]", // client-bb9esmzr.jsやAbout-sxdcv2rm.jsなどで出力される }, }); // ... const server = Bun.serve({ async fetch(req) { const url = new URL(req.url); // dist配下のJSファイルを配信 if (url.pathname.endsWith(".js")) { const file = Bun.file(`./dist${url.pathname}`); const isFileExists = await file.exists(); if (isFileExists) { return new Response(file, { headers: { "Content-Type": "application/javascript" }, }); } } try { const stream = await renderToReadableStream(<App path={url.pathname} />, { bootstrapModules: ["/client.js"], // bootstrapScriptsから変更 onError(error) { console.error("SSR streaming error:", error); }, }); const [stream1, stream2] = stream.tee(); const decoder = new TextDecoder(); logStream(stream2, decoder); return new Response(stream1, { headers: { "Content-Type": "text/html" }, }); } catch (error: unknown) { return new Response(`<h1>Something went wrong</h1>`, { status: 500, headers: { "Content-Type": "text/html" }, }); } }, });

Bun.buildの設定項目の詳細は以下です。

  • splitting: true — バンドラにコード分割を許可するフラグで、動的importを分割ポイントとして認識し、チャンクファイルを生成する
  • entry — entrypointsに指定したファイルの出力名
  • chunk — バンドラが自動分割したチャンクファイルの出力名

また、renderToReadableStreamのオプションbootstrapScriptsからbootstrapModulesに変更しています。

  • bootstrapScripts - <script src="/main.js" id="_R_" async="">を出力し
  • bootstrapModules - </script><script type="module" src="/client.js" id="_R_" async="">を出力

bootstrapModulesにするとtype="module"が追加されますが、こうする必要がある理由は後で説明します。

動作確認をしてみます。

チャンクファイルが生成され、遅延読み込みがされている

/に初回アクセスした際は、HTMLとclient.jsclient-bb9esmzr.jsHome-11dv1dzd.jsが読み込まれ、/aboutに遷移した時にAbout-sxdcv2rm.jsが遅延読み込みされていることがわかります。

client-bb9esmzr.jsclient.jsHome-11dv1dzd.jsの両方が使う共通コード(ReactのuseStateやSuspenseなど)がバンドラによって自動的に切り出されたチャンクです。両方のファイルからimportされるモジュールを重複して持たせず、1つの共有チャンクにまとめることでファイルサイズの無駄を省いています。

そして、/aboutを初回表示した際のchunkのログも以下のように、lazyを使用して動的インポートになったことでchunkが分かれて、さらにAboutコンポーネント内のSuspenseによりchunk2までストリーミングされていることがわかります。

chunk 0: <!DOCTYPE html><html><head><meta charSet="utf-8"/><link rel="modulepreload" fetchPriority="low" href="/client-2eyj965r.js"/><title>SSR Playground</title></head><body><!--$?--><template id="B:0"></template><p>Loading...</p><!--/$--><script>requestAnimationFrame(function(){$RT=performance.now()});</script><script type="module" src="/client-2eyj965r.js" id="_R_" async=""></script> chunk 1: <div hidden id="S:0"><div><h1>About</h1><a href="/">Home</a><div><p>Count: <!-- -->0</p><button>Click me</button><!--$?--><template id="B:1"></template><p>Loading...</p><!--/$--></div></div></div><script>$RB=[];$RV=function(a){$RT=performance.now();for(var b=0;b<a.length;b+=2){var c=a[b],e=a[b+1];null!==e.parentNode&&e.parentNode.removeChild(e);var f=c.parentNode;if(f){var g=c.previousSibling,h=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d||"/&"===d)if(0===h)break;else h--;else"$"!==d&&"$?"!==d&&"$~"!==d&&"$!"!==d&&"&"!==d||h++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;e.firstChild;)f.insertBefore(e.firstChild,c);g.data="$";g._reactRetry&&requestAnimationFrame(g._reactRetry)}}a.length=0}; $RC=function(a,b){if(b=document.getElementById(b))(a=document.getElementById(a))?(a.previousSibling.data="$~",$RB.push(a,b),2===$RB.length&&("number"!==typeof $RT?requestAnimationFrame($RV.bind(null,$RB)):(a=performance.now(),setTimeout($RV.bind(null,$RB),2300>a&&2E3<a?2300-a:$RT+300-a)))):b.parentNode.removeChild(b)};$RC("B:0","S:0")</script> chunk 2: <div hidden id="S:1"><p>Hello, world!</p></div><script>$RC("B:1","S:1")</script></body></html>

また、さきほどの「bootstrapScriptsからbootstrapModulesに変更しないといけない理由」についてですが、生成したチャンクファイルのモジュールの解決方法がESMの構文で行われるからです。

実際にHome-11dv1dzd.jsのファイルの中身を見てみると、client.jsと共通でimportしているclient-bb9esmzr.jsimport形式でインポートされていることがわかります。

Home-11dv1dzd.js
import { __toESM, require_jsx_dev_runtime } from "./client-bb9esmzr.js";

import/export文はESM(ES Modules)の構文で、scriptタグにtype="module"がないとブラウザはこれを解釈できず、エラーになってしまいます。 そして、type="module"を持つscriptタグは、既定ではJavaScript モジュールとして扱われ、先送り(HTMLのパースと並行してJSファイルをダウンロードし、パース完了後に実行)されます。5

一方で、実際のページ遷移時にSuspenseがトリガーされることでSuspenseに設定したフォールバックが一瞬表示されます。一瞬のチラつきはユーザー体験を損ねるため、次で変えていきます。

3. ページ遷移をスムーズにする

lazyを使うことでPromiseが発生し、Suspenseがトリガーされるようになりました。ページ遷移を開始して、JSファイルの読み込みと実行が完了するまではSuspenseに設定したfallbackが表示されますが、一瞬チラつくローディングUIは煩わしくもあります。

今回のような場合は、ローディングUIを表示するというよりかは前のページの表示内容を保持しておく方がユーザー体験が良くなります。そこでstartTransitionを使用します。

Router.tsx
event.intercept({ handler: () => { startTransition(() => { setPath(url.pathname); }); }, });

startTransitionを使用することで、setPathによる状態更新が「緊急でない遷移」として扱われます。Reactは新しいコンポーネントの準備が完了するまで前のページの表示を維持し、準備ができた時点で一気に切り替えます。これにより、Suspenseのfallbackを経由せずにスムーズなページ遷移が実現できます。

ただそうなると、いつSuspenseのfallbackが使用されるのか疑問に思いますが、以下のようになっています。

  • SSR(初回アクセス)- サーバーには「前のページ」がないため、lazyコンポーネントの解決までSuspense fallbackがストリーミングされる
  • クライアント側の遷移 - startTransitionによりfallbackは表示されず、前のページが維持される

4. 静的ファイルをキャッシュする

JSやCSS、画像ファイルなどは、静的ファイルといいます。ビルド時に生成されて、リクエストごとに中身が変わらないファイルのことです。 リクエストごとに中身が変わらないため、静的ファイルはキャッシュすることができます。例えば、client-abc123.jsは誰がいつリクエストしても同じ内容が返ります。クライアントごとに取得結果が変わらないので、CDNを利用してユーザーに近いサーバーからコンテンツを届けることができます。

静的ファイルをキャッシュしないことによるデメリットは以下で、基本的にはキャッシュすることが推奨されます。

  • 表示が遅くなる — 毎回ダウンロードが発生するので、ページの表示やインタラクティブになるまでの時間が長くなる
  • サーバー負荷が増える — 同じファイルへのリクエストが毎回発生するので、トラフィックとサーバー処理が増える
  • 通信量が増える — モバイル回線などデータ量に制限がある環境ではユーザーの負担になる
  • Lighthouseのスコアが下がる — 「Serve static assets with an efficient cache policy」の監査で警告が出る6

そして、静的ファイルを上手にキャッシュするためには、ビルドごとに異なるハッシュをファイル名に付与することが大切です。これはcache-bustingと呼ばれるパターンで、Cache-Controlヘッダーで長期キャッシュを設定しつつ、ファイルの中身が変わればハッシュが変わりURLも変わるため、ブラウザが確実に新しいファイルを取得するようにするものです。

今回のプロジェクトでは、Bun.buildのnamingで[hash]トークンを使用してファイル名にコンテンツハッシュを付与しています。

await Bun.build({ entrypoints: ["./src/client.tsx"], outdir: "./dist", splitting: true, naming: { entry: "[name].[ext]", chunk: "[name]-[hash].[ext]", }, });

ただ、この設定だとentryファイルは常にclient.jsとして出力されるため、中身が変わってもURLが変わらずcache-bustingができません。そこで以下のようにします。

const build = await Bun.build({ entrypoints: ["./src/client.tsx"], outdir: "./dist", splitting: true, naming: { entry: "[name]-[hash].[ext]", chunk: "[name]-[hash].[ext]", }, }); const entryFile = build.outputs.find((o) => o.kind === "entry-point"); if (!entryFile) { throw new Error("Entry file not found"); } const entryFilePath = `/client-${entryFile.hash}.js`; // ... const stream = await renderToReadableStream(<App path={url.pathname} />, { bootstrapModules: [entryFilePath], onError(error) { console.error("SSR streaming error:", error); }, });

naming.entryの命名設定を[name]-[hash].[ext]とし、hashが付与されたclient.jsファイルをbootstrapModulesに渡すようにします。 そして、静的ファイルの配信時にCache-Controlヘッダーを設定します。

return new Response(file, { headers: { "Content-Type": "application/javascript", "Cache-Control": "public, max-age=31536000, immutable", }, });
  • public — CDNなどの共有キャッシュにも保存してよいことを示す
  • max-age=31536000 — 1年間(秒数)キャッシュする
  • immutable — ファイルの中身が変わらないことを宣言し、リロード時の再検証リクエストも省く

おわりに

今回はSSRのプロジェクトにルーティングを追加し、コード分割と静的ファイルのキャッシュまで実装しました。振り返ると、それぞれの仕組みがつながっていることがわかります。

  • Navigation APIでSPA遷移を実現し、フルリロードなしでページを切り替える
  • React.lazy + splitting: trueでルートごとにチャンクを分割し、必要なものだけを読み込む
  • startTransitionでSuspenseのfallbackを回避し、前のページを保持したままスムーズに遷移する
  • ハッシュ付きファイル名 + Cache-Controlで長期キャッシュを効かせつつ、更新時は確実に新しいファイルを届ける

それぞれ単体の知識としては知っていても、SSRの文脈でどう繋がるかは実際に実装してみないと見えにくい部分だと思います。SSRの仕組みを体系的に理解するきっかけになっていれば幸いです!

脚注

  1. 前回の記事にあるように、<p>Count: <!-- -->0</p>の状態でchunk0(SSR時のHTML)のログが出力されていました

  2. 「エフェクトはクライアント上でのみ実行されます。サーバレンダリング中には実行されません。」

  3. hydrateRootに渡すコンポーネントはサーバーとクライアントで同じ結果を返すようにしないといけない

  4. https://ja.react.dev/reference/react/Suspense#usage

  5. https://developer.mozilla.org/ja/docs/Web/Performance/Guides/Lazy_loading#javascript

  6. Lighthouse のキャッシュ ポリシー監査が失敗する仕組み

ポストするGitHubで編集を提案