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のレンダリングをはじめるための閾値があるそうです。
最初に送信されるチャンクサイズが小さすぎるのかも。
古い情報ですが参考として。
おお、面白い情報ですね。ありがとうございます!