空雲 Blog

Eye catchNext.jsとReact18の簡単SSR-Streaming

publication: 2021/12/27
update:2024/02/20

※今回のソースコード
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";
13
14type 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;
24
25const 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 || isServer
39 );
40 useEffect(() => setRequestData(false), []);
41 return (
42 <SuspenseContext.Provider value={property.value}>
43 {isRequestData && (
44 <script
45 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};
56
57export 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";
8
9interface 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}
27
28const 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};
43
44const 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 <SuspenseLoader
56 dispatch={dispatch} //リロード用dispatch
57 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";
9
10export interface WeatherType {
11 publishingOffice: string;
12 reportDatetime: Date;
13 targetArea: string;
14 headlineText: string;
15 text: string;
16}
17
18// 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};
36
37const 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 <SuspenseLoader
54 dispatch={dispatch} //リロード用dispatch
55 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};
66
67export default Page;

動作の確認

ソースコードの具体的な内容や動作確認は以下で行えます

https://codesandbox.io/s/zen-williamson-z9201?file=/src/pages/index.tsx

動作内容

「読み込み中」の表示で一旦停止し、1 秒後に残りの HTML が吐き出されます。Reloadを押すかエリアをクリックしてページ遷移した場合は、CSRによるクライアントからのデータの読み込みに切り替わります。また、SSR-StreamingやCSRで読み込んだデータはキャッシュされるので、無駄な再読込は発生しません。

{"width":"1488px","height":"792px"}

{"width":"640px","height":"668px"}

SSR-Streaming が無茶苦茶簡単に実現できる

SSR-Streaming のハードルは小石ほどになりました。躓くポイントとしては Next.js のバグがそれなりに残っていることです。

現在、SSR/SSR-Streaming/CSR を自由に切り替えられる SuspenseLoader を開発中です。React17 系でも SSR-Streaming が使えなくはなりますが、コードを一切変更せずに動くように作っているので、乗り換えも簡単になると思います。こちらも完成次第、記事にします。