📦

React の renderToPipeableStream って何??

に公開

前置き

renderToPipeableStream を使って SSR をサンプルで実装しました(React のリポジトリの fixtures を参考にしました)。記事に出てくるサンプルのコードの全量を見たい方は以下のリポジトリはご参照ください。

https://github.com/doz13189/ssr-streaming/tree/main

ちなみに、サンプル実装していて、気になったけど細かいすぎることは記事の最後のオマケに書いていこうと思います。

(本題) React の renderToPipeableStream って何??

React Tree をストリーミング可能な形式で生成する API です。 レンダリングする React Tree を小さな塊(チャンク)に分割にして、レンダリング可能となった React Tree から、順次、レスポンスとして返していきます。

すぐにレンダリング可能な React Tree は、例えば、文字列のみを持つコンポーネントです。逆にすぐにレンダリング不可能なコンポーネントは、非同期処理を持つコンポーネントです。renderToPipeableStream では非同期処理を持つコンポーネントの処理完了を待たずに、レンダリング可能な React Tree から順次、レスポンスとして返していきます。

renderToPipeableStream を使ったサンプル実装

非同期処理をする <Content /> というコンポーネントを持つ <App /> を renderToPipeableStream を使って SSR します。

export default function App() {
  return (
    <Html title="Hello">
      <Suspense fallback={<>Loading</>}>
        <ErrorBoundary FallbackComponent={<>Error</>}>
          <Content /> // 外部リソースが必要な非同期コンポーネント
        </ErrorBoundary>
      </Suspense>
    </Html>
  );
}

<Content /> は以下のような実装です。5000ms 後に非同期処理が完了し、レンダリングされるコンポーネントです。

const sleep = (ms) => {
  return new Promise((resolve) => setTimeout(resolve, ms));
};

function Content() {
  use(sleep(5000));
  return <p>Hello World</p>;
}

サーバーはアクセスがあった場合に、<App /> をレンダリングした上で、ストリーミング形式でレスポンスを返していきます。以下の箇所で renderToPipeableStream を使っています。

