# Streaming Server-Side Rendering

Streaming Server-Side Renderingとは段階的にSSRを実行しクライアントへ配信するレンダリング方法です。 一度に全てのコンポーネントをレンダリングするのではなく、段階的に細かく分けてレンダリングを実行し、生成されたHTMLからクライアントへ配信します。 React18からは、Suspenseを使うことでコンポーネント単位でStreamingが可能となっています。

# SSRの問題点

なぜ、Streaming Server-Side Renderingが必要とされるのでしょうか。 Streaming Server-Side Renderingの必要性を理解するために、SSRの仕組みと問題点を見てみましょう。

SSRが実行されクライアントへ配信するまでのステップはサーバとクライアントで、次のようになります。

サーバ側で実行

  1. ブラウザがサーバへリクエストをする
  2. サーバはリクエストを受け取り、Reactのレンダリングを開始する
  3. コンテンツを生成するための商品データをAPIサーバに問い合わせる
  4. APIサーバから商品データを受け取り、コンテンツを生成する
  5. 生成したコンテンツ(HTML)をブラウザ(クライアント)に返す

クライアント側で実行

  1. ブラウザ側でHTMLファイル解析し、JavaScriptファイルをダウンロードする
  2. JavaScriptファイルを実行しReactを起動する
  3. すでに生成されたコンテンツ(HTML)に対して、ハイドレーションを実行する
  4. ハイドレーション後、アプリケーションが機能する状態になる

SSRでは、サーバ側でコンテンツを生成しクライアントへ配信します。 その後、クライアントでハイドレーションを実行しDOMにイベントをバインディングします。 ハイドレーションが完了すると、アプリケーションが機能する状態となります。

SSRの問題点 1

SSRではサーバ側でコンテンツを生成するため、FCPの改善が見込まれます。 しかし、同時に次のような問題が発生します。

  • サーバの処理が遅れた場合、ブラウザー画面に何も映らない
  • クライアント側で一部のハイドレーションが遅れた場合、ハイドレーションが完了するまでアプリケーション全体が機能しない

APIサーバへのデータ取得が遅れた場合、クライアントへの配信もその分遅くなります。遅れている間は、ブラウザー画面には何も映らないため、ユーザーから見ると真っ白の画面が表示され続けることになります。

Streaming-SSR-SSRの問題点-2.png

また、一部のコンポーネントのハイドレーションが遅れると、アプリケーション全体のハイドレーションが完了しません。 そのため、すでに他のコンポーネントのハイドレーションが完了していても、機能するまでの時間(TTI)が遅れるという問題が発生します。

SSRでは、全てのコンポーネントが完了していないとクライアントへの配信ができません。 たとえ、ほとんどのコンポーネントのレンダリングが完了していても、一つ重い処理があればその分遅れてしまいます。

Streaming Server-Side Renderingは、段階的にレンダリングを実行することでこのような問題を解決することができます。 重い処理を後回しにして先に完了したコンポーネントのみを配信することで、初回表示のパフォーマンスが向上します。

例えば、ヘッダー、サイドバー、フッターや一部のコンテンツは先に配信しておけば、ブラウザーに何も映らないという状況を回避できます。また、レンダリング負荷が高いコンテンツの場合、最初はローディングを表示しておいて、段階的に適用すれば、ハイドレーション時の負荷も抑えられます。

Streaming-SSR-SSRの問題点-3.png

# Streaming Server-Side Renderingの仕組み

Streaming Server-Side Renderingの仕組みをReactを例に見てみましょう。

次のような構成のニュースサイトを想定しましょう。

Streaming-SSR-仕組み-1.png

ヘッダー、フッター、サイドバー、トップニュースとコメントのセクションで分かれています。このうち、コメント部分が重い処理とします。

# SSRで実装

はじめに、通常のSSRで実装してみましょう。

// APIからデータを取得
const content = await fetchContent();
const comments = await fetchComments();

