SSRは何をしているのか①
はじめに
昨今のフレームワークを使うとSSRの実装を簡単に行うことができますが、ぼんやりとしか理解できておらず、危ういなと思ったので自作しながら勉強してみます!
1. ローカルでシンプルなSSRをする
プロジェクトのセットアップ
まず、サーバーサイドでJavaScript(TypeScript)を動かすためには、ランタイム環境が必要になります。 ランタイム環境には、広く昔から使われているNode.jsや、最近その代替として登場したBun、Denoなどがありますが、今回はWeb Streams APIの機能をそのまま使えて、かつbundler、package managerなどの機能を兼ねているBunで進めることにします。
また、最低限linterやformatterなどはOxcを使用し、Bunはパッケージマネージャーも兼ねているのでそのまま使用します。
# Blank templateを選択する
bun init ssr-playground
# oxlint
bun add -D oxlint
bun run oxfmt --init
# oxfmt
bun add -D oxfmt
bun run oxfmt --init
セットアップ後の状態は以下のようになります。
BunでReactをサーバーサイドレンダリングする
まず、Reactをインストールします。
bun add react react-dom bun add -D @types/react @types/react-dom
次に、Counterの機能を持ったReactコンポーネントとHTTPサーバーを作成します。
App.tsximport { useState } from "react";
export function App() {
const [count, setCount] = useState(0);
return (
<body>
<button onClick={() => setCount(count + 1)}>Click me</button>
<p>Count: {count}</p>
</body>
);
}
server.tsximport { App } from "./App";
import { renderToString } from "react-dom/server";
const server = Bun.serve({
routes: {
"/": () => {
const content = renderToString(<App />);
return new Response(content, {
headers: { "Content-Type": "text/html" },
});
},
},
});
console.log(`Listening on ${server.url}`);
今回はrenderToStringを使って、サーバーサイドでReactコンポーネントをレンダリングし、シンプルにHTMLを表示します。
bun run server.tsxでローカルと立ち上げてページを開くとテキストとボタンが表示されますが、カウンターは動作しません。
動作しないのはハイドレーションが行われていないからで、サーバーから返されるHTMLは静的な文字列でしかなく、ボタンのonClickなどのイベントハンドラがアタッチされません。 ReactではhydrateRootを使用して、サーバー上でレンダーされたHTML内部のDOM管理を引き継ぐことができます。
client.tsximport { hydrateRoot } from "react-dom/client";
import { App } from "./App";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
hydrateRoot(rootElement, <App />);
hydrateRootは、第一引数にどこからReactが管理するDOMノードになるかの情報を渡し、第二引数に実際のReactツリーを渡します。
hydrateRootはreact-dom/clientからインポートされることや、documentにアクセスしてDOMノードを見つけることをするため、ブラウザでしか動きません。
ブラウザで動かすには、純粋なJavaScriptコードに変換・バンドルする必要があります。Bunはバンドルも行うことができ、今回はBun.build()を使用します。
今回は簡易的にバンドルした後のtextデータをメモリ上に持ち、responseとして返すようにします。
server.tsxconst clientBuild = await Bun.build({
entrypoints: ["./client.tsx"],
outdir: "./dist",
});
const clientFile = await clientBuild.outputs[0]?.text();
const server = Bun.serve({
routes: {
"/": async () => {
// htmlを返す
const html = renderToString(<App />);
const fullHtml = `<!DOCTYPE html><html><head><meta charSet="utf-8" /><script type="module" src="/client.js"></script></head><body><div id="root">${html}</div></body></html>`;
return new Response(fullHtml, {
headers: { "Content-Type": "text/html" },
});
},
"/client.js": () => {
return new Response(clientFile, {
headers: { "Content-Type": "application/javascript" },
}),
}
},
});
SSR時に<script type="module" src="/client.js"></script>も含めてHTMLを返し、ブラウザがHTMLをパースする際にclient.jsのリクエストが発生するので、バンドルしたJavaScriptファイルを返すようにします。ブラウザがこのJavaScriptを実行するとhydrateRootが呼ばれ、サーバーが生成したDOMにイベントハンドラがアタッチされます。これがハイドレーションです。
2. Streaming SSR
renderToStringとrenderToReadableStream
renderToStringはシンプルなAPIで、同期的にHTML全体を作ってから返しますが、例えばAPI通信を複数箇所で行っている場合はレンダリングの前にすべてのデータを揃えておく必要があり、その間ブラウザには何も表示されません。。また、HTML文字列全体をメモリ上に生成してからレスポンスを返すため、メモリ使用量が増え、サーバーに負荷がかかります。
そこで、renderToStringをrenderToReadableStreamに置き換えます。renderToReadableStreamは、ReactツリーをWeb標準のReadableStreamとしてレンダリングします。
Streams APIとは、Web上でデータを少しずつ(チャンクごとに)読み書きするためのAPIのことで、例えば画像や動画で段階的に読み込んで表示することできていたものを、JavaScriptにおいても可能にしました。 renderToReadableStreamはこのStreams APIを活用することで、先ほどのrenderToStringのデメリットを解消し、HTMLを生成しながらチャンクごとにレスポンスを返せるため、ブラウザは届いた分から表示を開始できます。
Streams APIについては以下が参考になりました。
まず、renderToReadableStreamでドキュメント全体(<html>タグを含む)をレンダリングするため、Appコンポーネントをページ全体の構造にし、Counterコンポーネントを分離してContent.tsxにまとめます。
Content.tsximport { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</>
);
}
export function Content() {
return (
<div>
<Counter />
</div>
);
}
app.tsximport { Content } from "./Content";
export function App() {
return (
<html>
<head>
<meta charSet="utf-8" />
<title>SSR Playground</title>
</head>
<body>
<div id="root">
<Content />
</div>
</body>
</html>
);
}
client.tsximport { hydrateRoot } from "react-dom/client";
import { Content } from "./Content";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
hydrateRoot(rootElement, <Content />);
server.tsxではclient.tsxをmain.jsとして出力し、renderToReadableStreamにbootstrapScriptsを渡すと、生成されるHTMLに自動で<script>タグが挿入されます。
servr.tsximport { App } from "./App";
import { renderToReadableStream } from "react-dom/server";
const clientBuild = await Bun.build({
entrypoints: ["./client.tsx"],
outdir: "./dist",
naming: "main.js",
});
const clientFile = await clientBuild.outputs[0]?.text();
const server = Bun.serve({
routes: {
"/": async () => {
// streamを返す
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ["/main.js"],
});
return new Response(stream, {
headers: { "Content-Type": "text/html" },
});
},
"/main.js": () => {
return new Response(clientFile, {
headers: { "Content-Type": "application/javascript" },
}),
}
},
});
基本形ができました。ただし、このままだとStreamの「チャンクごとにレスポンスを返す」という機能を確認できないので、以下のようにReactのuseを使って非同期処理を伴うコンポーネントを作成して呼び出すようにしてみます。
Content.tsximport { use } from "react";
function fetchMessage(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
resolve("データの取得が完了しました!");
}, 5000);
});
}
const messagePromise = fetchMessage();
export function SlowMessage() {
const message = use(messagePromise);
return <p>{message}</p>;
}
export function Content() {
return (
<div>
<Counter />
<Suspense fallback={<p>Loading...</p>}>
<SlowMessage />
</Suspense>
</div>
);
}
useはPromiseの結果を読み取るフックで、Promiseが未解決の場合はSuspenseを発動させます。レンダリングのたびに新しいPromiseが作られると無限ループになるため、コンポーネントの外で1回だけ作成します。
また、チャンクの内容を見れるようにログを追加します。
server.tsxasync function logStream(stream: ReadableStream, decoder: TextDecoder) {
const reader = stream.getReader();
let i = 0;
while (true) {
const { done, value } = await reader.read(); // チャンクが届くまで待って読み出す
if (done) break;
console.log(`chunk ${i++}:`, decoder.decode(value));
}
}
--------
// streamを返す
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ["/main.js"],
});
// streamをteeして2つのstreamに分割
const [stream1, stream2] = stream.tee();
const decoder = new TextDecoder();
// stream2をログ出力
logStream(stream2, decoder);
return new Response(stream1, {
headers: { "Content-Type": "text/html" },
});
ここでstream.tee()というメソッドが出てきましたが、tee()1を使うとストリームを2つに分岐させてそれぞれを処理することができます。今回で言うと、Responseを返す用のstream1と、ログを出力するためのstream2です。streamをgetReader()すると、そのストリームは他から読むことができなくなるため、用途に応じて分岐させないといけません。
ここで、ログを確認してみます。
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 id="root"><div><p>Count: <!-- -->0</p><button>Click me</button><!--$?--><template id="B:0"></template><p>Loading...</p><!--/$--></div></div><script>requestAnimationFrame(function(){$RT=performance.now()});</script><script src="/main.js" id="_R_" async=""></script>
chunk 0ではHTML全体が返され、SlowMessageの部分はSuspenseのフォールバック内容(=<p>Loading...</p>)が出力されていることがわかります。
chunk 1: <div hidden id="S:0"><p>データの取得が完了しました!</p></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></body></html>
そして非同期処理が完了するとchunk 1が出力され、<p>データの取得が完了しました!</p>というSuspenseの中身が表示されています。今回はhtmlタグ全体ではなく、<div hidden id="S:0">、Suspenseの中身の要素、<script>タグ、</body></html>の閉じタグのみが返ってきています。
時系列としては以下のようになっています。
- chunk 0が届く → 画面に「Loading...」が表示される
- main.jsのダウンロード開始(非同期でまだ完了していない)
- chunk 1が届く → インラインのスクリプトが即実行 → Loading... が「データの取得が完了しました!」に置き換わる
- main.js のダウンロード完了 → hydrateRoot 実行 → イベントハンドラがアタッチされる
前提として、インラインのスクリプトと外部スクリプトには以下の違いがあります。
<!-- インラインスクリプト: ブラウザがHTMLを読み進めてscriptタグに到達した時点で実行(B:0をS:0で差し替える) -->
<script>$RC("B:0", "S:0")</script>
<!-- 外部スクリプト: ファイルをダウンロードしてから実行 -->
<script src="/main.js" async></script>
chunk 1が届いた瞬間にインラインスクリプトでSuspenseの中身をDOM操作で差し替え、ハイドレーション(イベントハンドラのアタッチなど)は外部スクリプトのダウンロード完了後に行う、という形で視覚的なフィードバックとインタラクティブ化を段階的に行っています。
段階的にHTMLが返ってきているStreaming SSRでは、どの部分を即座に送り、どの部分をSuspenseで遅延させるかの設計が重要です。ここでReactの「シェル」という概念を見ていきます。
3. シェルとは何か
React公式では、シェル(英語でshell2)とは以下のように説明されています。
アプリの全
<Suspense>バウンダリより外にある部分のことをシェル (shell) と呼びます。
つまり、ReactツリーのどのSuspenseにも囲まれていない部分のことを指します。
renderToReadableStreamでは、シェル全体のレンダーが完了したらstreamを開始します。そのため、例えば、<head>やページタイトルなど必要不可欠な要素をシェルとし、非同期で取得するデータなど後から揃えば問題ないコンテンツはSuspenseで囲んでストリーミングする、といった設計が必要になります。
シェル内のエラー
では、シェルのレンダー中にエラーが発生したらどうなるでしょうか? 例えば、サーバー上でAPIリクエストをしたら400エラーが返ってきた場合はどうでしょうか?
シェル内のエラーはページを描画するのに意味のある必要な情報を揃えられなかったことになり、エラーがthrowされます。そのため、renderToReadableStreamの外側にtry...catchを設定して、なんらかのフォールバックを返すようにします。
<SlowMessage>の外側にある<Suspense>を削除しシェル内のコンポーネントとした上で、fetchMessage内のresolveをrejectに変えてエラーを発生させます。また、renderToReadableStreamの外側にtry...catchを設定し、<h1>Something went wrong</h1>という500ステータスのレスポンスを返すようにします。ローカルを再起動すると5秒後にSomething went wrongが表示されました。
シェル外のエラー
一方でシェル外のエラーはどうなるでしょうか?
<SlowMessage>の外側に<Suspense>を再度追加してシェル外とし、そのSuspenseの親にreact-error-boundaryからインポートした<ErrorBoundary fallback={<p>SlowMessage failed to load</p>}>を追加します。また、renderToReadableStreamにonErrorを追加追加してサーバー側でエラーをハンドリングできるようにします。
すると、以下のようなchunk 1が返ってきます。
chunk 1: <script>$RX=function(b,c,d,e,f){var a=document.getElementById(b);a&&(b=a.previousSibling,b.data="$!",a=a.dataset,c&&(a.dgst=c),d&&(a.msg=d),e&&(a.stck=e),f&&(a.cstck=f),b._reactRetry&&b._reactRetry())};;$RX("B:0","","Switched to client rendering because the server rendering errored:\n\nデータの取得に失敗しました","Switched to client rendering because the server rendering errored:\n\nError: データの取得に失敗しました\n at \u003canonymous> (/Users/s31730/workspace/ssr-playground/step1/Content.tsx:17:18)","\n at SlowMessage (/Users/s31730/workspace/ssr-playground/step1/Content.tsx:25:19)\n at Suspense (\u003canonymous>)\n at y (/Users/s31730/workspace/ssr-playground/node_modules/react-error-boundary/dist/react-error-boundary.cjs:1:213)\n at div (\u003canonymous>)\n at Content (\u003canonymous>)\n at div (\u003canonymous>)\n at body (\u003canonymous>)\n at html (\u003canonymous>)\n at App (\u003canonymous>)")</script></body></html>
scriptタグと、body・htmlの閉じタグのみが返ってきており、Switched to client rendering because the server rendering erroredのような文字列があることがわかります。
これは書いてある通り、サーバーサイドレンダリング中にエラーが発生したのでクライアントレンダリングに切り替えており、クライアントでの再試行でも失敗した場合は、最も近いエラーバウンダリのフォールバックが表示されます。
1つ目のログでクライアントに切り替えたこと、3つ目のログででエラーバウンダリのフォールバックを表示することが書かれている②でやること
次の記事では、以下を試してみようと思います!
- ルーティングを追加して、複数のページでSSRに対応する
- CDNを使ってSSRの結果をキャッシュする