Next.jsとReact18の簡単SSR-Streaming
※今回のソースコード
https://github.com/SoraKumo001/next-weather-stream
Next.js と React18 の SSR-Streaming
React18(2022/1/3 の時点で beta)と Next.js を利用することで、SSR ストリーミングが使えます。この機能によって、HTML の書き出しの途中で描画可能なコンポーネントを先にクライアントに出力し、非同期データの取得などが残っているコンポーネントに関してはローディング中のメッセージが出せます。そして準備が完了したコンポーネントの HTML データが全て書き出された時点で、ページの表示が完了となります。受け渡しは最初に張った HTML のコネクション一本です。従来の SSR に対して、非同期データを待たずに UI が表示できるのが利点です。
利用のために必要な作業
対応しているバージョンの react のインストール
1yarn add react@rc react-dom@rc
その他はNext.jsに必要なものをいつも通り入れてください。
next.config.js の設定変更
1/**2 * @type { import("next").NextConfig}3 */4const config = {5 experimental: {6 concurrentFeatures: true,7 },8};9module.exports = config;
現時点で見つけた concurrentFeatures 有効時の Next.js@12.0.7 のバグ
Dynamic Routes が死ぬ
[id]のようなアドレスで router.query を使おうと思っても、サーバ側で undefined です。また HTML は表示されるものの、コンポーネントとしてマウントされません。利用するのは諦めて qeruy パラメータで回避する必要がありますdev 動作時にリスニングの限界が来る
404 エラーなどを出すとコネクションが待機中のままになり、そのうちリスニング数の限界が来て動かなくなりますVercel で動かすときは設定に注意する必要がある
concurrentFeatures と swcMinify を併用すると Vercel でエラーを出して動作しない
SSR-Streaming を使う方法
Suspense を利用する
React の Suspense コンポーネントを使います。
1<Suspense>2 {SSR-Streamingしたいコンポーネント}3</Suspense>
という構造をとり、データ取得のタイミングを Promise で返すようにして、コンポーネント内でthrow promiseを行います。promise が値を返すと、コンポーネントが再度呼び出されレンダリングが行えます。このレンダリングが完了すると、ストリーミングで追加されたHTMLがクライアントへ送られます。
Vercelのdemo(Next.js 12 React Server Components Demo (Alpha))の内容を確認する
https://github.com/vercel/next-rsc-demo
RSC with API Delays + HTTP Streaming
こちらのデモは以下の構造でSSR-Streamingを行っています。
1<Suspense>2 <Serverコンポーネント>3 <Clientコンポーネント />4 </Serverコンポーネント>5</Suspense>
まずは各コンポーネントの特性について説明します
通常コンポーネント
いつも利用しているコンポーネント
ServerやClientコンポーネントの配下に置くと、その特性を引き継ぐ
Streamingで直接使うと非同期で生成したデータがpropsとして渡せないので、HTMLとして一瞬表示されるものの、その後すぐデータが消えた状態となるServerコンポーネント
*.server.jsというファイル名で作られるコンポーネント
サーバ側でレンダリング
クライアントに渡されるときはHTMLにレンダリングされ、コンポーネントとして再マウントされない
コンポーネントの状態にならないので、クライアントから操作することは出来ないClientコンポーネント
*.client.jsというファイル名で作られるコンポーネント
サーバとクライアント両方でレンダリングされる
通常コンポーネントとの違いは、クライアント側でコンポーネントとして再マウントされ、ブラウザ上で操作可能となること
Serverコンポーネントから渡されたpropsはJSONに変換されHTMLと共に送られるため、クライアントでデータが使用可能となる
SuspenseでStreamingを行う際は通常コンポーネントでも動作可能です。しかし通常コンポーネントだけでStreamingを構成すると、クライアントで再マウント時にデータが消えます。その理由はサーバ側で生成したpropsがHTMLの状態では渡せないからです。ストリーミング時には完成形のHTMLが送られそれがレンダリングされますが、マウント後はpropsが空なので再レンダリングで消えてしまうのです。
この問題に対処するためいったんServerコンポーネントを挟み、Clientコンポーネントを配置しているのです。ちなみにServerコンポーネントの下に通常コンポーネントを置いても、Serverコンポーネントとして扱われるのでクライアント側でマウントされません。
RSC(ReactServerComponent)を使わず、通常コンポーネントのみで SSR-Streaming を行うプログラムを作る
Serverコンポーネントを使わない理由
SSR-Streamingを行うのに、いちいち.serverや.clientコンポーネントを作るのは面倒です。理想は全てを通常コンポーネントで完結させることです。というか、初手でDemoを確認せずにSSR-Streamingをやり始めたため、気がついたら通常コンポーネントで全て実装するという力業を完遂していました。
Streaming で送った通常コンポーネントがどうなるのか
通常コンポーネントでStreamingした場合の動作を復習します
サーバ
データ取得用 promise が完了
throw で中断していたコンポーネントの再レンダリング
HTML に変換されクライアントへ
クライアント
追加分の HTML を受け取る
JavaScript が必要な位置へ再配置
という流れを踏みます。さて、ここで重要な問題を理解する必要があります。クライアントに送られるのは HTML データです。そしてコンポーネントのレンダリングには非同期で取得したデータを利用しました。この状態でクライアントがコンポーネントの再レンダリングを行うと、レンダリングに必要なpropsデータが無いので表示内容が吹っ飛びます。
通常コンポーネントで内容を吹き飛ばさないためにすること
クライアント側でコンポーネントが再レンダリングされないように throw します。書き換えがストップするので、目出度く HTML の表示は吹き飛ばされずに済みます。しかしこの状態でステートやイベントはどうなるか疑問に思いますよね。dom を直接いじるという強硬手段に出ない限りはもちろん使えません。Serverコンポーネントと同じ状態になります。これが SSR-Streaming に課された制約です。
データが送られないという状況を打破する
発想の転換が必要です。SSR-Streaming で作成するコンポーネントをまともな思考で作っていたら、制約をそのまま引き継ぐことになります。引き継ぎたいのは制約ではなく、データなのです。サーバ側が持っているデータをクライアントに渡すことが出来れば、クライアント側のコンポーネントが再構築できます。つまり ServerコンポーネントがClientコンポーネントを呼び出すときと同じ事をしてやれば良いのです。
原理としては、送りたいデータを JSON にして<script>タグに貼り付けて HTML として出力し、それを受け取ったクライアントが dom から参照し props として配っています。
src/components/SuspenseLoader.tsx
1import {2 createContext,3 MutableRefObject,4 ReactNode,5 Suspense,6 SuspenseProps,7 useCallback,8 useContext,9 useEffect,10 useRef,11 useState,12} from "react";1314type PropeatyType<T> = {15 value?: T;16 isInit?: boolean;17 isSuspenseLoad?: boolean;18};19export type SuspenseDispatch = () => void;20const isServer = typeof window === "undefined";21const cacheMap: { [key: string]: unknown } = {};22const SuspenseContext = createContext<unknown>(undefined);23export const useSuspense = <T,>() => useContext(SuspenseContext) as T;2425const SuspenseWapper = <T,>({26 property,27 idName,28 children,29 load,30}: {31 property: PropeatyType<T>;32 idName: string;33 children: ReactNode;34 load: () => Promise<unknown>;35}) => {36 if (!property.isInit) throw load();37 const [isRequestData, setRequestData] = useState(38 property.isSuspenseLoad || isServer39 );40 useEffect(() => setRequestData(false), []);41 return (42 <SuspenseContext.Provider value={property.value}>43 {isRequestData && (44 <script45 id={idName}46 type="application/json"47 dangerouslySetInnerHTML={{48 __html: JSON.stringify({ value: property.value }),49 }}50 />51 )}52 {children}53 </SuspenseContext.Provider>54 );55};5657export const SuspenseLoader = <T, V>({58 name,59 loader,60 loaderValue,61 fallback,62 onLoaded,63 children,64 dispatch,65}: {66 name: string;67 loader: (value: V) => Promise<T>;68 loaderValue?: V;69 fallback?: SuspenseProps["fallback"];70 onLoaded?: (value: T) => void;71 children: ReactNode;72 dispatch?: MutableRefObject<SuspenseDispatch | undefined>;73}) => {74 const [_, reload] = useState({});75 const idName = "#__NEXT_DATA__STREAM__" + name;76 const property = useRef<PropeatyType<T>>({}).current;77 if (!isServer && !property.isInit) {78 const value = cacheMap[name];79 if (value) {80 property.value = value as T;81 property.isInit = true;82 property.isSuspenseLoad = false;83 }84 }85 const load = useCallback(86 () =>87 new Promise<T>((resolve) => {88 if (!isServer) {89 const node = document.getElementById(idName);90 if (node) {91 property.isSuspenseLoad = true;92 resolve(JSON.parse(node.innerHTML).value);93 return;94 }95 }96 loader(loaderValue as V).then((v) => {97 property.isSuspenseLoad = false;98 resolve(v);99 });100 }).then((value) => {101 property.isInit = true;102 property.value = value;103 cacheMap[name] = value;104 onLoaded?.(value);105 }),106 [loader, onLoaded]107 );108 if (dispatch) {109 dispatch.current = () => {110 property.value = undefined;111 property.isInit = false;112 delete cacheMap[name];113 reload({});114 };115 }116 return (117 <Suspense fallback={fallback || false}>118 <SuspenseWapper<T> idName={idName} property={property} load={load}>119 {children}120 </SuspenseWapper>121 </Suspense>122 );123};
SuspenseLoader でデータ諸々を管理し、SuspenseWapper で JSON データとコンポーネントを同時に送るようにしています。取得したデータは ContextAPI で配る形にして、専用の hook を作ります。大したコード量にはなりませんでした。
通常コンポーネントのみで天気予報を SSR-Streaming
以下が、先ほど作った SuspenseLoader を使って天気予報を表示するシステムです。
src/pages/index.tsx
気象庁からエリア情報を持ってくるコードです。
1import React, { Ref, RefObject, useRef } from "react";2import Link from "next/link";3import {4 SuspenseDispatch,5 SuspenseLoader,6 useSuspense,7} from "../libs/SuspenseLoader";89interface Center {10 name: string;11 enName: string;12 officeName?: string;13 children?: string[];14 parent?: string;15 kana?: string;16}17interface Centers {18 [key: string]: Center;19}20interface Area {21 centers: Centers;22 offices: Centers;23 class10s: Centers;24 class15s: Centers;25 class20s: Centers;26}2728const AreaList = () => {29 const area = useSuspense<Area | undefined>();30 if (!area) return <>読み込みに失敗しました</>;31 return (32 <div>33 {Object.entries(area.offices).map(([code, { name }]) => (34 <div key={code}>35 <Link href={`/weather/?id=${code}`}>36 <a>{name}</a>37 </Link>38 </div>39 ))}40 </div>41 );42};4344const Page = () => {45 const dispatch = useRef<SuspenseDispatch>();46 const loader = () =>47 fetch(`https://www.jma.go.jp/bosai/common/const/area.json`)48 .then((r) => r.json())49 //1秒の遅延を仕込む50 .then(async (v) => (await new Promise((r) => setTimeout(r, 1000))) || v)51 .catch(() => undefined);52 return (53 <>54 <button onClick={() => dispatch.current()}>Reload</button>55 <SuspenseLoader56 dispatch={dispatch} //リロード用dispatch57 name="Weather/130000" //SSR引き継ぎデータに名前を付ける58 loader={loader} //Promiseを返すローダー59 fallback={<div>読み込み中</div>} //読み込み中に表示しておくコンポーネント60 onLoaded={() => console.log("読み込み完了")} //読み込み完了後に発生するイベント61 >62 <AreaList />63 </SuspenseLoader>64 </>65 );66};67export default Page;
src/pages/weather/index.tsx
エリアごとの天気情報を表示するコードです
1import { useRouter } from "next/dist/client/router";2import Link from "next/link";3import React, { useRef } from "react";4import {5 SuspenseDispatch,6 SuspenseLoader,7 useSuspense,8} from "../../libs/SuspenseLoader";910export interface WeatherType {11 publishingOffice: string;12 reportDatetime: Date;13 targetArea: string;14 headlineText: string;15 text: string;16}1718// Next.jsのconcurrentFeaturesが有効になっていない場合はCSRで動作19const Weather = () => {20 const weather = useSuspense<WeatherType | undefined>();21 if (!weather) return <>読み込みに失敗しました</>;22 return (23 <div>24 <h1>{weather.targetArea}</h1>25 <div>{new Date(weather.reportDatetime).toLocaleString()}</div>26 <div>{weather.headlineText}</div>27 <pre>{weather.text}</pre>28 <div>29 <Link href="/">30 <a>戻る</a>31 </Link>32 </div>33 </div>34 );35};3637const Page = () => {38 const router = useRouter();39 const id = router.query["id"];40 const dispatch = useRef<SuspenseDispatch>();41 if (!id) return null;42 const loader = (id: number) =>43 fetch(44 `https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${id}.json`45 )46 .then((r) => r.json())47 //1秒の遅延を仕込む48 .then(async (v) => (await new Promise((r) => setTimeout(r, 1000))) || v)49 .catch(() => undefined);50 return (51 <>52 <button onClick={() => dispatch.current()}>Reload</button>53 <SuspenseLoader54 dispatch={dispatch} //リロード用dispatch55 name={`Weather/${id}`} //SSR引き継ぎデータに名前を付ける56 loader={loader} //Promiseを返すローダー57 loaderValue={Number(id)} //ローダーに渡すパラメータ(不要なら省略可)58 fallback={<div>読み込み中</div>} //読み込み中に表示しておくコンポーネント59 onLoaded={() => console.log("読み込み完了")} //読み込み完了後に発生するイベント60 >61 <Weather />62 </SuspenseLoader>63 </>64 );65};6667export default Page;
動作の確認
ソースコードの具体的な内容や動作確認は以下で行えます
https://codesandbox.io/s/zen-williamson-z9201?file=/src/pages/index.tsx
動作内容
「読み込み中」の表示で一旦停止し、1 秒後に残りの HTML が吐き出されます。Reloadを押すかエリアをクリックしてページ遷移した場合は、CSRによるクライアントからのデータの読み込みに切り替わります。また、SSR-StreamingやCSRで読み込んだデータはキャッシュされるので、無駄な再読込は発生しません。
SSR-Streaming が無茶苦茶簡単に実現できる
SSR-Streaming のハードルは小石ほどになりました。躓くポイントとしては Next.js のバグがそれなりに残っていることです。
現在、SSR/SSR-Streaming/CSR を自由に切り替えられる SuspenseLoader を開発中です。React17 系でも SSR-Streaming が使えなくはなりますが、コードを一切変更せずに動くように作っているので、乗り換えも簡単になると思います。こちらも完成次第、記事にします。