😎

Next.jsとReact18で簡単SSR-Streaming

2022/01/03に公開約11,100字2件のコメント

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

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

今回のソースコード

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 のインストール
yarn add react@beta react-dom@beta

その他は Next.js に必要なものをいつも通り入れてください。

  • next.config.js の設定変更
/**
 * @type { import("next").NextConfig}
 */
const config = {
  experimental: {
    concurrentFeatures: true,
  },
};
module.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 コンポーネントを使います。

<Suspense>{SSR - Streamingしたいコンポーネント}</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 を行っています。

<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 コンポーネントとして扱われるのでクライアント側でマウントされません。

RSC(ReactServerComponent)を使わず、通常コンポーネントのみで SSR-Streaming を行うプログラムを作る

Server コンポーネントを使わない理由

SSR-Streaming を行うのに、いちいち.server や.client コンポーネントを作るのは面倒です。理想は全てを通常コンポーネントで完結させることです。というか、初手で Demo を確認せずに SSR-Streaming をやり始めたため、気がついたら通常コンポーネントで全て実装するという力業を完遂していました。

Streaming で送った通常コンポーネントがどうなるのか

通常コンポーネントで Streaming した場合の動作を復習します

  • サーバ

    • データ取得用 promise が完了
    • throw で中断していたコンポーネントの再レンダリング
    • HTML に変換されクライアントへ
  • クライアント

    • 追加分の HTML を受け取る
    • JavaScript が必要な位置へ再配置

という流れを踏みます。さて、ここで重要な問題を理解する必要があります。クライアントに送られるのは HTML データです。そしてコンポーネントのレンダリングには非同期で取得したデータを利用しました。この状態でクライアントがコンポーネントの再レンダリングを行うと、レンダリングに必要な props データが無いので表示内容が吹っ飛びます。

通常コンポーネントで内容を吹き飛ばさないためにすること

一番簡単なのはクライアント側でコンポーネントが再レンダリングされないように throw します。書き換えがストップするので、目出度く HTML の表示は吹き飛ばされずに済みます。しかしこの状態だと Server コンポーネントと同じ状態になり、コンポーネントがマウントされず操作を受け付けなくなります。これが SSR-Streaming に課された制約です。

データが送られないという状況を打破する

発想の転換が必要です。SSR-Streaming で作成するコンポーネントをまともな思考で作っていたら、制約をそのまま引き継ぐことになります。引き継ぎたいのは制約ではなく、データなのです。サーバ側が持っているデータをクライアントに渡すことが出来れば、クライアント側のコンポーネントが再構築できます。つまり Server コンポーネントが Client コンポーネントを呼び出すときと同じ事を通常コンポーネントでやれば良いのです。

原理は送りたいデータを JSON にして<script>タグに貼り付けて HTML として出力し、それを受け取ったクライアントが dom から参照し props として配る流れです。

  • src/components/SuspenseLoader.tsx
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 を作ります。大したコード量にはなりませんでした。

通常コンポーネントのみで天気予報を SSR-Streaming

以下が、先ほど作った SuspenseLoader を使って天気予報を表示するシステムです。

  • src/pages/index.tsx

気象庁からエリア情報を持ってくるコードです。

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;
  • src/pages/weather/index.tsx

エリアごとの天気情報を表示するコードです

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://next-weather-streaming.herokuapp.com/

動作内容

「読み込み中」の表示で一旦停止し、1 秒後に残りの HTML が吐き出されます。

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

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

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

GitHubで編集を提案

Discussion

Herokuでの動作は問題ないものの、Vercelにデプロイすると静的SSRのサンプルがgetInitialPropsの1.5秒制限に引っかかる状態でした
対処した結果、名付けてHybrid SSR-Streamingという技術を生み出しました
静的SSRが設定時間に間に合わなければ、途中から部分的にSSR-Streamingに切り替えます

https://next-streaming.vercel.app/
ログインするとコメントできます