空雲リファレンス

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 以降が必要です
yarn add react@rc react-dom@rc
  • next.config.js に以下の設定が必要です
/** * @type { import("next").NextConfig} */ const config = { experimental: { concurrentFeatures: true, }, }; module.exports = config;

serverComponents の設定は RSC を使っている場合は必要になりますが、ハイブリッド SSR-Streaming では全てをSharedComponentsで実装しているので不要です。

SSR-Streaming の構造

想定される真っ当なパターン

Suspense の中で非同期処理を行い、非同期処理で生成された promise をthrow promiseします。すると Suspense が代替コンポーネントを表示し、promise 終了時点で ServerComponents 以下の結果をクライアントへ返します。

<SharedComponents> <Suspense> // HTMLに変換、再マウントはされない、ClientComponentsにpropsを渡せる <ServerComponents> // クライアント側で再マウントされる <ClientComponents> <SharedComponents /> </ClientComponents> </ServerComponents> </Suspense> </SharedComponents>

クライアントへ送られたとき、<ServerComponents>はコンポーネントとして再マウントされません。その代わり<ClientComponents>へ props の引き渡しが行えます。クライアントで動的に処理したいものは<ClientComponents>に記述します。

出来るけどやるとデータが吹っ飛ぶパターン

<SharedComponents> <Suspense> <SharedComponents /> // HTMLに変換、再マウントされる、データは消失する </Suspense> </SharedComponents>

一応、動きます。<SharedComponents>throw promiseを行えば、きちんと HTML 化され Streaming もされます。クライアント引き渡し後は<ClientComponents>と同じように再マウントもされます。問題点があるとすると、再マウント時に props を失うことです。非同期で取り出したデータが再マウントと再レンダリングによって消失します。何故かというと、レンダリング済みの HTML は送られるものの、それを作るためのデータは送られないからです。つまり Streaming 時にサーバ側で作成したデータが使えない状態となります。

つまり SSR-Streaming をする場合は、<ServerComponents><ClientComponents>を組み合わせるのが定石となります。

<ServerComponents>の問題点とその対処

クライアントから操作できないことです。Web アプリの動作は SSR でクライアントにデータを返したら終わりではありません。その後、リロードが必要になったり、内容を色々書き換えたりするときに<ServerComponents>が邪魔になります。

元々<ServerComponents>はサーバ側でコンポーネントを HTML 化することによって、クライアントに関連 JavaScript のデータを不要にするための技術です。データの受け渡しに使うのは本業ではありません。柔軟性を考えるのであれば全部<SharedComponents>だけで構成するのが理想です。

ということで<SuspenseLoader>というコンポーネントを作りました。

<SharedComponents> //SuspenseLoader自体はSharedComponentsになっており、Suspenseを内蔵している //ServerComponentsのpropsを引き渡す機能を力業で実装しているのでデータが消えない <SuspenseLoader> <SharedComponents /> // 力業で送ったデータはgetSuspenseData()で取得する </SuspenseLoader> </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 のソースコードです。

