Next.jsでハイブリッドSSR-Streamingを実装する
※今回作ったもの
https://next-streaming.vercel.app/
ハイブリッド SSR-Streaming とは
従来の非同期データの SSR(静的 SSR)
サーバサイドでレンダリングを行う際、getInitialProps や getServerSideProps を利用し非同期データを取得します。そのデータを利用してレンダリングを行うことで、初期 HTML に非同期で取得したデータを含めることが出来ます。しかしデータが集まるまで HTML が出力されないため、ユーザーに何も表示しない状態で待たせることになります。非同期データの取得が軽いものであればそれほど問題はありませんが、時間がかかってしまうとユーザーにストレスを与えることになります。
SSR-Streaming(動的 SSR)
サーバサイドでレンダリングを行う際、非同期データを使うコンポーネントをいったん保留にします。そして代替コンポーネントを表示しつつ HTML を出力し、準備が整ったコンポーネントを代替していたものと入れ替えます。これを HTML を出力する一本のコネクションで行います。この動作によって、ユーザーに適切な UI を提供しつつ準備を整えることが可能です。
欠点として、入れ替え処理に JavaScript が必要になることが挙げられます。従来の SSR であれば JavaScript を無効にしても、データが入った状態の初期ページは表示可能なのです。Streaming で送ったものは HTML の形にはなっているものの、JavaScript の使用が前提となっているのです。そうなってくるとクライアントサイドでデータを fetch する場合との差別化が難しいというのが実情です。
また、OGP などのデータは Streaming で吐き出しても、データは末端に追加されていくため<head>の適切な位置に配置できません。つまり Twitter などにアドレスを貼り付けても、OGP は読み込まれません。結局、静的 SSR が必要となります。
ハイブリッド SSR-Streaming
私が勝手に名付けた手法です。静的 SSR と動的 SSR を組み合わせて必要な HTML を返しつつ、時間のかかる処理は Streaming に回します。ただし単純に併用するわけではありません。静的 SSR の限界時間を設定し、その時間を超えたら自動的に Streaming に切り替えるようにします。これによってユーザーの待ち時間を調整しつつ、初期表示と追加表示のバランスを取ることが出来ます。これがハイブリッド SSR-Streaming です。
そもそものところでホスティングに Vercel を使うと getInitialProps や getServerSideProps の限界時間が 1.5 秒なので、それを超える場合は何らかの対処を取らなければなりません。
こちらに Vercel 公式の の SSR-Streaming のサンプルがあります。
https://next-news-rsc.vercel.sh/
Streaming のサンプルの他に比較用に静的 SSR で実装したものも置いてあります。
https://next-news-rsc.vercel.sh/ssr
運が良ければ実行されますが、かなりの確率で Vercel の 1.5 秒制限に引っかかってエラーを出します。
https://vercel.com/docs/error/application/EDGE_FUNCTION_INVOCATION_TIMEOUT
上記がエラーの理由です。Vercel で SSR をやると、けっこうなリスクが伴います。
React のコンポーネントの種類
この後の解説のために、RSC(ReactServerComponents)で登場するコンポーネントの種類を説明します
公式文書に関しては以下を参照してください
https://github.com/reactjs/rfcs/blob/serverconventions-rfc/text/0000-server-module-conventions.md
Server Components
ファイル名は.server.js
サーバのみで動作するコンポーネント
クライアントには HTML の状態で渡される
useState などの状態を保存するフックが使えない
Client Components
ファイル名は.client.js
クライアントで動作するコンポーネント
ただし ServerComponents の配下に設置した場合はサーバで実行され、クライアントに引き渡された後、コンポーネントとして再マウントされる
ServerComponents が ClientComponents 渡した props は、いったん JSON に変換され HTML と共に送られ再マウント時に再構築される
Shared Components
ファイル名は.js
いつものコンポーネント
ServerComponents の下に配置すれば ServerComponents になり、ClientComponents の下に配置すれば ClientComponents になる
SSR-Streaming を使うために
react@18 以降が必要です
1yarn add react@rc react-dom@rc
next.config.js に以下の設定が必要です
1/**2 * @type { import("next").NextConfig}3 */4const config = {5 experimental: {6 concurrentFeatures: true,7 },8};9module.exports = config;
serverComponents の設定は RSC を使っている場合は必要になりますが、ハイブリッド SSR-Streaming では全てをSharedComponentsで実装しているので不要です。
SSR-Streaming の構造
想定される真っ当なパターン
Suspense の中で非同期処理を行い、非同期処理で生成された promise をthrow promiseします。すると Suspense が代替コンポーネントを表示し、promise 終了時点で ServerComponents 以下の結果をクライアントへ返します。
1<SharedComponents>2 <Suspense>3 // HTMLに変換、再マウントはされない、ClientComponentsにpropsを渡せる4 <ServerComponents>5 // クライアント側で再マウントされる6 <ClientComponents>7 <SharedComponents />8 </ClientComponents>9 </ServerComponents>10 </Suspense>11</SharedComponents>
クライアントへ送られたとき、<ServerComponents>はコンポーネントとして再マウントされません。その代わり<ClientComponents>へ props の引き渡しが行えます。クライアントで動的に処理したいものは<ClientComponents>に記述します。
出来るけどやるとデータが吹っ飛ぶパターン
1<SharedComponents>2 <Suspense>3 <SharedComponents /> // HTMLに変換、再マウントされる、データは消失する4 </Suspense>5</SharedComponents>
一応、動きます。<SharedComponents>でthrow promiseを行えば、きちんと HTML 化され Streaming もされます。クライアント引き渡し後は<ClientComponents>と同じように再マウントもされます。問題点があるとすると、再マウント時に props を失うことです。非同期で取り出したデータが再マウントと再レンダリングによって消失します。何故かというと、レンダリング済みの HTML は送られるものの、それを作るためのデータは送られないからです。つまり Streaming 時にサーバ側で作成したデータが使えない状態となります。
つまり SSR-Streaming をする場合は、<ServerComponents>と<ClientComponents>を組み合わせるのが定石となります。
<ServerComponents>の問題点とその対処
クライアントから操作できないことです。Web アプリの動作は SSR でクライアントにデータを返したら終わりではありません。その後、リロードが必要になったり、内容を色々書き換えたりするときに<ServerComponents>が邪魔になります。
元々<ServerComponents>はサーバ側でコンポーネントを HTML 化することによって、クライアントに関連 JavaScript のデータを不要にするための技術です。データの受け渡しに使うのは本業ではありません。柔軟性を考えるのであれば全部<SharedComponents>だけで構成するのが理想です。
ということで<SuspenseLoader>というコンポーネントを作りました。
1<SharedComponents>2 //SuspenseLoader自体はSharedComponentsになっており、Suspenseを内蔵している3 //ServerComponentsのpropsを引き渡す機能を力業で実装しているのでデータが消えない4 <SuspenseLoader>5 <SharedComponents /> // 力業で送ったデータはgetSuspenseData()で取得する6 </SuspenseLoader>7</SharedComponents>
面倒なことは全て<SuspenseLoader>が引き受けさせます。Streaming 時のデータの受け渡しからデータキャッシュの管理、クライアント側でのリロード処理まで全部やらせます。パラメータ一つで SSR/SSR-Streaming/CSR、好きな動作を切り替えられるようにします。<SuspenseLoader>の中のコンポーネントは、特に何も考えずにデータを受け取ってレンダリングを行えます。そして最適なタイミングで処理されます。
ハイブリッド SSR-Streaming の作り方
ハイブリッド SSR-Streaming を行うには、静的 SSR と動的 SSR を同時に管理する必要があります。静的 SSR を行う方法として getInitialProps を使っています。その理由はデータ取得のためのプリレンダリングを行うための方法に関して、他に選択肢がないからです。
プリレンダリングを行いデータを生成
生成したデータを使い、本レンダリングを行う
プリレンダリングは ApolloGraphQL でも使われています。データ取得用のレンダリングとクライアントに返す HTML を生成するレンダリングをそれぞれ行っています。二回レンダリングするので非効率なように見えますが、データ取得用の fetch 処理はコンポーネント内に記述できるので、サーバ用とクライアント用を二重に書かなくて良いという利点があります。
そしてプリレンダリングにタイムアウト時間を設定すると、時間制限によるハイブリッド SSR-Streaming という構造を作り出せます。プリレンダリングで時間内に終わった処理は本レンダリングにデータを引き渡し、終わらなかったものは保留中の promise を渡します。本レンダリング内でthrow promiseすれば、間に合わなかったコンポーネントが SSR-Streaming に切り替わります。
SuspenseLoader のソースコードです。
1import React, {2 createContext,3 createElement,4 MutableRefObject,5 ReactElement,6 ReactNode,7 Suspense,8 SuspenseProps,9 useCallback,10 useContext,11 useEffect,12 useMemo,13 useRef,14 useState,15} from "react";16type SuspensePropeatyType<T, V> = {17 value?: T;18 isInit?: boolean;19 isSuspenseLoad?: boolean;20 loaderValue?: V;21};22type PromiseMap = {23 [key: string]: { streaming: boolean; promise: Promise<unknown> | undefined };24};2526export type SuspenseType = "streaming" | "ssr" | "csr";27export type SuspenseTreeContextType = {28 promiseMap: {29 [key: string]: {30 streaming: boolean;31 promise: Promise<unknown> | undefined;32 };33 };34 cacheMap: { [key: string]: unknown };35};36export type SuspenseDispatch<T = unknown> = (value?: T) => void;37const isServer = typeof window === "undefined";38const SuspenseDataContext = createContext<{39 value: unknown;40 dispatch: unknown;41}>(undefined as never);42export const useSuspenseData = <T,>() =>43 useContext(SuspenseDataContext).value as T;44export const useSuspenseDispatch = <V,>() =>45 useContext(SuspenseDataContext).dispatch as SuspenseDispatch<V>;46const SuspenseWapper = <T, V>({47 property,48 idName,49 dispatch,50 children,51 load,52 streaming,53}: {54 property: SuspensePropeatyType<T, V>;55 idName: string;56 dispatch: SuspenseDispatch<V>;57 children: ReactNode;58 load: () => Promise<unknown>;59 streaming?: boolean;60}) => {61 const { isInit, isSuspenseLoad, value } = property;62 if (!isInit) throw load();63 const [isRequestData, setRequestData] = useState(64 (isSuspenseLoad || isServer) && streaming65 );66 useEffect(() => setRequestData(false), []);67 const contextValue = useMemo(() => {68 return { value, dispatch };69 }, [value, dispatch]);70 return (71 <SuspenseDataContext.Provider value={contextValue}>72 {isRequestData && (73 <script74 id={idName}75 type="application/json"76 dangerouslySetInnerHTML={{77 __html: JSON.stringify({ value }),78 }}79 />80 )}81 {children}82 </SuspenseDataContext.Provider>83 );84};8586export const SuspenseLoader = <T, V>({87 name,88 loader,89 loaderValue,90 fallback,91 onLoaded,92 children,93 dispatch,94 type = "streaming",95}: {96 name: string;97 loader: (value: V) => Promise<T>;98 loaderValue?: V;99 fallback?: SuspenseProps["fallback"];100 onLoaded?: (value: T) => void;101 children: ReactNode;102 dispatch?: MutableRefObject<SuspenseDispatch<V> | undefined>;103 streaming?: boolean;104 ssr?: boolean;105 type: SuspenseType;106}) => {107 const reload = useState({})[1];108 const idName = "#__NEXT_DATA__STREAM__" + name;109 const { promiseMap, cacheMap } = useTreeContext();110 const property = useRef<SuspensePropeatyType<T, V>>({}).current;111 if (!property.isInit) {112 const value = cacheMap[name] as T | undefined;113 if (value) {114 property.value = value;115 property.isInit = true;116 property.isSuspenseLoad = false;117 onLoaded?.(value);118 }119 }120 const load = useCallback(() => {121 const promise =122 (isServer && (promiseMap[name]?.promise as Promise<T>)) ||123 new Promise<T>((resolve) => {124 if (!isServer) {125 const node = document.getElementById(idName);126 if (node) {127 property.isSuspenseLoad = true;128 resolve(JSON.parse(node.innerHTML).value);129 return;130 }131 }132 loader(property.loaderValue || (loaderValue as V)).then((v) => {133 property.isSuspenseLoad = false;134 resolve(v);135 });136 });137 promise.then((value) => {138 property.isInit = true;139 property.value = value;140 cacheMap[name] = value;141 onLoaded?.(value);142 });143 if (isServer)144 promiseMap[name] = { promise, streaming: type === "streaming" };145146 return promise;147 }, [148 promiseMap,149 cacheMap,150 name,151 type,152 loader,153 property,154 loaderValue,155 idName,156 onLoaded,157 ]);158 const loadDispatch = useCallback(159 (value?: V) => {160 property.value = undefined;161 property.isInit = false;162 property.loaderValue = value;163 delete cacheMap[name];164 delete promiseMap[name];165 setVisible(isServer || false);166 reload({});167 },168 [cacheMap, name, promiseMap, property, reload]169 );170 if (dispatch) {171 dispatch.current = loadDispatch;172 }173 const [isCSRFallback, setCSRFallback] = useState(type === "csr");174 useEffect(() => {175 setCSRFallback(false);176 }, []);177 const [isVisible, setVisible] = useState(true);178 useEffect(() => {179 setVisible(true);180 }, [isVisible]);181 return isCSRFallback ? (182 <>{fallback}</>183 ) : (184 <Suspense fallback={fallback || false}>185 {isVisible && (186 <SuspenseWapper<T, V>187 idName={idName}188 property={property}189 dispatch={loadDispatch}190 load={load}191 streaming={!cacheSrcMap[name]}192 >193 {children}194 </SuspenseWapper>195 )}196 </Suspense>197 );198};199200const globalTreeContext = {201 promiseMap: {},202 cacheMap: {},203};204let cacheSrcMap: { [key: string]: unknown } = {};205export const setSuspenseTreeContext = (context?: SuspenseTreeContextType) => {206 if (!context) return;207 const { promiseMap, cacheMap } = context;208 Object.assign(globalTreeContext.promiseMap, promiseMap);209 Object.assign(globalTreeContext.cacheMap, cacheMap);210 cacheSrcMap = { ...cacheMap };211};212213const TreeContext = createContext<SuspenseTreeContextType>(undefined as never);214const useTreeContext = () => useContext(TreeContext) || globalTreeContext;215export const getDataFromTree = async (216 element: ReactElement,217 timeout?: number218): Promise<SuspenseTreeContextType | undefined> => {219 if (!isServer) return Promise.resolve(undefined);220 const promiseMap: PromiseMap = {};221 const cacheMap: { [key: string]: unknown } = {};222 const ReactDOMServer = require("react-dom/server");223 const isStreaming = "renderToReadableStream" in ReactDOMServer;224 if (isStreaming) {225 ReactDOMServer.renderToReadableStream(226 createElement(227 TreeContext.Provider,228 { value: { promiseMap, cacheMap } },229 element230 )231 );232 } else {233 ReactDOMServer.renderToStaticNodeStream(234 createElement(235 TreeContext.Provider,236 { value: { promiseMap, cacheMap } },237 element238 )239 ).read();240 }241 let length = Object.keys(promiseMap).length;242 const promiseTimeout = new Promise(243 (resolve) => timeout && setTimeout(resolve, timeout)244 );245 for (;;) {246 const result = await Promise.race([247 Promise.all(248 Object.values(promiseMap)249 .filter((v) => !isStreaming || !v.streaming)250 .map((v) => v.promise)251 ),252 promiseTimeout,253 ]);254 if (!result) {255 break;256 }257258 const newlength = Object.keys(promiseMap).length;259 if (newlength === length) break;260 length = newlength;261 }262 return { cacheMap, promiseMap };263};
必要な機能は全てここに詰め込みました。
SuspenseLoader の使い方
ソースコードなど
動作確認
https://next-streaming.vercel.app/
ソースコード
https://github.com/SoraKumo001/next-streaming
静的 SSR 処理の作成部分
_app.tsx
1import { AppContext, AppProps } from "next/app";2import React from "react";3import {4 getDataFromTree,5 setSuspenseTreeContext,6 SuspenseTreeContextType,7} from "@react-libraries/suspense-loader";89const App = (props: AppProps & { context: SuspenseTreeContextType }) => {10 const { Component, context } = props;11 setSuspenseTreeContext(context);12 return <Component />;13};1415App.getInitialProps = async ({ Component, router, AppTree }: AppContext) => {16 const context = await getDataFromTree(17 <AppTree Component={Component} pageProps={{}} router={router} />,18 140019 );20 return { context };21};22export default App;
getInitialProps に getDataFromTree を入れてプレレンダリングします。引数の 1400 は 1.4 秒で静的 SSR を切り上げる設定です。必要なデータは content に入れて、本レンダリング側に渡します。
主要処理の抜粋
SuspenseLoader でデータをロードし、コンポーネント内で useSuspenseData()経由でデータを取得しています。loader には動作確認用に遅延設定を入れています。Vercel の Demo にはリロードなどの機能は入っていませんが、こちらのサンプルには本番で必要になりそうな動作は一通り入れてあります。
ちなみに Story コンポーネントは Vercel の Demo からほぼそのまま持ってきています。
1const PageStreaming = () => {2 return (3 <Page>4 <div>5 <Link href="/">⬅️</Link> SSR Streaming6 </div>7 <News wait={0} type="streaming" />8 </Page>9 );10};1112export const loader = ({13 type,14 wait,15}: {16 type: string;17 wait: number;18}): Promise<unknown | undefined> =>19 fetch(`https://hacker-news.firebaseio.com/v0/${type}.json`)20 .then((v) => v.json())21 .then(22 async (v) =>23 (await new Promise((r) =>24 wait ? setTimeout(r, wait) : r(undefined)25 )) || v26 )27 .catch(() => undefined);2829const News = ({ wait, type }: { wait: number; type: SuspenseType }) => {30 const dispatch = useRef<SuspenseDispatch>();31 return (32 <>33 <div>34 <button35 onClick={() => {36 location.reload();37 }}38 >39 Reload(Browser)40 </button>{" "}41 <button42 onClick={() => {43 dispatch.current!();44 }}45 >46 Reload(CSR)47 </button>48 </div>49 <hr />50 <SuspenseLoader51 dispatch={dispatch} //Dispatch for reloading52 name="news" //Name the SSR transfer data.53 loader={loader} //A loader that returns a Promise54 loaderValue={{ type: "topstories", wait }} //Parameters to be passed to the loader (can be omitted if not needed)55 fallback={<Spinner />} //Components to be displayed while loading56 onLoaded={() => console.log("Loading complete")} //Events that occur after loading is complete57 type={type}58 >59 <NewsWithData wait={wait} type={type} />60 </SuspenseLoader>61 </>62 );63};64export const NewsWithData = ({65 wait,66 type,67}: {68 wait: number;69 type: SuspenseType;70}) => {71 const storyIds = useSuspenseData<number[] | undefined>();72 if (!storyIds) return null;73 return (74 <>75 {storyIds.slice(0, 30).map((id) => {76 return (77 <SuspenseLoader78 key={id}79 name={`News/${id}`}80 loader={loader}81 loaderValue={{ type: `item/${id}`, wait }}82 fallback={<Spinner />}83 onLoaded={() => console.log(`Loading complete(${id})`)}84 type={type}85 >86 <Story />87 </SuspenseLoader>88 );89 })}90 </>91 );92};9394export const Story = () => {95 const { id, title, date, url, user, score, commentsCount } = useSuspenseData<{96 id: number;97 title: string;98 date: string;99 url: string;100 user: String;101 score: number;102 commentsCount: number;103 }>();104 const dispatch = useSuspenseDispatch();105 const { host } = url ? new URL(url) : { host: "#" };106 const [voted, setVoted] = useState(false);107 return (108 <div style={{ margin: "5px 0" }}>109 <div className="title">110 <span111 style={{112 cursor: "pointer",113 fontFamily: "sans-serif",114 marginRight: 5,115 color: voted ? "#ffa52a" : "#ccc",116 }}117 onClick={() => setVoted(!voted)}118 >119 ▲120 </span>121 <a href={url}>{title}</a>122 {url && (123 <span className="source">124 <a href={`http://${host}`}>{host.replace(/^www\./, "")}</a>125 </span>126 )}127 </div>128 <div className="meta">129 {score} {plural(score, "point")} by{" "}130 <a href={`/user?id=${user}`}>{user}</a>{" "}131 <a href={`/item?id=${id}`}>{date}</a> |{" "}132 <a href={`/item?id=${id}`}>133 {commentsCount} {plural(commentsCount, "comment")}134 </a>{" "}135 |{" "}136 <a137 style={{138 background: "lightGray",139 borderRadius: "4px",140 cursor: "pointer",141 }}142 onClick={() => {143 dispatch();144 }}145 >146 Reload147 </a>148 </div>149 </div>150 );151};
まとめ
今回作成したサンプルは RSC を使用していません。その関係で react@17 以前と互換性があります。コードを一文字も変更すること無く、そのまま旧バージョンで稼働します。ただし concurrentFeatures の設定は外さないと Next.js に怒られます。react@17 を使用した場合の注意点として、下位互換で動くので SSR-Streaming は動きません。静的 SSR と CSR のハイブリッドになります。
SSR-Streaming は開発途上の機能です。しかし「へえ、新機能があるんだ?」と、ちょっと動かして納得する程度で終わらせたら面白くありません。さらにその先の新機能を見据えて突き進むのがプログラミングの醍醐味です。ということで、年末年始の空いた時間はこのネタでかなり楽しめました。