Next.js Router Cache (Client Cache)について

Next.js

公開日時:2024/06/09

前書き

Next.js の app routerで多くのユーザを悩ませているのがキャッシュ周りではないでしょうか?
個人がある程度理解したつもりでも解釈が間違っていたり、いつの間にか仕様が変わっていたり...(執筆時点でver15がrcとして公開されており、デフォルトの挙動が変わる予定になっている...)
実際のプロジェクトに採用してチームとして共通の認識を持つことは難しいと感じています。
感度が高いメンバーが揃っていれば良いかもしれませんが、そんなことはほぼあり得ない。
何かを見つけるたびに時間がかかってしまうような気がしてしまいます...。(大袈裟に言うと技術大好きチームの遊び道具としてはもってこい)

とはいえNext.jsはReactのフレームワークとしては知らない人はいないだろうと言っても良いほどの知名度であり、app routerを採用することも増えてきているのではないでしょうか?この流れは必然的でありapp routerが悪いというわけではありません。というのもReact自身がRSC(React Server Component)というものを搭載し、それに対応するために生まれたのがapp routerだからです。
(簡単にいうとサーバサイドでレンダリングすることができるようにした)

私もその一人でプロジェクトでapp routerを採用することになり、色々とトライしている真っ最中なのでその勉強の一環として記事を書いていきたいと思います。

Router Cache(Client Cache)

タイトルにも書いた通り今回のテーマはRouter Cache
公式ドキュメント: https://nextjs.org/docs/app/building-your-application/caching#router-cache