res.send(
  renderToString(
    <>
      <html>
      <head>
        <link rel="stylesheet" href="/index.css" />
      </head>

      <body>
      <div id="app">
        <App content={content} comments={comments} />
      </div>
      </body>
      </html>
    </>
  )
);

renderToString (opens new window)はコンポーネントからHTMLを生成します。ここではAPIからデータを取得してHTMLを生成し、レスポンスとしてクライアントに送信しています。

Streaming-SSR-仕組み-1.png

この段階ではただのHTMLのため、Reactが起動していません。そのため、DOMにイベントがバイディングされておらずコメントを追加するが機能しません。

ブラウザー側でReactを起動するため、次のようなscriptを読み込ませます。

















 






// APIからデータを取得
const content = await fetchContent();
const comments = await fetchComments();

res.send(
  renderToString(
    <>
      <html>
      <head>
        <link rel="stylesheet" href="/index.css" />
      </head>

      <body>
      <div id="app">
        <App content={content} comments={comments} />
      </div>
      <script src="/main.js"></script>
      </body>
      </html>
    </>
  )
);

<script src="/main.js"></script>はハイドレーションをするためのコードになります。

import React from "react";
import { hydrateRoot } from "react-dom/client";

import App from "./App";

hydrateRoot(
  document.getElementById("app"),
  <App comments={[]} content={""} />
);

hydrateRootでハイドレーションが実行され、アプリケーションが機能する状態となります。

しかし、このコードを実行すると以下のようなエラーが発生します。

react-dom.development.js:14643 Uncaught Error: Hydration failed because the initial UI does not match what was rendered on the server.
    at throwOnHydrationMismatch (react-dom.development.js:14643:9)
    at popHydrationState (react-dom.development.js:14844:9)
    at completeWork (react-dom.development.js:25195:31)
    at completeUnitOfWork (react-dom.development.js:30666:16)
    at performUnitOfWork (react-dom.development.js:30555:5)
    at workLoopSync (react-dom.development.js:30429:5)
    at renderRootSync (react-dom.development.js:30382:7)
    at performConcurrentWorkOnRoot (react-dom.development.js:29657:74)
    at workLoop (scheduler.development.js:276:34)
    at flushWork (scheduler.development.js:245:14)

これは、サーバ側で生成されたHTMLとブラウザー側で生成したHTMLに不一致が生じているためです。

サーバではトップニュースとコメントを取得していました。

 
 
 











 








// APIからデータを取得
const content = await fetchContent();
const comments = await fetchComments();

res.send(
  renderToString(
    <>
      <html>
      <head>
        <link rel="stylesheet" href="/index.css" />
      </head>

      <body>
      <div id="app">
        <App content={content} comments={comments} />
      </div>
      <script src="/main.js"></script>
      </body>
      </html>
    </>
  )
);

しかし、ブラウザーではデータがない状態になっています。








 


import React from "react";
import { hydrateRoot } from "react-dom/client";

import App from "./App";

hydrateRoot(
  document.getElementById("app"),
  <App comments={[]} content={""} />
);

これを解消するためにサーバからデータを渡してあげる必要があります。scriptタグに次のようなJSONデータを埋め込みましょう。
















 
 
 
 
 
 
 
 







const content = await fetchContent();
const comments = await fetchComments();

res.send(
  renderToString(
    <>
      <html>
      <head>
        <link rel="stylesheet" href="/index.css" />
      </head>

      <body>
      <div id="app">
        <App content={content} comments={comments} />
      </div>
      <script
        dangerouslySetInnerHTML={{
          __html: `window.__data__ = ${JSON.stringify({
            content,
            comments,
          })};`,
        }}
      ></script>
      <script src="/main.js"></script>
      </body>
      </html>
    </>
  )
);

これで、window.__data__にサーバで取得したデータを保持できます。

そして、ブラウザー側でこのデータを取得しReactに渡します。








 


import React from "react";
import { hydrateRoot } from "react-dom/client";

import App from "./App";

