ReactのStreaming SSRをエッジでやる
React18のストリーミング機能とCloudflare WorkersやDenoのStreams APIが気になっていたので、それらを組み合わせて遊んでみた。というかこの2つの組み合わせこそがReactのやりたいことだと思います。ところで、Reactよく分かってないので、ツッコミどころあるかもしれませんが、優しくしてください。
やりたいこと
SSRしたHTMLをStreamで返却し、コンテンツの準備ができたらSuspenseの内側だけを差し替える。これを全部、HTTP Streamingでやるのがミソ。結果当然ながら、パフォーマンスが上がったりいいことが起こる。
Suspense
ReactのSuspenseとはたぶん、Suspenseの外側を先に描画して、Suspenseの内側で投げたPromiseが完了するまでにfallbackを表示するってことだと思います。外部APIのレスポンスを待つとかがリアルワールドのユースケースでしょうけど、今回の場合は一定時間sleepして、その後にHello Worldを返却するコンポーネントを作成します。
const Component: React.FC = () => {
if (done) {
done = false
return (
<p>
<b>Hello World!!</b>
</p>
)
}
throw new Promise((resolve) =>
setTimeout(() => {
done = true
resolve(true)
}, DELAY)
)
}
このコンポーネントをSuspenseでくるみます。fallbackは中身がない時に描画する内容です。
<Suspense fallback={<p>Loading...</p>}>
<Component />
</Suspense>
たぶん、これで合っていると思う。
ReactDOMServer.renderToReadableStream
ReactDOMServer.renderToReadableStream()がReact18で追加されました。
ReactDOMServer.renderToReadableStream(element, options)
React 要素を初期状態の HTML にストリーミングします。Readable Stream として resolve する Promise を返します。サスペンスと HTML ストリーミングを完全にサポートします。
とのこと。興味深いのはReactDOMServerにはNode用にrenderToPipeableStream()というメソッドがあるのですが、注意書きにこう書いてあります。
これは Node.js 専用の API です。Deno やモダンなエッジランタイムのような Web Stream の環境では、代わりに renderToReadableStream を使用してください。
Deno!モダンなエッジランタイム!たぶん、Cloudflare Workersとかのことですね!!
ReadableStream
Cloudflare WorkersとかDenoにはStreams APIが使えてて、これは何に使うのか?と思っていたけど、今回のユースケースなんですね。
ちなみにDenoの公式サイトのExamplesの中にはReadableStreamを使ってHTTP Streamingをする例が載ってます。
そういえば、HTTP Stremingって、WebSocketsとかCommetとかそういう文脈で昔触った気がします。
Cloudlfare WorkersでStreaming SSRする
ReactDOMServer.renderToReadableStream()はReadableStreamを返します。上記の通り、Cloudlfare WorkersやDenoというエッジランタイムでは、Streams APIが使えて、こいつも扱えます。具体的にはResponseオブジェクトのbodyに直接渡すだけでOKです!
const app = new Hono()
app.get('/', async (c) => {
const stream = await renderToReadableStream(
<html>
<body>
<Suspense fallback={<p>Loading...</p>}>
<Component />
</Suspense>
</body>
</html>
)
return new Response(stream, {
headers: {
'X-Content-Type-Options': 'nosniff',
'Content-Type': 'text/html',
},
})
})
おお、簡単です!これだけで、Streaming SSRができます!クライアントコードを生成して、hydrateすることもできますが、今回はあえてしません(ReactDOM.hydrateRoot()を使うといいらしい)!ちなみに、Cloudflare Workersで動かすので、Wranlgerが使えます。WranglerはトランスコンパイルなしでTypeScript/TSXを渡せるので便利です。
試す
では、デプロイします。
Chromeでアクセスすると、当初「Loading...」と表示されますが、2秒後に「Hello World!!」となります。

これらが全てSSRされた1枚のページで行われているのが驚きです。

具体的な挙動は以下のcurlを叩いた様子を見れば分かります。

リクエストを飛ばしてすぐにSSRされたHTMLが返却されます。Suspenseの中身がなくfallbackが表示されている状態です。ただ、Streamingなので、レスポンスはペンディングの状態です。そして、2秒後に追加のリソースが返却され、レスポンスが閉じます。そのリソースにはJavaScriptが記載されていて、ブラウザ上でSuspenseの中身を書き換えています。
面白いですね。以下がデモURLです。
Safariで動かない
これ、PCのChromeでは動くんですが、Safariでは謎に動きません。text/plainにして、プレーンテキストならば動くので、HTMLはレスポンスが完了しない限り描画してないっぽいです。どなたか解決策を知っていたら教えて下さい。
まとめ
エッジでSSRするし、配信もWeb標準のStreams APIを使ってHTTP Streamingをして、それをSuspenseで埋め込む。同じことをやっている文献が他にほとんどないのですが、これがReactのやりたいことだと思います。面白かったです。
以下、今回使ったコードです!
Discussion
確認していないので間違っているかもしれませんが、
ブラウザがHTMLのレンダリングをはじめるための閾値があるそうです。
最初に送信されるチャンクサイズが小さすぎるのかも。
古い情報ですが参考として。
おお、面白い情報ですね。ありがとうございます!