Next.jsのキャッシュは4つあると書かれていますが、一番厄介なのがこのRouter Cacheなのではないかと感じています。
これは唯一のClient-Sideのキャッシュなのです。
これが密接に関係してくるのは <Link>になってきます。この <Link>ただの <a>をラップしたコンポーネントではなく、非常に多くの機能を持ったものになってます。
<Link> を使うことにより、Nextの支配するrouter上でのページ遷移が可能となり高速なページ遷移が可能となります。
私はこのrouterというものが厄介だと感じます。なぜなら、 <Link> 自体はサーバサイドコンポーネントで使用できてしまうからです。
<Link> を使わなくても useRouter.push() を実行することで同じ挙動を得ることができます。しかし、これはクライアントコンポーネントでしか使えません。また公式ドキュメントにも useRouter は特別な場合のみ使用してね。と書いてあります。(https://nextjs.org/docs/app/api-reference/functions/use-router)
詳細は割愛しますが、ここで大事なのはrouterという存在と <Link> コンポーネントの関係性でした。

もう一つ重要なのが、静的レンダリングと動的レンダリングについてです。
以下の画像はbuild時のターミナルの画像になります。この⚪︎がついてる箇所は静的レンダリング、fがついている箇所が動的レンダリングになります。簡単にいうと [id] のように記述することでbuild時には決まっておらず、ユーザがアクセスした時に動的にルートが生成されるようになります。Client Cacheというのはこの動的レンダリングが非常に密接に関わってくることになります。

※静的レンダリングされるものに関してはServer Cacheが関連してくることになるので、今回のテーマから外れるので解説は無し。

ここからが本題のRouter Cacheについて
Router Cacheはブラウザの一時メモリに保存されます。つまりユーザのリクエストによって生成されたルーティングがそのユーザのブラウザ(クライアントサイド)に記憶されるということになります。
このキャッシュはページをリロードした場合には破棄されます。 <a> によるページ遷移でも失われます。
<Link> もしくは useRouter を用いたページ遷移でのみ有効になります。

ここでちょっとしたサンプルを用意します。構成は上に添付した画像通りです。
src/app/blog/[id]/page.tsx を作成します。
このコンポーネントはサーバコンポーネントで動的レンダリングされるものです。また挙動がわかりやすくなるようにsetTimeoutで時間がかかる処理を再現しています。

src/app/blog/[id]/page.tsx
import Link from 'next/link';

const sleep = (ms: number) => {
  return new Promise((resolve) => setTimeout(resolve, ms));
};

const getTime = async () => {
  await sleep(2000);
  return new Date().toLocaleTimeString();
};

const BlogArticlePage = async ({ params }: { params: { id: string } }) => {
  console.log('client-side fetching time...');
  const time = await getTime();
  console.log('time fetched:', time);
  return (
    <div>
      <h1>Article</h1>
      <p>{params.id}</p>
      <p>{time}</p>
      <Link href='/blog'>Back to Blog</Link>
    </div>
  );
};

export default BlogArticlePage;

このページへの導線を作成します。
<Link> コンポーネントと useRouter を使ったものを用意しています。

src/app/blog/page.tsx
import Link from 'next/link';
import { ClientLink } from './ClientLink';

const BlogPage = () => {
  return (
    <div>
      <h1>Welcome BlogPage</h1>
      <div className='flex flex-col space-y-2 items-center'>
        <Link href='/blog/1'>Link to blog/1</Link>
        <ClientLink />
      </div>
    </div>
  );
};

export default BlogPage;
src/app/blog/ClientLink.tsx
'use client';

import { useRouter } from 'next/navigation';

export const ClientLink = () => {
  const router = useRouter();
  const handleClick = () => {
    router.push('/blog/1');
  };
  return <button onClick={handleClick}>clientLink to blog/1</button>;
};

※動作確認は全てbuild環境で行います。(dev環境と挙動が異なるため)

この2つのLinkのうちどちらでも良いのですが、最初に遷移を行うとawaitの処理が走り、ページ遷移が完了するのに少し時間がかかります。しかし、2回目以降はそのタイムラグが発生することなくページ遷移を行うことができるようになることがわかります。
これが Router Cacheになります。スムーズな状態で何回かページ遷移を繰り返してみましょう。そのうち再度awaitの処理が走りページ遷移が重くなるタイミングがきます。何が起きたかというとRouter Cacheが破棄されたことが原因です。実はRouter Cacheには寿命があるのです。しかもこの寿命を握るのは <Link> コンポーネントなのです。これが結構大きな罠ではないかなと思ってます。

デフォルトで2つの時間が設定されており、staticとdynamicです。(この名前がまた曲者でdynamic-routeとは別物)

  • dynamic: Linkのprefetchが未指定の場合 (デフォルト) => 30秒
  • static: Linkのprefetchがtrue またはrouter.prefetchを呼び出す時 => 5分

このdynamicの設定により、デフォルトでは寿命が30秒となっているわけです。しかもこの設定はconfigで設定できるものの、個別に設定することはできません。https://nextjs.org/docs/app/api-reference/next-config-js/staleTimes
また無効にすることもできません。つまり、リアルタイムで更新されるようなものには全く向かないのです。
例えばサーバサイドのfetchのcacheを無効にして常に最新のデータを取得できるようにしたとしても、Router Cacheが生きている間はRouterがそれを返すため、サーバの通信が行われません。この挙動はパフォーマンス的には良いのですが、知っておかないと大変なことになります。リアルタイム性を求めるものは全てクライアントサイドでの処理にする必要があります。(当然といえば当然かも)

ここでもう一つ、staticというものが出てきました。これは <Link> コンポーネントにprefetchというプロパティを設定することで、遷移先をprefetchした場合にstaticになります。もう少し具体的にいうと、 prefetchを設定した <Link> が画面上に存在する場合、ユーザが遷移する可能性があるページとしてNextが裏でページ遷移を先回り(prefetch)してくれるというものになります。
そしてこの機能を使うとなぜかRouter Cacheの寿命が5分になります。

試しにやってみましょう。
と言っても追加するのは一箇所だけです。

src/app/blog/page.tsx
import Link from 'next/link';
import { ClientLink } from './ClientLink';

const BlogPage = () => {
  return (
    <div>
      <h1>Welcome BlogPage</h1>
      <div className='flex flex-col space-y-2 items-center'>
        <Link href='/blog/1' prefetch> // <- prefetchを追加
          Link to blog/1
        </Link>
        <ClientLink />
      </div>
    </div>
  );
};

export default BlogPage;

これで画面を確認すると、ページ遷移を行う前にターミナルにlogが吐かれているのが確認できます。awaitの処理が完了するとさらにlogが吐かれます。つまりページ遷移せずともprefetchにより事前に処理が完了しているのです。
この状態でページ遷移するともちろん待つことなくすぐにページが表示されます。またページ遷移を繰り返すと、Router Cacheの寿命が5分になっていることも確認できます。
prefetchを有効活用することでユーザの体験を向上させることができます。しかし、このprefetchを使うとデフォルトでは5分という長い時間Router Cacheを保持することになってしまいます。これは機能によっては致命的なことになりかねないのでとても注意が必要です。また、ブラウザのメモリというのがどれほどの領域を持っているのかわかりませんが、ページ遷移が多数行われるとその分メモリを圧迫するのでパフォーマンス的に大丈夫なのだろうかとも思ってしまいます。

まとめ

  • Router Cacheはクライアントサイドのページ遷移に関係する
  • ページ遷移は <Link> コンポーネントを使用することが推奨されている
    • useRouter はクライアントコンポーネント限定で prefetchがしづらいため?
  • 動的レンダリングされるルートは <Link> コンポーネントの prefetchでRouter Cacheの生存時間が変わる
    • prefetchなし: 30秒(デフォルト)
    • prefetchあり: 5分(デフォルト)
  • Router Cacheが生存しているうちはクライアントサイドだけで完結するため、サーバサイドの処理が走らない