🙆

Next.jsでSSRを限界まで簡単に実現する

2021/09/23に公開2

1.getInitialPropsの終焉

1.1. Next.jsではgetInitialPropsのSSRが終わったことにされている

Next.jsの9.3以降、getStaticPropsやgetServerSidePropsが登場し、現在ではgetInitialPropsを使ったSSRが終焉を迎えたかのような風潮となっています。しかしgetStaticPropsとgetServerSidePropsは、実際の所で大きな欠点を抱えています。

getStaticPropsはSSG前提で使うなら全く問題ありません。しかし、ある程度の更新頻度をもつシステムやリアルタイムな編集機能と相性が悪いです。ISRで使う場合も有効期限後の一回目で古いデータが表示される仕様があるので、タイミングが悪いと、せっかっく訪れた人に古いデータを渡してしまうことがあります。使いどころによっては強力ですが、用途は限定されます。

getServerSidePropsはそのページに遷移するごとにデータを拾ってくれるので、リアルタイム性に優れています。と、普通は考えますが、遷移しないと拾ってくれません。ルーティングが起こらないと更新しないので、ページ内でデータをリロードするためには同一ページへのルーティングを発生させる必要があります。そして厄介なのが、そのルーティングが起こると、今度はデータ更新を止める術がないと言うことです。クライアントでデータをキャッシュしているから今は更新しないで欲しいという場合でも、必ずデータをとりに行きます。やる気みなぎるgetServerSidePropsを止める手段はありません。

そして終焉がやってきたと思われているgetInitialPropsです。前述の二つと決定的に違うのは、getStaticPropsやgetServerSidePropsがページコンポーネントと関連付くのに対し、getInitialPropsは全ページ共通のAppコンポーネントで利用されます。全ページ対象の機能を一カ所で集中管理する仕様です。そして前述二つはサーバサイド専用機能であるのに対し、getInitialPropsはクライアントとサーバの両方で呼び出されるので、機能の出し分けが非常に難しくなります。また、サーバ側で呼び出されるのはページをリロードしたタイミング一度きりで、ルーティングが発生しても、サーバ上では二度と呼ばれることはありません。getInitialPropsはあらゆる処理を一カ所で処理し、サーバで処理されるのは一回こっきりです。さらにクライアントからも呼び出しがあります。おそらく話を聞くだけだと、全く利点が見えてこないと思います。

1.2. さあ、今こそgetInitialPropsだ

実はSSRを前提として、自由自在なタイミングでデータのやりとりを制御しようと思ったら、結果としてgetInitialPropsしか選択肢はありません。getStaticPropsは欲しいタイミングでデータが更新されないし、getServerSidePropsはいらないときまで活動するからです。ページ更新のタイミングでgetInitialPropsを使ってSSRを行い、残りの更新処理はクライアント側でキャッシュ構造を作りつつ、必要なタイミングでリロードするのが最適なのです。

2.getInitialPropsでSSRを実現するために

2.1. サーバとクライアントで二重にfetchを書かなければならない苦難

SSRが面倒くさいのがこれです。ページをリロードしてはい終わりというシステムならサーバ側だけの処理で済みます。しかしクライアント側でデータの要求を制御するのであれば、両方でそれぞれfetchを作らなければなりません。さらに認証なんて機能を盛り込もうものなら、見るのは地獄です。

2.2. 一本化しよう

まずは二重にコードを書くというのは防ぐことにします。それを実現しているのはGraphQLで使われているApolloです。ただ、Apolloを導入しつつSSRを適切に行うためにはそれなりの精神的ハードルを越えなければなりません。動作原理だけパクらせてもらいましょう。

ApolloではどうやってコードをダブらせずSSRを実現しているかというと、答えは空レンダリングです。サーバ上で一度Reactのレンダリングを行ってデータだけ取り出し、それをAppコンポーネントのpropsに渡しています。そしてサーバ上でSSR用本レンダリングを行って、生成されたHTMLデータと作られたpropsをクライアントに渡しています。クライアント側はサーバ側がHTMLを生成するのに使ったpropsと同一内容を受け取る限り、差分が発生しないので無駄なNode変更を起こしません。

つまり同じ事をやれば、fetch処理は一本化できます。非常に簡単な話ですよね?

3.空レンダリングしてデータを渡すだけ、話は簡単

ということでやってみましょう。
まずは誰でも簡単に使える気象庁のデータを持ってくることにします。

3.1. サンプルソース

ソースコード: https://github.com/SoraKumo001/next-weather
動作確認: https://next-weather-opal.vercel.app/

src/pages/_app.tsx

SSR用にgetInitialProps内でgetDataFromTreeを実行し、空レンダリングで生成したデータをAppコンポーネントに送っています。
具体的な処理内容に関しては@react-libraries/use-ssrでソースコードを公開しています。

import {
  CachesType,
  createCache,
  getDataFromTree,
} from "@react-libraries/use-ssr";