hydrateRoot(
  document.getElementById("app"),
  <App comments={window.__data__.comments} content={window.__data__.content} />
);

サーバとブラウザーで扱うデータの整合性を保つことでハイドレーションが実行され、イベントのバイディングが完了されます。

ハイドレーション完了後はコメント機能が動いていることが分かります。

# Streaming Server-Side Renderingで実装

SSRの例では、コメントの取得に時間がかかってしまい初回のレンダリングが遅れていました。

Streaming Server-Side Renderingでは、コメント部分のレンダリングが終わる前にその他のUIを先に配信させてみましょう。 コメント部分は、最初は読み込み中を表示させて、データ取得が完了したらコンテンツを表示するように変更します。

# コメント以外をレンダリングする

React18からは、renderToPipeableStream (opens new window)を使用してStreaming Server-Side Renderingを実装します。

const content = await fetchContent();
const comments = fetchComments();

const stream = renderToPipeableStream(
  <>
    <html>
    <head>
      <link rel="stylesheet" href="/index.css" />
    </head>

    <body>
    <div id="app">
      <App content={content} comments={comments} />
    </div>
    <script
      dangerouslySetInnerHTML={{
        __html: `window.__data__ = ${JSON.stringify({
          content,
        })};`,
      }}
    ></script>
    </body>
    </html>
  </>,
  {
    bootstrapScripts: ['/main.js'],
    onShellReady() {
      stream.pipe(res);
    },
  }
)

bootstrapScriptsでハイドレーションを実行するためのファイルを指定します。onShellReadyは初回のレンダリングが完了次第呼び出されます。ここでは、コメント以外のUIが生成されたタイミングでクライアントに送信します。

コメントのデータ取得で、非同期処理を解決していない点に注意してください。


 

const content = await fetchContent();
const comments = fetchComments();

コメントのデータ取得が完了する前にコンテンツをクライアントに配信したいので、ここではawaitを外してPromiseの解決を後回しにしています。

これにより、コメント以外のUIを即座に送信することができるようになりました。

Streaming-SSR-仕組み-4.png

# コメントをレンダリングする

次に、読み込み中の表示とコメントデータのPromiseの解決を実装しましょう。

はじめに、読み込み中を実装します。ReactではSuspense (opens new window)を使うことで非同期処理が完了するまで仮のUIを表示させることができます。

次のように非同期処理が伴うコンポーネントをSuspenseでラップします。



 
 
 


<div className="comments">
  <h2>コメント</h2>
  <Suspense fallback={<div>読み込み中...</div>}>
    <Comments comments={props.comments} />
  </Suspense>
</div>

fallbackにはデータ取得が完了するまで表示したいコンポーネントを指定します。ここでは、読み込み中を指定します。

Suspenseコンポーネントは内部のコンポーネントがPromiseをthrowするとサスペンドした状態になります。このサスペンドした状態を検知すると、fallbackで指定したコンポーネントが表示されます。そして、Promiseが解決されるとサスペンド状態が解除され、ラップしたコンポーネントが表示されます。

<Suspense fallback={<div>サスペンドすると表示される</div>}>
  {/* サスペンド状態が解除されたら表示される */}
  <Component />
</Suspense>

サスペンドした状態と解除はコンポーネント側でどのように実装するのでしょうか。React18からはuseというAPIが提供されており、これを使うとSuspenseの制御ができます。

具体的に使い方を見てみましょう。

CommentsコンポーネントにはPromiseオブジェクトを渡しています。

<div className="comments">
  <h2>コメント</h2>
  <Suspense fallback={<div>読み込み中...</div>}>
    {/* props.commentsはPromiseオブジェクト */}
    <Comments comments={props.comments} />
  </Suspense>
</div>

このPromiseがthrowされるとサスペンド状態になり、解決するとサスペンドが解除されます。

その一連の動作を保証してくれるのがuseAPIになります。

Promiseオブジェクトをuseに渡すことでサスペンドの制御をしてくれます。