import React, { createContext, createElement, MutableRefObject, ReactElement, ReactNode, Suspense, SuspenseProps, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; type SuspensePropeatyType<T, V> = { value?: T; isInit?: boolean; isSuspenseLoad?: boolean; loaderValue?: V; }; type PromiseMap = { [key: string]: { streaming: boolean; promise: Promise<unknown> | undefined }; }; export type SuspenseType = "streaming" | "ssr" | "csr"; export type SuspenseTreeContextType = { promiseMap: { [key: string]: { streaming: boolean; promise: Promise<unknown> | undefined; }; }; cacheMap: { [key: string]: unknown }; }; export type SuspenseDispatch<T = unknown> = (value?: T) => void; const isServer = typeof window === "undefined"; const SuspenseDataContext = createContext<{ value: unknown; dispatch: unknown; }>(undefined as never); export const useSuspenseData = <T,>() => useContext(SuspenseDataContext).value as T; export const useSuspenseDispatch = <V,>() => useContext(SuspenseDataContext).dispatch as SuspenseDispatch<V>; const SuspenseWapper = <T, V>({ property, idName, dispatch, children, load, streaming, }: { property: SuspensePropeatyType<T, V>; idName: string; dispatch: SuspenseDispatch<V>; children: ReactNode; load: () => Promise<unknown>; streaming?: boolean; }) => { const { isInit, isSuspenseLoad, value } = property; if (!isInit) throw load(); const [isRequestData, setRequestData] = useState( (isSuspenseLoad || isServer) && streaming ); useEffect(() => setRequestData(false), []); const contextValue = useMemo(() => { return { value, dispatch }; }, [value, dispatch]); return ( <SuspenseDataContext.Provider value={contextValue}> {isRequestData && ( <script id={idName} type="application/json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ value }), }} /> )} {children} </SuspenseDataContext.Provider> ); }; export const SuspenseLoader = <T, V>({ name, loader, loaderValue, fallback, onLoaded, children, dispatch, type = "streaming", }: { name: string; loader: (value: V) => Promise<T>; loaderValue?: V; fallback?: SuspenseProps["fallback"]; onLoaded?: (value: T) => void; children: ReactNode; dispatch?: MutableRefObject<SuspenseDispatch<V> | undefined>; streaming?: boolean; ssr?: boolean; type: SuspenseType; }) => { const reload = useState({})[1]; const idName = "#__NEXT_DATA__STREAM__" + name; const { promiseMap, cacheMap } = useTreeContext(); const property = useRef<SuspensePropeatyType<T, V>>({}).current; if (!property.isInit) { const value = cacheMap[name] as T | undefined; if (value) { property.value = value; property.isInit = true; property.isSuspenseLoad = false; onLoaded?.(value); } } const load = useCallback(() => { const promise = (isServer && (promiseMap[name]?.promise as Promise<T>)) || new Promise<T>((resolve) => { if (!isServer) { const node = document.getElementById(idName); if (node) { property.isSuspenseLoad = true; resolve(JSON.parse(node.innerHTML).value); return; } } loader(property.loaderValue || (loaderValue as V)).then((v) => { property.isSuspenseLoad = false; resolve(v); }); }); promise.then((value) => { property.isInit = true; property.value = value; cacheMap[name] = value; onLoaded?.(value); }); if (isServer) promiseMap[name] = { promise, streaming: type === "streaming" }; return promise; }, [ promiseMap, cacheMap, name, type, loader, property, loaderValue, idName, onLoaded, ]); const loadDispatch = useCallback( (value?: V) => { property.value = undefined; property.isInit = false; property.loaderValue = value; delete cacheMap[name]; delete promiseMap[name]; setVisible(isServer || false); reload({}); }, [cacheMap, name, promiseMap, property, reload] ); if (dispatch) { dispatch.current = loadDispatch; } const [isCSRFallback, setCSRFallback] = useState(type === "csr"); useEffect(() => { setCSRFallback(false); }, []); const [isVisible, setVisible] = useState(true); useEffect(() => { setVisible(true); }, [isVisible]); return isCSRFallback ? ( <>{fallback}</> ) : ( <Suspense fallback={fallback || false}> {isVisible && ( <SuspenseWapper<T, V> idName={idName} property={property} dispatch={loadDispatch} load={load} streaming={!cacheSrcMap[name]} > {children} </SuspenseWapper> )} </Suspense> ); }; const globalTreeContext = { promiseMap: {}, cacheMap: {}, }; let cacheSrcMap: { [key: string]: unknown } = {}; export const setSuspenseTreeContext = (context?: SuspenseTreeContextType) => { if (!context) return; const { promiseMap, cacheMap } = context; Object.assign(globalTreeContext.promiseMap, promiseMap); Object.assign(globalTreeContext.cacheMap, cacheMap); cacheSrcMap = { ...cacheMap }; }; const TreeContext = createContext<SuspenseTreeContextType>(undefined as never); const useTreeContext = () => useContext(TreeContext) || globalTreeContext; export const getDataFromTree = async ( element: ReactElement, timeout?: number ): Promise<SuspenseTreeContextType | undefined> => { if (!isServer) return Promise.resolve(undefined); const promiseMap: PromiseMap = {}; const cacheMap: { [key: string]: unknown } = {}; const ReactDOMServer = require("react-dom/server"); const isStreaming = "renderToReadableStream" in ReactDOMServer; if (isStreaming) { ReactDOMServer.renderToReadableStream( createElement( TreeContext.Provider, { value: { promiseMap, cacheMap } }, element ) ); } else { ReactDOMServer.renderToStaticNodeStream( createElement( TreeContext.Provider, { value: { promiseMap, cacheMap } }, element ) ).read(); } let length = Object.keys(promiseMap).length; const promiseTimeout = new Promise( (resolve) => timeout && setTimeout(resolve, timeout) ); for (;;) { const result = await Promise.race([ Promise.all( Object.values(promiseMap) .filter((v) => !isStreaming || !v.streaming) .map((v) => v.promise) ), promiseTimeout, ]); if (!result) { break; } const newlength = Object.keys(promiseMap).length; if (newlength === length) break; length = newlength; } return { cacheMap, promiseMap }; };

必要な機能は全てここに詰め込みました。

SuspenseLoader の使い方

ソースコードなど

  • 動作確認

https://next-streaming.vercel.app/

  • ソースコード

https://github.com/SoraKumo001/next-streaming

静的 SSR 処理の作成部分

  • _app.tsx
import { AppContext, AppProps } from "next/app"; import React from "react"; import { getDataFromTree, setSuspenseTreeContext, SuspenseTreeContextType, } from "@react-libraries/suspense-loader"; const App = (props: AppProps & { context: SuspenseTreeContextType }) => { const { Component, context } = props; setSuspenseTreeContext(context); return <Component />; }; App.getInitialProps = async ({ Component, router, AppTree }: AppContext) => { const context = await getDataFromTree( <AppTree Component={Component} pageProps={{}} router={router} />, 1400 ); return { context }; }; export default App;

getInitialProps に getDataFromTree を入れてプレレンダリングします。引数の 1400 は 1.4 秒で静的 SSR を切り上げる設定です。必要なデータは content に入れて、本レンダリング側に渡します。

主要処理の抜粋

SuspenseLoader でデータをロードし、コンポーネント内で useSuspenseData()経由でデータを取得しています。loader には動作確認用に遅延設定を入れています。Vercel の Demo にはリロードなどの機能は入っていませんが、こちらのサンプルには本番で必要になりそうな動作は一通り入れてあります。

ちなみに Story コンポーネントは Vercel の Demo からほぼそのまま持ってきています。

const PageStreaming = () => { return ( <Page> <div> <Link href="/">⬅️</Link> SSR Streaming </div> <News wait={0} type="streaming" /> </Page> ); }; export const loader = ({ type, wait, }: { type: string; wait: number; }): Promise<unknown | undefined> => fetch(`https://hacker-news.firebaseio.com/v0/${type}.json`) .then((v) => v.json()) .then( async (v) => (await new Promise((r) => wait ? setTimeout(r, wait) : r(undefined) )) || v ) .catch(() => undefined); const News = ({ wait, type }: { wait: number; type: SuspenseType }) => { const dispatch = useRef<SuspenseDispatch>(); return ( <> <div> <button onClick={() => { location.reload(); }} > Reload(Browser) </button>{" "} <button onClick={() => { dispatch.current!(); }} > Reload(CSR) </button> </div> <hr /> <SuspenseLoader dispatch={dispatch} //Dispatch for reloading name="news" //Name the SSR transfer data. loader={loader} //A loader that returns a Promise loaderValue={{ type: "topstories", wait }} //Parameters to be passed to the loader (can be omitted if not needed) fallback={<Spinner />} //Components to be displayed while loading onLoaded={() => console.log("Loading complete")} //Events that occur after loading is complete type={type} > <NewsWithData wait={wait} type={type} /> </SuspenseLoader> </> ); }; export const NewsWithData = ({ wait, type, }: { wait: number; type: SuspenseType; }) => { const storyIds = useSuspenseData<number[] | undefined>(); if (!storyIds) return null; return ( <> {storyIds.slice(0, 30).map((id) => { return ( <SuspenseLoader key={id} name={`News/${id}`} loader={loader} loaderValue={{ type: `item/${id}`, wait }} fallback={<Spinner />} onLoaded={() => console.log(`Loading complete(${id})`)} type={type} > <Story /> </SuspenseLoader> ); })} </> ); }; export const Story = () => { const { id, title, date, url, user, score, commentsCount } = useSuspenseData<{ id: number; title: string; date: string; url: string; user: String; score: number; commentsCount: number; }>(); const dispatch = useSuspenseDispatch(); const { host } = url ? new URL(url) : { host: "#" }; const [voted, setVoted] = useState(false); return ( <div style={{ margin: "5px 0" }}> <div className="title"> <span style={{ cursor: "pointer", fontFamily: "sans-serif", marginRight: 5, color: voted ? "#ffa52a" : "#ccc", }} onClick={() => setVoted(!voted)} > &#9650; </span> <a href={url}>{title}</a> {url && ( <span className="source"> <a href={`http://${host}`}>{host.replace(/^www\./, "")}</a> </span> )} </div> <div className="meta"> {score} {plural(score, "point")} by{" "} <a href={`/user?id=${user}`}>{user}</a>{" "} <a href={`/item?id=${id}`}>{date}</a> |{" "} <a href={`/item?id=${id}`}> {commentsCount} {plural(commentsCount, "comment")} </a>{" "} |{" "} <a style={{ background: "lightGray", borderRadius: "4px", cursor: "pointer", }} onClick={() => { dispatch(); }} > Reload </a> </div> </div> ); };

まとめ

今回作成したサンプルは RSC を使用していません。その関係で react@17 以前と互換性があります。コードを一文字も変更すること無く、そのまま旧バージョンで稼働します。ただし concurrentFeatures の設定は外さないと Next.js に怒られます。react@17 を使用した場合の注意点として、下位互換で動くので SSR-Streaming は動きません。静的 SSR と CSR のハイブリッドになります。

SSR-Streaming は開発途上の機能です。しかし「へえ、新機能があるんだ?」と、ちょっと動かして納得する程度で終わらせたら面白くありません。さらにその先の新機能を見据えて突き進むのがプログラミングの醍醐味です。ということで、年末年始の空いた時間はこのネタでかなり楽しめました。