※今回のソースコード
https://github.com/SoraKumo001/next-weather-stream
React18(2022/1/3 の時点で beta)と Next.js を利用することで、SSR ストリーミングが使えます。この機能によって、HTML の書き出しの途中で描画可能なコンポーネントを先にクライアントに出力し、非同期データの取得などが残っているコンポーネントに関してはローディング中のメッセージが出せます。そして準備が完了したコンポーネントの HTML データが全て書き出された時点で、ページの表示が完了となります。受け渡しは最初に張った HTML のコネクション一本です。従来の SSR に対して、非同期データを待たずに UI が表示できるのが利点です。
yarn add react@rc react-dom@rc
その他はNext.jsに必要なものをいつも通り入れてください。
/**
* @type { import("next").NextConfig}
*/
const config = {
experimental: {
concurrentFeatures: true,
},
};
module.exports = config;
React の Suspense コンポーネントを使います。
<Suspense>
{SSR-Streamingしたいコンポーネント}
</Suspense>
という構造をとり、データ取得のタイミングを Promise で返すようにして、コンポーネント内でthrow promise
を行います。promise が値を返すと、コンポーネントが再度呼び出されレンダリングが行えます。このレンダリングが完了すると、ストリーミングで追加されたHTMLがクライアントへ送られます。
https://github.com/vercel/next-rsc-demo
RSC with API Delays + HTTP Streaming
こちらのデモは以下の構造でSSR-Streamingを行っています。
<Suspense>
<Serverコンポーネント>
<Clientコンポーネント />
</Serverコンポーネント>
</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コンポーネントとして扱われるのでクライアント側でマウントされません。
SSR-Streamingを行うのに、いちいち.serverや.clientコンポーネントを作るのは面倒です。理想は全てを通常コンポーネントで完結させることです。というか、初手でDemoを確認せずにSSR-Streamingをやり始めたため、気がついたら通常コンポーネントで全て実装するという力業を完遂していました。
通常コンポーネントでStreamingした場合の動作を復習します
サーバ
クライアント
という流れを踏みます。さて、ここで重要な問題を理解する必要があります。クライアントに送られるのは HTML データです。そしてコンポーネントのレンダリングには非同期で取得したデータを利用しました。この状態でクライアントがコンポーネントの再レンダリングを行うと、レンダリングに必要なpropsデータが無いので表示内容が吹っ飛びます。
クライアント側でコンポーネントが再レンダリングされないように throw します。書き換えがストップするので、目出度く HTML の表示は吹き飛ばされずに済みます。しかしこの状態でステートやイベントはどうなるか疑問に思いますよね。dom を直接いじるという強硬手段に出ない限りはもちろん使えません。Serverコンポーネントと同じ状態になります。これが SSR-Streaming に課された制約です。
発想の転換が必要です。SSR-Streaming で作成するコンポーネントをまともな思考で作っていたら、制約をそのまま引き継ぐことになります。引き継ぎたいのは制約ではなく、データなのです。サーバ側が持っているデータをクライアントに渡すことが出来れば、クライアント側のコンポーネントが再構築できます。つまり ServerコンポーネントがClientコンポーネントを呼び出すときと同じ事をしてやれば良いのです。
原理としては、送りたいデータを JSON にして<script>
タグに貼り付けて HTML として出力し、それを受け取ったクライアントが dom から参照し props として配っています。
import {
createContext,
MutableRefObject,
ReactNode,
Suspense,
SuspenseProps,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
type PropeatyType<T> = {
value?: T;
isInit?: boolean;
isSuspenseLoad?: boolean;
};
export type SuspenseDispatch = () => void;
const isServer = typeof window === "undefined";
const cacheMap: { [key: string]: unknown } = {};
const SuspenseContext = createContext<unknown>(undefined);
export const useSuspense = <T,>() => useContext(SuspenseContext) as T;
const SuspenseWapper = <T,>({
property,
idName,
children,
load,
}: {
property: PropeatyType<T>;
idName: string;
children: ReactNode;
load: () => Promise<unknown>;
}) => {
if (!property.isInit) throw load();
const [isRequestData, setRequestData] = useState(
property.isSuspenseLoad || isServer
);
useEffect(() => setRequestData(false), []);
return (
<SuspenseContext.Provider value={property.value}>
{isRequestData && (
<script
id={idName}
type="application/json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({ value: property.value }),
}}
/>
)}
{children}
</SuspenseContext.Provider>
);
};
export const SuspenseLoader = <T, V>({
name,
loader,
loaderValue,
fallback,
onLoaded,
children,
dispatch,
}: {
name: string;
loader: (value: V) => Promise<T>;
loaderValue?: V;
fallback?: SuspenseProps["fallback"];
onLoaded?: (value: T) => void;
children: ReactNode;
dispatch?: MutableRefObject<SuspenseDispatch | undefined>;
}) => {
const [_, reload] = useState({});
const idName = "#__NEXT_DATA__STREAM__" + name;
const property = useRef<PropeatyType<T>>({}).current;
if (!isServer && !property.isInit) {
const value = cacheMap[name];
if (value) {
property.value = value as T;
property.isInit = true;
property.isSuspenseLoad = false;
}
}
const load = useCallback(
() =>
new Promise<T>((resolve) => {
if (!isServer) {
const node = document.getElementById(idName);
if (node) {
property.isSuspenseLoad = true;
resolve(JSON.parse(node.innerHTML).value);
return;
}
}
loader(loaderValue as V).then((v) => {
property.isSuspenseLoad = false;
resolve(v);
});
}).then((value) => {
property.isInit = true;
property.value = value;
cacheMap[name] = value;
onLoaded?.(value);
}),
[loader, onLoaded]
);
if (dispatch) {
dispatch.current = () => {
property.value = undefined;
property.isInit = false;
delete cacheMap[name];
reload({});
};
}
return (
<Suspense fallback={fallback || false}>
<SuspenseWapper<T> idName={idName} property={property} load={load}>
{children}
</SuspenseWapper>
</Suspense>
);
};
SuspenseLoader でデータ諸々を管理し、SuspenseWapper で JSON データとコンポーネントを同時に送るようにしています。取得したデータは ContextAPI で配る形にして、専用の hook を作ります。大したコード量にはなりませんでした。
以下が、先ほど作った SuspenseLoader を使って天気予報を表示するシステムです。
気象庁からエリア情報を持ってくるコードです。
import React, { Ref, RefObject, useRef } from "react";
import Link from "next/link";
import {
SuspenseDispatch,
SuspenseLoader,
useSuspense,
} from "../libs/SuspenseLoader";
interface Center {
name: string;
enName: string;
officeName?: string;
children?: string[];
parent?: string;
kana?: string;
}
interface Centers {
[key: string]: Center;
}
interface Area {
centers: Centers;
offices: Centers;
class10s: Centers;
class15s: Centers;
class20s: Centers;
}
const AreaList = () => {
const area = useSuspense<Area | undefined>();
if (!area) return <>読み込みに失敗しました</>;
return (
<div>
{Object.entries(area.offices).map(([code, { name }]) => (
<div key={code}>
<Link href={`/weather/?id=${code}`}>
<a>{name}</a>
</Link>
</div>
))}
</div>
);
};
const Page = () => {
const dispatch = useRef<SuspenseDispatch>();
const loader = () =>
fetch(`https://www.jma.go.jp/bosai/common/const/area.json`)
.then((r) => r.json())
//1秒の遅延を仕込む
.then(async (v) => (await new Promise((r) => setTimeout(r, 1000))) || v)
.catch(() => undefined);
return (
<>
<button onClick={() => dispatch.current()}>Reload</button>
<SuspenseLoader
dispatch={dispatch} //リロード用dispatch
name="Weather/130000" //SSR引き継ぎデータに名前を付ける
loader={loader} //Promiseを返すローダー
fallback={<div>読み込み中</div>} //読み込み中に表示しておくコンポーネント
onLoaded={() => console.log("読み込み完了")} //読み込み完了後に発生するイベント
>
<AreaList />
</SuspenseLoader>
</>
);
};
export default Page;
エリアごとの天気情報を表示するコードです
import { useRouter } from "next/dist/client/router";
import Link from "next/link";
import React, { useRef } from "react";
import {
SuspenseDispatch,
SuspenseLoader,
useSuspense,
} from "../../libs/SuspenseLoader";
export interface WeatherType {
publishingOffice: string;
reportDatetime: Date;
targetArea: string;
headlineText: string;
text: string;
}
// Next.jsのconcurrentFeaturesが有効になっていない場合はCSRで動作
const Weather = () => {
const weather = useSuspense<WeatherType | undefined>();
if (!weather) return <>読み込みに失敗しました</>;
return (
<div>
<h1>{weather.targetArea}</h1>
<div>{new Date(weather.reportDatetime).toLocaleString()}</div>
<div>{weather.headlineText}</div>
<pre>{weather.text}</pre>
<div>
<Link href="/">
<a>戻る</a>
</Link>
</div>
</div>
);
};
const Page = () => {
const router = useRouter();
const id = router.query["id"];
const dispatch = useRef<SuspenseDispatch>();
if (!id) return null;
const loader = (id: number) =>
fetch(
`https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${id}.json`
)
.then((r) => r.json())
//1秒の遅延を仕込む
.then(async (v) => (await new Promise((r) => setTimeout(r, 1000))) || v)
.catch(() => undefined);
return (
<>
<button onClick={() => dispatch.current()}>Reload</button>
<SuspenseLoader
dispatch={dispatch} //リロード用dispatch
name={`Weather/${id}`} //SSR引き継ぎデータに名前を付ける
loader={loader} //Promiseを返すローダー
loaderValue={Number(id)} //ローダーに渡すパラメータ(不要なら省略可)
fallback={<div>読み込み中</div>} //読み込み中に表示しておくコンポーネント
onLoaded={() => console.log("読み込み完了")} //読み込み完了後に発生するイベント
>
<Weather />
</SuspenseLoader>
</>
);
};
export default Page;
ソースコードの具体的な内容や動作確認は以下で行えます
https://codesandbox.io/s/zen-williamson-z9201?file=/src/pages/index.tsx
「読み込み中」の表示で一旦停止し、1 秒後に残りの HTML が吐き出されます。Reloadを押すかエリアをクリックしてページ遷移した場合は、CSRによるクライアントからのデータの読み込みに切り替わります。また、SSR-StreamingやCSRで読み込んだデータはキャッシュされるので、無駄な再読込は発生しません。
SSR-Streaming のハードルは小石ほどになりました。躓くポイントとしては Next.js のバグがそれなりに残っていることです。
現在、SSR/SSR-Streaming/CSR を自由に切り替えられる SuspenseLoader を開発中です。React17 系でも SSR-Streaming が使えなくはなりますが、コードを一切変更せずに動くように作っているので、乗り換えも簡単になると思います。こちらも完成次第、記事にします。