import { AppContext, AppProps } from "next/app";

const App = (props: AppProps & { cache: CachesType }) => {
  const { Component, cache } = props;
  createCache(cache);
  return <Component />;
};
App.getInitialProps = async ({ Component, router, AppTree }: AppContext) => {
  const cache = await getDataFromTree(
    <AppTree Component={Component} pageProps={{}} router={router} />
  );
  return { cache };
};
export default App;

src/pages/index.tsx

地域一覧を取得するサンプルです

useSSRを使いSSRの対象になるデータをsetStateすることで、getDataFromTree時にデータが送られます。
useSSR上の非同期関数の終了がSSRの動作完了とみなされるので、サーバ上でfetchが完了するまで待機可能です。

クライアント上ではコンポーネントがアンマウントされた後も、明示的にstateをクリアしない限り、対象データはキャッシュされます。
ルーティングが起こっても、無駄にデータを取りに行くことはありません。
ボタンを押した際のイベントでsetState(undefined)を行っており、これによってキャッシュがクリアされ、クライアント側で再fetchが動きます。

import React from "react";
import Link from "next/link";
import { useSSR } from "@react-libraries/use-ssr";

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 Page = () => {
  const [state, setState] = useSSR<Area | null>(
    "area",
    async (state, setState) => {
      //既にデータがあったら処理しない
      if (state !== undefined) return;
      //処理中のフラグを立てる
      setState(null);
      const result = await fetch(
        `https://www.jma.go.jp/bosai/common/const/area.json`
      )
        .then((r) => r.json())
        .catch(() => null);
      //結果の保存
      setState(result);
    }
  );
  return (
    <div>
      <button onClick={() => setState(undefined)}>Reload</button>
      {state &&
        Object.entries(state.offices).map(([code, { name }]) => (
          <div key={code}>
            <Link href={`/weather/${code}`}>
              <a>{name}</a>
            </Link>
          </div>
        ))}
    </div>
  );
};
export default Page;

src/pages/weather/[id].tsx

地域コードの天気を表示するサンプルです

useSSRでは保存データにKeyを設定する必要があります
地域コードがかぶらないように["weather", String(id)]のように、配列でキーを設定しています

import { useSSR } from "@react-libraries/use-ssr";
import { useRouter } from "next/dist/client/router";
import Link from "next/link";
import React from "react";

export interface Weather {
  publishingOffice: string;
  reportDatetime: Date;
  targetArea: string;
  headlineText: string;
  text: string;
}

const Page = () => {
  const router = useRouter();
  const id = router.query["id"];
  const [state, setState] = useSSR<Weather | null>(
    ["weather", String(id)] /*CacheKeyName*/,
    async (state, setState) => {
      if (state !== undefined) return;
      setState(null);
      const result = await fetch(
        `https://www.jma.go.jp/bosai/forecast/data/overview_forecast/${id}.json`
      )
        .then((r) => r.json())
        .catch(() => null);
      setState(result);
    }
  );
  return (
    <div>
      <button onClick={() => setState(undefined)}>Reload</button>
      {state && (
        <>
          <h1>{state.targetArea}</h1>
          <div>{new Date(state.reportDatetime).toLocaleString()}</div>
          <div>{state.headlineText}</div>
          <pre>{state.text}</pre>
        </>
      )}
      <div>
        <Link href="/">
          <a>戻る</a>
        </Link>
      </div>
    </div>
  );
};
export default Page;

3.2. 動作結果

3.2.1. 動作状況

地域一覧

詳細

3.2.2. 出力されたHTML

SSRされたことが確認出来ます

4.まとめ

「SSR」 + 「クライアントでも自由に更新」という処理において、二重のコードを防いでコンパクトに記述できるようになりました。
とても簡単ですよね?
ちなみにfetch部分は、データがもってこられるのであれば他のライブラリや仕組みを利用してもかまいません。

今後の予定としてgetDataFromTreeに手を入れて、サーバ側で一定のデータをキャッシュする仕組みを作ってISR擬きをする構想があります。

GitHubで編集を提案

Discussion

snakasnaka

また、サーバ側で呼び出されるのはページをリロードしたタイミング一度きりで、ルーティングが発生しても、サーバ上では二度と呼ばれることはありません。

記事の主題とはズレるのですが、上記の箇所について手元の Next.JS 12 で確認していますが、条件によってはこのとおりの動作とはならないようでした。
( 記事を書かれた時点から Next.JS の実装もかなり変化しているので当然かもしれませんがメモとして... :pray: )

確認した条件:

  • Next.JS : next@12.3.4
  • 対象 Page が SSG 対象となっている ( 対象 Page で getStaticProps が実装されている )
  • _app.tsx に getInitialProps を実装している

上記の条件では

  • 対象 Page をリロードした場合、Linkなどで対象 Page へ遷移した場合、いずれの場合でも Server-side で getInitialProps が動作しているように見えました