🤙

ReactのStreaming SSRをエッジでやる

2022/07/05に公開2

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をする例が載ってます。

https://examples.deno.land/http-server-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!!」となります。

output

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

SS

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

output

リクエストを飛ばしてすぐにSSRされたHTMLが返却されます。Suspenseの中身がなくfallbackが表示されている状態です。ただ、Streamingなので、レスポンスはペンディングの状態です。そして、2秒後に追加のリソースが返却され、レスポンスが閉じます。そのリソースにはJavaScriptが記載されていて、ブラウザ上でSuspenseの中身を書き換えています。

面白いですね。以下がデモURLです。

https://react-streaming-ssr.yusukebe.workers.dev/

Safariで動かない

これ、PCのChromeでは動くんですが、Safariでは謎に動きません。text/plainにして、プレーンテキストならば動くので、HTMLはレスポンスが完了しない限り描画してないっぽいです。どなたか解決策を知っていたら教えて下さい。

まとめ

エッジでSSRするし、配信もWeb標準のStreams APIを使ってHTTP Streamingをして、それをSuspenseで埋め込む。同じことをやっている文献が他にほとんどないのですが、これがReactのやりたいことだと思います。面白かったです。

以下、今回使ったコードです!

https://gist.github.com/yusukebe/232f14efae731a6ed2afa12ae370b0d0

Discussion

azechiazechi

......PCのChromeでは動くんですが、Safariでは謎に動きません......

確認していないので間違っているかもしれませんが、
ブラウザがHTMLのレンダリングをはじめるための閾値があるそうです。
最初に送信されるチャンクサイズが小さすぎるのかも。

古い情報ですが参考として。
https://stackoverflow.com/questions/16909227/using-transfer-encoding-chunked-how-much-data-must-be-sent-before-browsers-s/16909228#16909228