🛳️

【Next.js x React18】SSR Streamingについてまとめてみた

2021/12/17に公開

https://qiita.com/advent-calendar/2021/kaonavi

この記事は カオナビ Advent Calendar 2021 17日目です。

はじめに

株式会社カオナビのフロントエンドエンジニアの@shinji_beckyです。

先日Next.js ConfがありNext.js 12で様々な機能のリリースが紹介されました。
その中にReact18のサポートがされるという内容があり、SSR Streamingの話がありました。
Suspenseとかその辺の話かな〜くらいには思ってましたが、「まだよーわからん」状態だったのであらためて調べてみました。

いろいろ調べた中でこちらのページがめちゃめちゃ参考になりました。
基本的にはこちらの内容ベースなのでより詳しい話はこちらを読んでいただけると幸いです。

https://github.com/reactwg/react-18/discussions/37

Server-Side Rendering(SSR)のはなし

SSRとは

SSRとはなんぞや。
簡単に説明すると「サーバーサイドで事前にレンダリングしたページをレスポンスとして返してもらう」みたいな感じです。

メリットとしては

  • リクエストのごとにHTML生成するので常にページが最新の状態になる
  • SEOに強い
  • クライアントのスペックに依存しない

などがあります

SSRの流れ

実際にSSRのページを開いてインタラクティブな状態になるまでの流れは以下のようになります

  1. クライアントからリクエスト
  2. サーバー側でリクエストを受け取りAPIサーバーに必要なデータをFetch
  3. Fetch後、サーバー側でHTMLをレンダリング
  4. レンダリングしたHTMLをクライアントに返却(TTFB)
  5. JSをロードする
  6. ロードしたJSをHTMLにHydrate[1]する
  7. Hydrateが完了し、Interactiveな状態になる(FID)

SSRのつらいとこ

先ほど1~7の工程でSSRの流れを書き出しましたが、この中でいくつか辛いポイントがあります。

1つ目は2.サーバー側でリクエストを受け取りAPIサーバーに必要なデータをFetch の部分です。
APIサーバーの処理速度などに問題があった場合、サーバー側でのFetch処理が遅延してしまいます。
それによりクライアントにHTMLが返却されるまでの時間が長くなってしまうという問題が発生します。

2つ目は5.JSをロードする の部分です。
JavaScriptのコードがロードされてからHydrateが行われるのですが、ここでロードするJavaScriptのサイズが大きい場合にHydrateまでの時間がかかってしまいます。
React.lazy()を利用してバンドルを分割するアプローチも考えられますが、SSRでは機能しないため解決が難しいです。

3つ目は6.ロードしたJSをHTMLにHydrateする の部分です。
JavaScriptロード後、ReactはHydrateを開始するとコンポーネントツリー全体に対して処理されるまで終了することができません。
既にHydrateが完了しているヘッダーやナビゲーションバーなどがあっても、複雑なコンポーネントがあるとそのコンポーネントの処理が終了するまで他のコンポーネントはInteractiveな状態にならない問題があります。

この問題を解決するためには

上記で挙げた3つの問題点で共通することとして、「それぞれ処理が全て終了しないと次のフェーズにすすまない」ということです。

  • DataFetchの必要ないコンポーネント、もしくはDataFetchが終わったコンポーネントが他を待たずにHTMLを返してくれたら?
  • JSロードが終了したコンポーネントからHydrateされるようになったら?
  • Hydrateが完了したコンポーネントからInteractiveになったら?

これまでのSSRで0か100だったものを柔軟にするのがSSR Streamingです。

Next.jsでのSSR Streamingの利用

Next.js 12でconcurrentFeatures:trueをnext.config.jsで指定することでSSR Streamingを利用できます。
この機能を利用することでReact18のSuspenseHTTP streamingのサポートを受けることができます。

// next.config.js
module.exports = {
  experimental: {
    concurrentFeatures: true,
  },
}

Suspenseで解決

Next.jsでSSR Streamingを利用する準備ができましたが、どうすればSSR Streamingになるのか?
それは<Suspense>を利用するだけです。
Suspenseには以下の機能が含まれています。

Streaming HTML

DataFetchが完了しないとHTMLが返却されないという問題に対応する機能がStreamingHTMLです。
この機能はSuspenseを対象のコンポーネントにラップするだけで機能します。

import { Suspense } from 'react';