Commentsコンポーネントで受け取ったPromiseをuseに渡します。これでSuspenseの制御が可能となります。






 



import { useState, use } from "react";

export function Comments(props) {
  const [comment, setComment] = useState("");
  // Promiseオブジェクトをuseに渡す。Promiseが解決されるまで(データ取得が完了するまで)サスペンド状態になる。
  const [comments, setComments] = useState(use(props.comments));

  ...

これでコメントのレンダリングが実装できました。

# ハイドレーション

最後に、コメント部分のハイドレーションを実装しましょう。

SSRでは、次のようにwindowオブジェクトへ直接JSON形式のデータを埋め込んでいました。

<script
  dangerouslySetInnerHTML={{
    __html: `window.__data__ = ${JSON.stringify({
      content,
      comments
    })};`,
  }}
></script>

そして、ハイドレーション実行時にデータを渡して整合性を保っていました。

// main.js

hydrateRoot(
  document.getElementById("app"),
  <App comments={window.__data__.comments} content={window.__data__.content} />
);

しかし、commentsはPromiseオブジェクトのためこのままJSON形式で渡すことはできません。Promiseが解決してからデータを渡す必要があります。

では、サーバからのPromiseをどのようにクライアントに渡すか見てみましょう。

はじめに、ハイドレーション実行時のcommentspropsにPromiseオブジェクトを渡します。

// main.js

const comments = new Promise((resolve) => {
  window.__setComments__ = (comments) => resolve(comments);
});

hydrateRoot(
  document.getElementById("app"),
  // `comments`propsにPromiseオブジェクトを渡す。
  <App comments={comments} content={window.__data__.content} />
);

このPromiseオブジェクトはCommentsコンポーネントに渡され、window.__setComments__が実行されると解決されます。

window.__setComments__はコメントデータを受け取り上記のPromiseへデータを渡します。

window.__setComments__を実行するために次のようなコンポーネントを用意しましょう。

import * as React from "react";
import { use } from "react";

export function SetComments(props) {
  const comments = use(props.comments);

  return (
    <script
      dangerouslySetInnerHTML={{
        __html: `window.__setComments__(${JSON.stringify(comments)})`,
      }}
    />
  );
}

SetCommentsコンポーネントはPromiseオブジェクトを受け取り、React.useを介してデータを取得します。そして、scriptタグにwindow.__setComments__(${JSON.stringify(comments)})と書くことでwindow.__setComments__を実行します。

実際にクライアントで実行されるコードを見るとイメージしやすいでしょう。クライアントで展開されたときは次のようなコードになっています。

<script>
  window.__setComments__([{ id: '1', name: 'コメント`' }])
</script>

そして、SetCommentsコンポーネントを追加します。






















 
 
 











const content = await fetchContent();
const comments = fetchComments();

const stream = renderToPipeableStream(
  <>
    <html lang="ja">
    <head>
      <link rel="stylesheet" href="/index.css" />
    </head>

    <body>
    <div id="app">
      <App content={content} comments={comments} />
    </div>
    <script
      dangerouslySetInnerHTML={{
        __html: `window.__data__ = ${JSON.stringify({
          content,
        })};`,
      }}
    ></script>
    <Suspense fallback={<script></script>}>
      <SetComments comments={comments} />
    </Suspense>
    </body>
    </html>
  </>,
  {
    bootstrapScripts: ['/main.js'],
    onShellReady() {
      stream.pipe(res);
    },
  }
)

SuspenseでSetCommentsコンポーネントをラップし、Promiseオブジェクト(comments)をpropsに渡します。SetCommentsコンポーネント内では、Promiseが解決したタイミング(非同期処理が終わった)でデータを取得しwindow.__setComments__経由でクライアントにデータを渡しています。

では、実際の動きを見てみましょう。

比較のため、最初にSSRの動きを見てみましょう。

ブラウザーでアクセスすると数秒待った後に、画面が表示されました。

次に、Streaming Server-Side Renderingを見てみましょう。

ブラウザーでアクセスすると待たされることなく、すぐに画面が表示されました。コメントは最初に読み込み中と表示され、数秒後にコンテンツが表示されます。SSRではコメントのデータ取得が完了するまで何も表示されなかったのに対し、Streaming Server-Side Renderingでは即座にブラウザー画面に反映されました。

# Node.js Stream

renderToPipeableStreamはNode.js Streamを使用してレスポンスを返しています。

Node.js Stream (opens new window)とはNode.jsが提供するインターフェースで、データを細かくストリーミングすることにより、大容量のファイルでもメモリ面で効率よく読み込みや書き込みをすることができます。

ファイルの書き込みだけでなく、httpモジュールでも使えるためサーバからのレスポンスをストリーミングすることができます。

例えば、100行のCSVデータをStreamを使って出力してみましょう。

import fs from 'fs'

const readable = fs.createReadStream('./organizations-100.csv', {
  encoding: 'utf8',
  highWaterMark: 1024 // 1キロバイト分だけバッファに保持する
});

for await (const chunk of readable) {
  console.log('Chunk start ==========================================================')
  console.log(chunk);
  console.log('Chunk end ============================================================')
}

Streamは、内部にバッファとしてデータを保持します。highWaterMarkで、どの程度までデータを保持できるか指定できます。ここでは、highWaterMarkに1キロバイト分だけ保持するように設定しています。この値を超えないように、内部で制御することでデータを細かくストリーミングすることができます。

ターミナルで実行すると、次のように細かく出力されているのが分かります。

Chunk start ==========================================================
Index,Organization Id,Name,Website,Country,Description,Founded,Industry,Number of employees
1,FAB0d41d5b5d22c,Ferrell LLC,https://price.net/,Papua New Guinea,Horizontal empowering knowledgebase,1990,Plastics,3498
2,6A7EdDEA9FaDC52,"Mckinney, Riley and Day",http://www.hall-buchanan.info/,Finland,User-centric system-worthy leverage,2015,Glass / Ceramics / Concrete,4952
3,0bFED1ADAE4bcC1,Hester Ltd,http://sullivan-reed.com/,China,Switchable scalable moratorium,1971,Public Safety,5287
4,2bFC1Be8a4ce42f,Holder-Sellers,https://becker.com/,Turkmenistan,De-engineered systemic artificial intelligence,2004,Automotive,921
5,9eE8A6a4Eb96C24,Mayer Group,http://www.brewer.com/,Mauritius,Synchronized needs-based challenge,1991,Transportation,7870
6,cC757116fe1C085,Henry-Thompson,http://morse.net/,Bahamas,Face-to-face well-modulated customer loyalty,1992,Primary / Secondary Education,4914
7,219233e8aFF1BC3,Hansen-Everett,https://www.kidd.org/,Pakistan,Seamless disintermediate collaboration,2018,Publishing Industry,783
Chunk end ============================================================
Chunk start ==========================================================
2
8,ccc93DCF81a31CD,Mcintosh-Mora,https://www.brooks.com/,Heard Island and McDonald Islands,Centralized attitude-oriented capability,1970,Import / Export,4389
9,0B4F93aA06ED03e,Carr Inc,http://ross.com/,Kuwait,Distributed impactful customer loyalty,1996,Plastics,8167
10,738b5aDe6B1C6A5,Gaines Inc,http://sandoval-hooper.com/,Uzbekistan,Multi-lateral scalable protocol,1997,Outsourcing / Offshoring,9698
11,AE61b8Ffebbc476,Kidd Group,http://www.lyons.com/,Bouvet Island (Bouvetoya),Proactive foreground paradigm,2001,Primary / Secondary Education,7473
12,eb3B7D06cCdD609,Crane-Clarke,https://www.sandoval.com/,Denmark,Front-line clear-thinking encryption,2014,Food / Beverages,9011
13,8D0c29189C9798B,"Keller, Campos and Black",https://www.garner.info/,Liberia,Ameliorated directional emulation,2020,Museums / Institutions,2862
14,D2c91cc03CA394c,Glover-Pope,http://www.silva.biz/,United Arab Emirates,Persevering contextually-based approach,2013,Medical Practice,9079
15,C8AC1eaf9C036F4,Pacheco-Spears,https://aguil
Chunk end ============================================================
Chunk start ==========================================================
ar.com/,Sweden,Secured logistical synergy,1984,Maritime,769
16,b5D10A14f7a8AfE,Hodge-Ayers,http://www.archer-elliott.com/,Honduras,Future-proofed radical implementation,1990,Facilities Services,8508
17,68139b5C4De03B4,"Bowers, Guerra and Krause",http://www.carrillo-nicholson.com/,Uganda,De-engineered transitional strategy,1972,Primary / Secondary Education,6986
18,5c2EffEfdba2BdF,Mckenzie-Melton,http://montoya-thompson.com/,Hong Kong,Reverse-engineered heuristic alliance,1998,Investment Management / Hedge Fund / Private Equity,4589
19,ba179F19F7925f5,Branch-Mann,http://www.lozano.com/,Botswana,Adaptive intangible frame,1999,Architecture / Planning,7961
20,c1Ce9B350BAc66b,Weiss and Sons,https://barrett.com/,Korea,Sharable optimal functionalities,2011,Plastics,5984
21,8de40AC4e6EaCa4,"Velez, Payne and Coffey",http://burton.com/,Luxembourg,Mandatory coherent synergy,1986,Wholesale,5010
22,Aad86a4F0385F2d,Harrell LLC,http://www.frey-rosario.com/,Guadeloupe,Reverse-engineered mission-critical moratorium,201
Chunk end ============================================================
... 省略

Chunk start ==========================================================
erance focus group,1975,Photography,2988
97,BA6Cd9Dae2Efd62,Good Ltd,http://duffy.com/,Anguilla,Reverse-engineered composite moratorium,1971,Consumer Services,4292
98,E7df80C60Abd7f9,Clements-Espinoza,http://www.flowers.net/,Falkland Islands (Malvinas),Progressive modular hub,1991,Broadcast Media,236
99,AFc285dbE2fEd24,Mendez Inc,https://www.burke.net/,Kyrgyz Republic,User-friendly exuding migration,1993,Education Management,339
100,e9eB5A60Cef8354,Watkins-Kaiser,http://www.herring.com/,Togo,Synergistic background access,2009,Financial Services,2785

Chunk end ============================================================

Streamには、Readable StreamとWritable Streamがあり、それぞれ読み取りと書き込みを行います。

Readable StreamにはpipeというAPIが用意されており、引数にWritable Streamを渡すことで自動的にデータ量の調整を行ってくれます。

Writable Streamはインターフェースを満たしていればどんなオブジェクトでも構いません。そのため、次のようにprocess.stdoutを指定することも可能です。

import fs from 'fs'
import stream from 'stream'

const readable = fs.createReadStream('./organizations-100.csv', {
  encoding: 'utf8',
  highWaterMark: 1024 // 1キロバイト分だけバッファに保持する
});

// process.stdoutはstream.Writableインターフェースを満たしている
console.log(process.stdout instanceof stream.Writable) // true

readable.pipe(process.stdout)

httpモジュールのres(ServerResponse)も同様にWritable Streamのインターフェースを満たしているのでpipeに渡すことができます。そのため、サーバからの配信もストリーミングを使うことができます。

http://localhost:3000からCSVファイルをストリーミングで配信するコードを書いてみましょう。

import http from 'http'
import fs from 'fs'

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

const server = http.createServer((req, res) => {
  const stream = fs.createReadStream('./organizations-100.csv', {
    encoding: 'utf8',
    highWaterMark: 1024
  })

  // 500ms毎にストリーミングする
  stream.on('data', async (chunk) => {
    console.log('chunk: \n\n', chunk)
    stream.pause()
    await sleep(500)
    stream.resume()
  })

  stream.pipe(res)
})

server.listen(3000, () => console.log(`Server listening on http://localhost:3000`))

サーバを起動し、http://localhost:3000でアクセスすると次のように細かくレスポンスが返ってきているのが分かります。

このように、ReactのrenderToPipeableStreamもNode.js Streamでレンダリング結果を細かくストリーミングしています。

# Next.jsで実装

ReactのServer APIを通してStreaming Server-Side Renderingの仕組みを見てきましたが、実務で使用するには非同期処理の解決やハイドレーションなど考慮する点が多くなります。

実際のアプリケーション開発ではフレームワークを使う方が効率的でしょう。 Next.js13からはStreaming Server-Side Renderingをサポート (opens new window)しています。

Reactの実装と同様にSuspenseコンポーネントを使うことで、Streaming Server-Side Renderingの挙動を実現できます。

import { Suspense } from "react";
import { PostFeed, Weather } from "./Components";

export default function Posts() {
  return (
    <section>
      <Suspense fallback={<p>Loading feed...</p>}>
        <PostFeed />
      </Suspense>
      <Suspense fallback={<p>Loading weather...</p>}>
        <Weather />
      </Suspense>
    </section>
  );
}

それでは、前述のニュースサイトをNext.jsで実装してみましょう。

app/page.tsxにホーム画面のコードを実装します。

import { Suspense } from "react";
import { Comments } from "./Comments";

const fetchContent = () =>
  new Promise((resolve) =>
    setTimeout(() => resolve("今日のトップニュースです。"))
  );

export default async function Home() {
  const content = (await fetchContent()) as string;

  return (
    <div className="container">
      <header>経済ニュース</header>

      <div className="main-container">
        <aside>
          <h2>サイドバー</h2>
          <ul>
            <li>トップニュース</li>
            <li>特集</li>
            <li>経済</li>
            <li>国際</li>
            <li>情報・IT</li>
            <li>文化</li>
          </ul>

        </aside>

        <main>
          <h2>トップニュース</h2>
          <p>{content}</p>

          <div className="comments">
            <h2>コメント</h2>
            <Suspense fallback={<div>読み込み中...</div>}>
              {/* @ts-expect-error Async Server Component */}
              <Comments />
            </Suspense>
          </div>
        </main>
      </div>

      <footer>Japan News プライバシーポリシー | お問い合わせ | Copyright © 2023-present</footer>
    </div>
  )
}

コメント部分は同様にSuspenseでラップし、非同期処理が解決されたタイミングで段階的に配信します。

<Suspense fallback={<div>読み込み中...</div>}>
  {/* @ts-expect-error Async Server Component */}
  <Comments />
</Suspense>

Commentsコンポーネントでは非同期処理を実行し、コメントデータを取得しています。

import { Comments as Component } from "./CommentsComponent";

const fetchComments = () =>
  new Promise((resolve) =>
    setTimeout(() => resolve(["コメント 1", "コメント 2", "コメント 3"]), 3000)
  );

export async function Comments() {
  const comments = (await fetchComments()) as string[];

  return <Component comments={comments} />;
}

これでStreaming Server-Side Renderingが実現できました。ReactのServer APIを使って実装したのと比べると、圧倒的に記述量が少なくなっています。ハイドレーションやSuspenseの制御を隠蔽してくれるおかげで開発者はコンポーネント開発に注力することができます。

# メリット

Streaming Server-Side Renderingは、SSRのメリットと同時に以下のようなメリットがあります。

# TTFBの向上

SSRでは、サーバの処理時間に応じてTTFBが遅くなるというデメリットがありました。

SSRでは、TTFBが遅くなる

しかし、Streaming Server-Side Renderingでは段階的にレンダリングを実行できるため、クライアントへの最初のレスポンスが高速になります。 これによりTTFBのパフォーマンスが大幅に向上します。TTFBが向上することによりFCPとTTIのパフォーマンスも改善されます。

Streaming Server-Side RenderingではTTFBのパフォーマンスが大幅に向上