📶

SSR StreamingのStreaming部分ってなんだ?

2024/06/11に公開

はじめに

React, Next.jsの比較的新しい機能の一つにSSR Streamingがあります。
ざっくり説明すると、「SSR時点で重い処理を含むコンポーネントのレスポンスを遅延させて、軽いものから先に描画・インタラクティブにしよう!」的なものです。

SSR Streamingの理解には以下の記事が大変参考になりました。

https://zenn.dev/shinbey/articles/8acce14b8b60e3

https://frontenddesign.codelly.dev/guide/architecture/Streaming-Server-Side-Rendering.html#ssrの問題点

しかし、私はストリームという技術の概要とメリットはなんとなく理解しつつも、その実現方法・具体的な使い方はあやふやな状態でした。
なので、本記事ではストリームとはそもそも何かと振り返りつつ、App Routerでの実現方法にも踏み込むことで理解を深めてみようと思います。

対象読者

  • SSR Streamingの概要をなんとなーく知っている
  • ストリーム?なんか小さく分割して送受信するやつでしょ?ぐらいの認識

本記事の目標

  • ストリームの概要を復習
  • HTTP通信でのストリームの仕組みを知る
  • SSR Streamingの実現方法に対するイメージがつく (後からDOM操作するスクリプトを返してるか〜みたいにざっくり)

ストリームとは?

そもそもストリームとは、データを比較的小さいチャンクに分けて入出力を行うものです。
この小さいチャンクに分割する処理が、使用メモリの節約や処理の高速化に繋がってきます。
この入出力の部分は抽象化されていて、ファイル・ソケット・標準I/Oなど様々なものを扱うことができます。

実装に関しては、Node.jsのストリームですが以下の記事が参考になりました。

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

例えば読み込みだと、対象のリソースからチャンクごとに内部バッファに取り入れ、イベントでプログラム側から扱うことができるみたいです。

HTTP通信でのストリーム

ストリームは色々な文脈で聞くことがありますが、HTTP通信ではどのような仕組みになっているのでしょうか?
調べると、MDNに対応するHTTP Headerの Transfer-Encodinge: chunked がありました。

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Transfer-Encoding

この方式では、コンテンツを一度に送らずチャンクに分けて送信します。
それぞれのチャンクには先頭に現在のチャンクの長さを16進数の形式で追加し、\r\nで区切ります。
その後にチャンクのコンテンツと最後に\r\nで再び区切ります。
通信の終了には長さ0のチャンクを送信することで終了を合図しています。

MDNに例があったので、この仕様と照らし合わせてみてください。

HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked

7\r\n
Mozilla\r\n
9\r\n
Developer\r\n
7\r\n
Network\r\n
0\r\n
\r\n

Next.jsのSSR Streamingに対応したレスポンスを確認すると、確かにこのヘッダーがありました。

小さい環境でストリームを試す

理解をするためにはとりあえず実装してみると良いので、Nodeのhttpモジュールで小規模なプログラムを書いてみます。

以下は初期表示から3秒後に「after waiting」を表示するコードになります。

import http from "http";

const port = 8000;

const server = http.createServer(async (req, res) => {
  await streamingHTMLResponse(res);
});

function timer(ms: number) {
  return new Promise<void>((resolve) => {
    setTimeout(() => resolve(), ms);
  });
}

async function streamingHTMLResponse(res: http.ServerResponse) {
  res.writeHead(200, {
    "Content-Type": "text/html",
  });

  res.write(`
  <html>
    <body>
      <p>initial view</p>
    `);

  await timer(3000);

  res.write(`
      <p>after waiting</p>
    </body>
  </html>
  `);
  res.end();
}

server.listen(port);

ブラウザから表示すると、次のように表示が切り替わります。
transition

httpモジュールはデフォルトで「Transfer-Encodinge: chunked」に対応しているみたいなので、res.writeを複数回呼ぶことで実現できました。

1個目のチャンクにはhtmlタグを途中まで送っています。
後半の閉じタグが未送信なので何も表示されないのでは?と思いましたが、Chrome側が自動で補完しているみたいです。

2個目のチャンクには3秒後にコンテンツと閉じタグを送っています。

ポイントは、開発者ツールのネットワークを見ると一つのコネクションの中で完結していることです。
ブラウザのタブにあるローディング表記も、3秒後まで消えることなく表示されています。Ajaxとは明確に異なりますね。

App Routerでの実現方法の考察

実装例

HTTPのストリームがわかったところで、本題であるSSR Streamingのストリーム部分の実現方法を確認してみましょう。

とりあえずSSR StreamingをApp Routerで試してみます。バージョンはnext@14.2.3です。
App RouterではSuspenseの中に非同期のコンポーネントを置くだけで、いい感じにやってくれます(凄い)

以下のコードは、HeavyComponentという処理が重たいコンポーネントがある想定で、後から中身を表示するものになります。
デフォルトでページがstatic renderingになるので、revalidateを0にしてdynamicに変更します。これをしないと、ビルド時にHeavyComponentが計算されてキャッシュされます。

// app/page.tsx
export const revalidate = 0;

export default function Home() {
  return (
    <main className="p-4">
      <Suspense fallback={<p>Loading...</p>}>
        <HeavyComponent />
      </Suspense>
    </main>
  );
}

// components/HeavyComponent.tsx
export async function HeavyComponent() {
  await timer(5000);

  return (
    <div>
      <p>Heavy Component</p>
    </div>
  );
}

画面の遷移

transition

通信の考察

開発者ツールのネットワークを確認すると、初期リクエストの通信が完了せず、bodyの閉じタグの前で次のレスポンスを待機していることがわかります。

response

5秒後にこの通信は完了して、HeavyComponentレンダリング結果のRSC Payload, HTMLと$RCというSuspense箇所に挿入するスクリプトが含まれています。

response2

$RC("B:0", "S:0")がポイントで、恐らく"B:0"(Suspenseの箇所)”S:0"(HeavyComponentのDOM)を移しています。

SSR Streamingはこのように実現されているみたいです。
先ほど試した小規模のストリームと似ています。
HTTP Headerを確認しても, 確かに「Transfer-Encodinge: chunked」がありました。

さいごに

SSR Sreamingのストリーム部分について、調べたことをまとめてみました。
皆さんの理解の補助になれば、嬉しい限りです!
ここまで読んでいただき、ありがとうございました!

参考

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

Discussion