export default function Home() {
  return (
    <Container>
      <NavBar />
      <SideBar />
      <Suspense fallback={<Spinner />}>
	<Comments />
      </Suspense>
    </Container>
  )
}

<Suspense><Comments />をラップすることで、<Comments />以外の部分がHTMLのStreamを始めるために<Comments />を待つ必要がなくなります。
つまり通常のSSRでできなかったCommentsのDataFetchが終了する前にNavBar、SideBarのHTMLを返却することがこれで可能になります。
また、<Comments />のDataFetchが終了するまではfallbackに指定している<Spinner />が返却されます。
(グレーはHTMLがStreamingされた状態)

そして<Comments />のDataFetchが完了するとReactが追加のHTMLをStreamingします。
そのHTMLを適切な場所に配置するための最小限のインライン<script>タグを送信します。

これでDataFetchが終了するまで全てのHTMLの返却を待機する必要がなくなり、DataFetchが必要な部分とそれ以外で分けてHTMLをSteamingすることができるようになりました。

Selective Hydration

残りの問題である

  • すべてのJS Loadが終了するまでHydrateが始まらない
  • Hydrateが全て終了するまでInteractiveにならない

を解決してくれるのがSelective Hydrationという機能です。

「SSRのつらいとこ」でちょろっと言及していたReact.lazy()と組み合わせて利用します。
SSRでは利用できませんでしたが、<Suspense>でラップすることでJS Loadされる前にHydrateすることができます。

import { lazy } from 'react';

const Comments = lazy(() => import('./Comments.js'));

// ...

<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>

<Suspense>でラップしている<Comments />をReact.lazy()でコード分割することで<Comments />のJS Loadを待たずに他の部分のHydrateが開始されます。

(ブルーはHydrateされた状態)

これによりJS Loadに時間がかかってしまうような重いコンポーネントがあっても残りのページが先にInteractiveな状態になることができます。

HTML Streaming前にHydrate

これまでDataFetch -> HTML Streaming -> JS Load -> Hydrationを段階的に進めるケースの説明をしましたが、全てのHTMLがStremingされる前にHydrateされるケースも対応できます。

例えば<Comments />のDataFetchがこれまで以上に時間がかかる状態を想定してみます。
まず<Comments />以外の部分がHTMLのStreamingが完了した状態になります。

ここまでは先ほどの説明と同じ状況ですが、「DataFetchがこれまで以上に時間がかかる状態」の場合、
<Comments />がDataFetch中に他の部分がHydrateまで完了することが考えられます。
Selective Hydrationはこのシチュエーションにも対応することができます。
図で言うと以下の状態になります。

<Comments />以外はHydrateが完了しInteractiveな状態になっています。
そして<Comments />のHTML Streamingが完了します。

最終的に<Comments />のJS Load,Hydrateが完了しInteractiveな状態になります。

Hydrate前にページを操作すると、、

これまで<Comments />のみをSuspenseでラップしていましたが、複数利用するとどうなるでしょうか?
<SideBar />Suspenseでラップしてみます。

import { Suspense } from 'react';
import { lazy } from 'react';

const Comments = lazy(() => import('./Comments.js'));
const SideBar = lazy(() => import('./SideBar.js'));

export default function Home() {
  return (
    <Container>
      <NavBar />
      <Suspense fallback={<Spinner />}>
        <SideBar />
      </Suspense>
      <Suspense fallback={<Spinner />}>
	<Comments />
      </Suspense>
    </Container>
  )
}

これにより<Comments /><SideBar />とそれ以外でHTML Streaming,Hydrateそれぞれ行われるようになりました。

では<Comments /><SideBar />両方がHTML Streamingされた状態としましょう。

<SideBar />からHydrateが始まる状態でユーザーが<Comments />の操作をしようとして要素をクリックしたとします。

するとReactがクリックが発生したことを記録して優先的に<Comments />のHydrateを行うようになります。
個人的に今回調べていた中で一番驚いたのはこの機能ですね、すごい。

まとめ

今回はSSRの課題とSSR Streamingのどのようなアプローチでそれが解決されるかをざっと紹介しました。
SSRのフローはここまで細かく調べたことがなかったのでとても勉強になりました。
そしてReact18の機能の強力さも知ることができました。

もう一つ話題になったReactServerComponentも今回紹介したSSR Streamingにつながってくるっぽいのですがこちらはまた別で勉強したいなと思います。

脚注
  1. HydrateはDOMに対して必要なイベントハンドラーなどをアタッチすることです ↩︎

Discussion