😽

Next.jsでハイブリッドSSR-Streamingを実装する

21 min read

※ こちらでも同じ記事を書いています

https://next-blog.croud.jp/contents/MGw8m0Ulp8FueHKF5dM1

近い未来の SSR

Next.js と React18 の組み合わせで SSR-Streaming という機能が利用可能になりました。これによって時間のかかる非同期データが必要なコンポーネントは、ローディング表示を出しつつ準備が整ったら表示を切り替えるという処理を一本の HTML コネクションで行えます。しかし遅延して出力される HTML は JavaScript によって再配置が必要なため、CSR で後からデータを送って再レンダリングするのと違いが見えにくいという印象があります。これを有効活用するためハイブリッド SSR-Streaming という仕組みを考えてみました。

SSR(ServerSideRendering の種類)

  • 従来の非同期データの 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 で HTML を返しつつ、時間のかかる処理は Streaming に回します。ただし単純に併用するわけではありません。静的 SSR の限界時間を設定し、その時間を超えたら自動的に Streaming に切り替えるようにします。するとユーザーの待ち時間を調整しつつ、初期表示と追加表示のバランスを取ることが出来ます。これがハイブリッド SSR-Streaming です。静的 SSR のタイムアウト後に CSR に切り替えるというのも有効な方法です。ただし CSR に切り替えた場合は、サーバ側で取得中だったデータがタイムアウトと共に無駄になります。最大の効率を得るためには Streaming と併用が望まれます。

Vercel と SSR の制約

ホスティングに 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 をやる場合は、時間のかかる非同期処理を 1.5 秒以内に収める必要があるのです。つまりハイブリッド SSR-Streaming の出番です。

React のコンポーネントの種類

Vercel の SSR-Streaming の Demo では 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 になる

という具合に RSC について紹介しましたが、今回 SSR-Streaming をするに当たって RSC は使用しません。RSC 不要で諸々面倒な作業を吸収する SuspenseLoader というライブラリを用意したので、使うのは全て SharedComponents となります。

SSR-Streaming を使うために

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

serverComponents の設定は RSC を使っている場合は必要になりますが、ハイブリッド SSR-Streaming では全てをSharedComponentsで実装しているので不要です。
swcMinify と concurrentFeatures を併用すると、Vercel でエラーを起こすため注意が必要です。

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 を実現する SuspenseLoader

npm に登録してあります

https://www.npmjs.com/package/@react-libraries/suspense-loader

ハイブリッド 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 };
};
const isReact18 = Number(/(\d+)/.exec(React.version)![0]) >= 18;
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
    | ((value: T, dispatch: SuspenseDispatch<V>) => ReactNode);
  load: () => Promise<unknown>;
  streaming?: boolean;
}) => {
  const { isInit, isSuspenseLoad, value } = property;
  if (!isInit && (isReact18 || !isServer)) throw load();
  const [isRequestData, setRequestData] = useState(
    (isSuspenseLoad || isServer) && streaming
  );
  useEffect(() => setRequestData(false), []);
  const contextValue = useMemo(() => {
    return { value, dispatch };
  }, [value, dispatch]);
  if (!isInit) {
    load();
    return null;
  }
  return (
    <SuspenseDataContext.Provider value={contextValue}>
      {isRequestData && (
        <script
          id={idName}
          type="application/json"
          dangerouslySetInnerHTML={{
            __html: JSON.stringify({ value }),
          }}
        />
      )}
      {typeof children === "function"
        ? children(value as T, dispatch)
        : 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
    | ((value: T, dispatch: SuspenseDispatch<V>) => ReactNode);
  dispatch?: MutableRefObject<SuspenseDispatch<V> | undefined>;
  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];
      reload({});
    },
    [cacheMap, name, promiseMap, property, reload]
  );
  if (dispatch) {
    dispatch.current = loadDispatch;
  }
  const [isCSRFallback, setCSRFallback] = useState(type === "csr");
  useEffect(() => {
    setCSRFallback(false);
  }, []);
  if (isCSRFallback) return <>{fallback}</>;
  if (isServer && !isReact18) {
    if (promiseMap[name] && !property.isInit) return <>{fallback}</>;
    return (
      <>
        <SuspenseWapper<T, V>
          idName={idName}
          property={property}
          dispatch={loadDispatch}
          load={load}
          streaming={!cacheSrcMap[name]}
        >
          {children}
        </SuspenseWapper>
      </>
    );
  }
  return (
    <Suspense fallback={fallback || false}>
      <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 にはリロードなどの機能は入っていませんが、こちらのサンプルには本番で必要になりそうな動作は一通り入れてあります。

ちなみに おおよその動作は Vercel の Demo と同じです。Demo に無い機能としては、ページ表示後の CSR 動作、ページング機能、ユーザ情報表示、コメント表示などです。ソースは完全に作り直しているので、ぶっちゃけ原型をとどめていません。

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>
  );
};

SuspenseLoaderの下位に配置したコンポーネントは、useSuspenseDataという hook で loader が返したデータを受け取れます。Suspense や promise の throw 処理は隠蔽されているので一切書く必要はありません。

まとめ

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

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

GitHubで編集を提案

Discussion

ログインするとコメントできます