const ssrStream = renderToPipeableStream(<App />, {

大事なポイントとして、<Content /> は非同期処理をしているため、非同期処理が完了するまでレンダリングする React Tree は確定しません。 従来の SSR では <Content /> は非同期処理の完了を待っていました。

しかし、renderToPipeableStream は、<Content /> の非同期処理の完了を待たずにレンダリング可能な部分のみを React Tree として生成します。

この時、<Content /> 部分はどうなるのか...と言うと、Suspense の fallback に定義されているコンポーネントである <>Loading</><Content /> の代わりにレスポンスとして返されます。

まとめると、renderToPipeableStream が返す第1弾のレスポンスは、<App /> を返すのですが、その時、<Content /> の代わりに <>Loading</> が返されています。第1弾のレスポンスを受け取ったクライアントは以下を html として表示することになります。

<!DOCTYPE html>
<html lang="en">
   <head>
      <meta charSet="utf-8"/>
      <meta name="viewport" content="width=device-width, initial-scale=1"/>
      <link rel="shortcut icon" href="favicon.ico"/>
      <title>Hello</title>
   </head>
   <body>
      <!--$?-->
      <template id="B:0"></template>
      Loading
      <!--/$-->
   </body>
</html>

第1弾のレスポンスを受け取ったクライアントで、表示される内容は以下のようになります。

ただ、まだ第2弾があります。第2弾は <Content /> の非同期処理が完了したタイミングです。非同期処理完了によって Hello World という文字列をレスポンスとして返します(script タグについては記事の最後のオマケで触れます)。

<div hidden id="S:0">
   Hello World<!-- -->
</div>
<script>function $RC(a,b){a=document.getElementById(a);b=document.getElementById(b);b.parentNode.removeChild(b);if(a){a=a.previousSibling;var f=a.parentNode,c=a.nextSibling,e=0;do{if(c&&8===c.nodeType){var d=c.data;if("/$"===d)if(0===e)break;else e--;else"$"!==d&&"$?"!==d&&"$!"!==d||e++}d=c.nextSibling;f.removeChild(c);c=d}while(c);for(;b.firstChild;)f.insertBefore(b.firstChild,c);a.data="$";a._reactRetry&&a._reactRetry()}};$RC("B:0","S:0")</script>

第2弾のレスポンスを受け取ったクライアントで、表示される内容は以下のようになります。

このように第1弾、第2弾...とストリーミング形式で React Tree を返していくのが renderToPipeableStream となります。

従来の SSR の問題点

<Content /> は非同期処理が完了するまでレンダリングする React Tree は確定しません。従来の SSR では <Content /> は非同期処理の完了を待っていました。

サラッと前述はしましたが、従来の SSR の場合は、全ての非同期処理が完了したタイミングでクライアントサイド(ブラウザ)にレスポンスを返していました。非同期処理のコンポーネントがページ全体のレンダリングを待たせてしまう...というような状況が発生しうるため、効率的ではありませんでした。

このような状況は望ましくないため、非同期処理の完了を待たずして、レスポンスを返すのが renderToPipeableStream です。

ちなみに、従来の SSR では、クライアントサイド(ブラウザ)にレスポンスを返す箇所で renderToString が使用されていました。renderToString は基本的には React Tree を文字列に変換するシンプルな API です。

https://react.dev/reference/react-dom/server/renderToString

renderToString は当然ながらストリーミングには対応しておらず、renderToString を使って SSR をすると先に述べた従来の SSR の問題点を引き起こすことになります。

The existing renderToString method keeps working but is discouraged.
https://react.dev/blog/2022/03/29/react-v18#react-dom-server

renderToPipeableStream が登場したことによって、renderToString は非推奨となったようです。renderToPipeableStream ではクライアントサイド(ブラウザ)に対して複数回のレスポンスを返す分、通信回数は増えるように思います。それでも複数回のレスポンスに小分けにして、初期表示を早める方がユーザー体験が向上するケースが多いのだと思います。

そもそもストリーミングって何?

ストリーミングとは、データを小さな単位(チャンク)に分割して扱う処理方式を指します。よくある用途としては、動画や画像の読み込みであり、ファイル全体のサイズがメモリを上回ると、一括で処理できないため、チャンクに分割して扱ったりします。

Node では以下の API を使ってストリーム形式でデータを扱うことができます。これがrenderToPipeableStream の内部実装で使われています。

https://nodejs.org/dist/latest-v18.x/docs/api/stream.html#stream

ミニマムのストリーミングのサンプル実装だと、以下のような形でファイルをストリーム形式で読み込みます。

const fs = require('fs');
const reader = fs.createReadStream('./example.txt');
reader.on("data", (chunk) => console.log(`Received ${chunk.length} bytes of data.`));

> Received 15 bytes of data.

renderToPipeableStream では、レンダリング可能なコンポーネントをチャンクとして返していきます。前述の例だと以下が1つのチャンクとなります。

<!DOCTYPE html>
<html lang="en">
   <head>
      <meta charSet="utf-8"/>
      <meta name="viewport" content="width=device-width, initial-scale=1"/>
      <link rel="shortcut icon" href="favicon.ico"/>
      <title>Hello</title>
   </head>
   <body>
      <!--$?-->
      <template id="B:0"></template>
      Loading
      <!--/$-->
   </body>
</html>

React のソースコードには以下の箇所で、チャンクの作成がされています。
https://github.com/facebook/react/blob/154b85213a65377a67ac4f7a4a39116024bc3028/packages/react-dom/src/server/ReactDOMFizzServerNode.js#L44

renderToPipeableStream が使えるようになったバージョン

renderToPipeableStream は React18 でリリースされた API です。renderToPipeableStream は Node 用の API で、Deno などを使う場合は renderToReadableStream を使います。

https://react.dev/blog/2022/03/29/react-v18#react-dom-server

Next.js でも使用されており、ドキュメントを見る限りだと 13.4 でリリースされた App Router を使えば、おそらく renderToPipeableStream が内部的に使われているのだと思います。

https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming

まとめ

React Server Components (RSC) でも renderToPipeableStream は利用されています(むしろ、RSC での利用が本懐か...)。RSC はまだ実験的段階ですが、renderToPipeableStream は React18 のリリースに含まれています。Next.js では App Router の機能を経由して使用することもできます。

RSC の文脈で一緒に登場することの多い renderToPipeableStream ですが、SSR での利用も可能であり、あえて、RSC から切り離してまとめてみました。

(オマケ) チャンクを眺めてみる

renderToPipeableStream では、非同期コンポーネントは後続のチャンクで送られてくるわけですが、どのように Suspense の fallback のコンポーネントと非同期処理が完了したコンポーネントを入れ替えているのでしょう...などなど、細かい疑問がチャンクを眺めていると解決したので、おまけとして書いていきます。

眺めるチャンクを貼っておく

以下にあるのはサンプル実装の <App /> をレンダリングする上で、クライアントサイド(ブラウザ)が最終的に受け取るものです。以下にあるものは統合していますが、実際にはこれらが小分けにチャンクとして返されます。

<!DOCTYPE html>
<html lang="en">
   <head>
      <meta charSet="utf-8"/>
      <meta name="viewport" content="width=device-width, initial-scale=1"/>
      <link rel="shortcut icon" href="favicon.ico"/>
      <title>Hello</title>
   </head>
   <body>
      <!--$?-->
      <template id="B:0"></template>
      Loading<!--/$-->
   </body>
</html>
<div hidden id="S:0">
   Hello World<!-- -->
</div>

<script>
function $RC(a, b) {
  a = document.getElementById(a);
  b = document.getElementById(b);
  b.parentNode.removeChild(b);
  if (a) {
    a = a.previousSibling;
    var f = a.parentNode,
      c = a.nextSibling,
      e = 0;
    do {
      if (c && 8 === c.nodeType) {
        var d = c.data;
        if ('/$' === d)
          if (0 === e) break;
          else e--;
        else ('$' !== d && '$?' !== d && '$!' !== d) || e++;
      }
      d = c.nextSibling;
      f.removeChild(c);
      c = d;
    } while (c);
    for (; b.firstChild; ) f.insertBefore(b.firstChild, c);
    a.data = '$';
    a._reactRetry && a._reactRetry();
  }
}
$RC('B:0', 'S:0');
</script>

謎の文字列 $?/$

Suspense の callback として定義した <>Loading</> は以下のような形式でチャンクに含まれています。気になるのは $?/$ です。

<!--$?-->
<template id="B:0"></template>
Loading<!--/$-->

これは Suspense の境界を表しており、非同期処理が完了したタイミングで callback として定義したコンポーネントは、非同期処理が完了したコンポーネントに置換されるわけですが、$?/$ は置換対象の目印となっています。

$?/$ は、以下に React リポジトリで定義されています。

https://github.com/facebook/react/blob/0b974418c9a56f6c560298560265dcf4b65784bc/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSetShared.js#L7-L8

hidden 属性での定義

非同期処理が完了したタイミングで送られてくるチャンクは2つに分類できそうです。1つは非同期処理が完了したコンポーネントで、これには hidden 属性が付与されています。

<div hidden id="S:0">
   Hello World<!-- -->
</div>

2つ目は、script です。この script が Suspence の fallback のコンポーネントを削除して、非同期処理によって得られた結果を差し込んでいるようです。script による処理によってコンポーネントが表示されるため、チャンクでは hidden 属性を付与してユーザーに見えないようにしているのかな、と思っています。

<script>
function $RC(a, b) {
  ...省略
}
$RC('B:0', 'S:0');
</script>

script はトランスパイル後なので読みづらいですが、元のコードはおそらく以下の箇所だと思われます。

https://github.com/facebook/react/blob/0eaca37565b3aeb0d967bf403029a2d39d831ad4/packages/react-dom-bindings/src/server/fizz-instruction-set/ReactDOMFizzInstructionSet.js#L143

なぜ空の template ?

Suspence の fallback に定義したコンポーネントは空の template の近くに定義されており、唐突の template タグに困惑しました。

<!--$?-->
<template id="B:0"></template>
Loading<!--/$-->

これは、React リポジトリにコメントとして解答が記載されており、理由は、template タグがどこにでも差し込めるかららしいです。

https://github.com/facebook/react/blob/51a7c45f8799cab903693fcfdd305ce84ba15273/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js#L2691-L2693

参考

https://github.com/facebook/react/tree/main/fixtures/ssr2

https://tech-blog.lakeel.com/n/n62073e6f3101

https://jser.dev/react/2023/03/30/progressive-hydration/

Discussion