📄

Next.js の fetchCache を考える

2023/07/08に公開
3

Next.js App Router は global のfetch関数に patch をあてており、自動でデータ取得が最適化されるように設計されています。そのうちの一つが、取得したデータをキャッシュし、再利用する最適化です。キャッシュされた取得データは、必要に応じて任意のタイミングで Revalidate(データ再取得・キャッシュ更新)が可能です。本稿はこのfetch関数を経由してキャッシュされる「fetchCache」について、どのように向き合うべきか考察したメモです。

検証留意点

はじめに、筆者が検証にあたり留意した点を書いていきます。本稿を読んでみて「自分でも検証してみたい!」となった方は、参考にしていただければと思います。

【1】取得データがキャッシュされるタイミング

タイミングは2通りあります。

  • ビルド時: next buildで Next.js アプリをビルドした時
  • リクエスト時: next startした Next.js アプリにブラウザからのリクエストが発生した時

このキャッシュされるタイミングは Pages Router における ISR(Incremental Static Regeneration)と同様です。Pages Router は「ページ単位」のキャッシュとなっていましたが、App Router においては「データ取得単位」となっており、よりきめ細かなキャッシュ戦略を取れるようになっています。

【2】fetchCache を都度削除する

上述のとおりnext buildでビルドした Next.js アプリをnext startしなければ、キャッシュの挙動は確認できません。そのためnext devで起動した開発サーバーは対象外にする必要があります。

また検証時、前回ビルド時にキャッシュされたファイルがそのまま残るため注意が必要です。ローカル開発環境においては、キャッシュは静的ファイルとして.next/cache/fetch-cacheに生成されます。例えばこんな感じのファイルです(フォーマット済)このフォルダ内出力を監視することで、どのタイミングでキャッシュが行われるのか目視確認できます。

.next/cache/fetch-cache/ee9c18264e5cc119a6db9334ae99a0e26fb6975947d8332197a5a152e77f2248
{
  "kind": "FETCH",
  "data": {
    "headers": {
      // ...
    },
    // base64変換されたbody
    "body": "eyJ0aW1lIjoiMjAyMy0wNy0wOFQwNDo1ODoxMC41NDdaIiwibWV0aG9kIjoiR0VUIn0=",
    "status": 200,
    "tags": [
      "/page"
    ]
  },
  "revalidate": 31536000
}

このキャッシュファイルが残っていたままだと違いに気付けないため、検証設定値を変更するごとに以下のコマンドで clean build & start するようにします。

rm -rf .next && npm run build && npm start

ビルド時に生成される fetchCache

Next.js は「ビルド時にできる限りキャッシュ可能なものはしてしまう」という方針をとっています。ビルド時に Layout・Page を事前レンダリングしてまわり、そのタイミングでfetch()が実行されると、fetchCache が生成されます。

例えば以下の場合、dataAがキャッシュ可能ならば.next/cache/fetch-cacheに該当のファイルがビルド時に生成されることが確認できます。ビルド時にこのようなキャッシュ生成を行うため、ビルド環境で API サーバーに疎通できるようにしておく必要があるのはこのためです。

import { cookies } from "next/headers";

export default async function Page() {
  const dataA = await fetchDataA(); // <- キャッシュされる(静的データの場合)
  return "...";
}

Dynamic Functions とは「ビルド時にキャッシュすべきではない境界」として判定基準にされる関数のことです。例えばcookies()はリクエストヘッダーの cookie を参照する関数ですが、これは「ビルド時点では、どんなリクエストであるか特定できない」ということを意味します。

Dynamic Functions がビルド時のデータ取得中に実行されると、以降のデータ取得は中断されます。つまり、Dynamic Functions 使用後のdataBは、キャッシュ可能なデータであっても、ビルド時キャッシュの対象外になります。

import { cookies } from "next/headers";

export default async function Page() {
  const dataA = await fetchDataA(); // <- キャッシュされる(静的データの場合)
  const cookieStore = cookies(); // <- 以降のキャッシュを中断する Dynamic Function
  const dataB = await fetchDataB(); // <- キャッシュされない(静的データでも)
  return "...";
}

リクエスト時にキャッシュされる fetchCache

Dynamic Functions の影響でビルド時にキャッシュされなかった上記例のdataBですが、ずっとキャッシュされないわけではありません。以下のように fetch関数オプションに{ cache: "force-cache" }を指定している場合、リクエスト時にキャッシュされます。キャッシュして欲しいデータに関しては、このように明示的にforce-cacheを指定します。

export async function fetchDataB() {
  const res = await fetch("https://example.api.com/api/data-b", {
    cache: "force-cache",
  });
  const data = await res.json();
  return data;
}

このリクエスト時キャッシュ生成を確認するためには、ビルド時に到達しなかった処理に到達する、つまり該当ページを誰かが閲覧する必要があります。「{ cache: "force-cache" }を指定しているのに API サーバーにリクエストが飛ばないのは何故?」とビルド時に悩んだら、ページを閲覧したかを確認しましょう。閲覧した瞬間に.next/cache/fetch-cacheに静的キャッシュファイルが増えることが確認できたら、リクエスト時キャッシュがうまくいっています。

ちなみにfetch関数で取得したデータがキャッシュされるか否かの判定に、リクエストメソッドは関係ありません。POST も対象となるため、fetch による GraphQL 取得データも fetchCache としてキャッシュされます(ビルド時・リクエスト時共通)

fetchCache 設定を変更する

さて、ここまで解説した fetchCache についてですが、Next.js に設けられたデフォルト設定そのままとしていました。この fetchCache 設定は必要に応じて変更することができ、fetch関数の{ cache }オプションを一律変更します。Layout または Page ファイルで、以下のようにfetchCacheを export します。

export const fetchCache = "auto";
// 'auto' | 'default-cache' | 'only-cache'
// 'force-cache' | 'force-no-store' | 'default-no-store' | 'only-no-store'

この fetchCache 設定はセグメント毎に設定でき、セグメントにネストされた Route すべてが対象になります。話が複雑にならないよう、本稿では RootLayout にのみ設定しているものとして話を進めます(RootLayout に設定すれば、全てのfetch関数が対象になる)

なお、公式ドキュメントにも書いてあるとおり、これはアドバンスな設定となっているため、本当に必要になったタイミングに応じて指定しましょう。

【1】only-no-store / only-cache:絶対にキャッシュしたくない(したい)

"only-no-store"を指定すると、個別fetch関数に{ cache: "force-cache" }が指定されていると、ビルド時にエラーが発生します。完全に認証要件で構成されているプロジェクトの場合、{ cache: "force-cache" }が個別指定されているのは誤りである可能性が高いため、ビルドエラーで気づくことができます。

export const fetchCache = "only-no-store";

反対に"only-cache"指定は、個別fetch関数に{ cache: "no-store" }が指定されていると、ビルド時にエラーを発生させます。

export const fetchCache = "only-cache";

このonly-*指定ですが、有効範囲は「Dynamic Functions」使用前に限られます。そのため、Dynamic Functions を跨ぐとビルド時のチェックをすり抜けてしまうため、執筆時点現在、そこまで効力があるものではないようです。

【2】force-cache / force-no-store:個別指定を強制上書きしたい

個別fetch関数に指定されている{ cache }オプションを全て上書きます。

// 全て`{ cache: "no-store" }`に上書き
export const fetchCache = "force-no-store";

// 全て`{ cache: "force-cache" }`に上書き
export const fetchCache = "force-cache";

個別指定は「意思をもって」指定されているはずですから、この設定をする場面はレアケースだと筆者は考えています。たくさんの fetch 関数にひとつずつ書くのが面倒だから、というケースでは有効かもしれません。あるいは、サードパーティライブラリを使っていて、その中で書かれている設定を上書きしないといけない、というケースも考えられます。

【3】default-no-store:基本的にキャッシュしたくない

基本的にキャッシュを行いませんが、個別fetch関数に{ cache: "force-cache" }が指定されているものに限り、キャッシュされます。キャッシュされるタイミングは、fetch関数使用が Dynamic Functions 使用前後に起因して「ビルド時・リクエスト時」で切り替わります。

export const fetchCache = "default-no-store";

【4】default-cache:基本的にキャッシュしたい

基本的にキャッシュを行いますが、個別fetch関数に{ cache: "no-store" }が指定されているものに限り、キャッシュを阻止します。キャッシュされるタイミングは、fetch関数使用が Dynamic Functions 使用前後に起因して「ビルド時・リクエスト時」で切り替わります。fetchCache設定を何も指定していない場合(auto)とほぼ同じですが「Dynamic Functions を跨いでもリクエスト時にキャッシュする」という点が異なります。

export const fetchCache = "default-cache";

なおこの fetchCache 設定をしている場合、{ cache: "no-store" }の個別指定を忘れてしまうと、個人に紐づくデータ(動的データ)もキャッシュされる可能性があるため、注意が必要です。

【5】auto:Next.js デフォルトの設定

Next.js に設定されているデフォルトの fetchCache 設定です。default-cacheとほとんど変わりませんが「Dynamic Functions を跨ぐとリクエスト時にキャッシュしない」という点が異なります。リクエスト時でキャッシュしたい場合、個別fetch関数に{ cache: "force-cache" }が指定する必要があります。

export const fetchCache = "auto";

fetchCache を安全に活用するには

Next.js はビルド時にキャッシュしてまわるほど「キャッシュを積極的に行うべし」という思想のようです。筆者は検証中に「autoよりもdefault-cacheの方が Next.js の思想に近いのでは?」という点を疑問に感じました。autoは「Dynamic Functions を跨ぐとリクエスト時にキャッシュしない」ため、キャッシュされて欲しい静的データ取得は個別fetch関数に{ cache: "force-cache" }をつけてまわらなければいけません。これは面倒なことのように思いますが、筆者は安全性を優先した結果だと察しています。

リクエスト時でなければキャッシュできないデータ取得は、Dynamic Functions 使用後に限られます。データ取得に header 情報を使用するか否かにか関わらず、この文脈以降の処理は個人に紐づくデータ取得が続く可能性が高いです。こういった背景から「force-cache指定のないデータ取得の自動キャッシュは Dynamic Functions を境界に打ち切る」という判断は腑に落ちます。

このauto設定は、安全性と利便性を両立しようとした結果だと理解はできます。しかしながら、自明な実装を犠牲にしているようにも感じます。公式ドキュメントではわかりやすさを優先した結果「fetch()は自動で{ cache: "force-cache" }が設定されます」と説明を(一部で)打ち切っていますが、実際には Dynamic Functions の影響が多分にあります。これはデフォルトで適用されるautoの挙動を説明しきれておらず、混乱の元になっているのではないでしょうか。

App Router fetchCache を今後検討する機会があれば、筆者は以下の方針を基本的にとるものと考えています。キャッシュすべきか否かの判断は自動に頼らず、意思をもってforce-cacheまたはno-storeを指定したいと思います。

  • 認証要件が多めなので、キャッシュは慎重に行いたい
    • export const fetchCache = "default-no-store"を設定
    • キャッシュして欲しいデータ取得は、必ず個別にforce-cacheを指定してまわる
  • 認証要件が少なめなので、キャッシュは積極的に行いたい(事故に注意)
    • export const fetchCache = "default-cache"を設定
    • キャッシュされてはいけないデータ取得は、必ず個別にno-storeを指定してまわる

※注意:本稿では比較要因を減らすため意図的に{ revalidate: 0 }オプションは除いています。あらかじめご了承ください。

追記

「Dynamic Functions が個別の fetch cache 設定をスイッチする境界である」という内容を記載しましたが、正確にはやや異なります。本稿でいう Dynamic Functions とはnext/headersから import するcookiesheadersのみを指しています。しかし、Next.js のドキュメントにおいて Dynamic Functions にはsearchParamsも含まれています。このsearchParams参照を跨ぐだけなら、fetchCache の自動 cache 設定はデフォルトforce-cacheのままとなります。こういった背景からも、安全性を優先した結果の挙動のように思いました。

Discussion

koichikkoichik
  • 認証要件が全く絡まない
    • export const fetchCache = "only-cache"を設定

これはいくらなんでも豪快すぎるというか極端すぎませんかね?w
認証が絡む・絡まないは異なる利用者間ではキャッシュを共有できないという、いわば空間軸の話だとすると、それとは別に時間軸、いわゆるキャッシュの鮮度というのも考慮する必要があると思います
認証要件が絡まないからといって新鮮なデータをフェッチしなくていいとは限らないので、"only-cache"が適用できるかどうかは他の要件次第になるんじゃないかと思いました

TFTF

わかりやすい説明ありがとうございます。
VercelのAWS Lambdaにホストした場合、インスタンスがspin upしたコールド・スタート状態のとき、ビルド時にキャッシュしたデータが使用されるのでしょうか?Cloud Runなどの場合、メモリの状態が引き継がれるようなことがあると記憶していますが、そのあたりキャッシュの整合性はどうしているのかなとふと思いました。

TakepepeTakepepe

Vercel 以外の環境で意図通りにキャッシュを共有する方法は、筆者も現在模索中です。ISR を Vercel 以外の環境で実現することが難しかったように、インスタンスを跨ぐキャッシュ共有は同様の課題があります。

ただ、以下のような機能も最近は出てきていますので、工夫次第ではうまく構成できるかもしれません。本稿で紹介している fetchCache は、この Incremental Cache のうちの一つと認識しています。
https://nextjs.org/docs/app/api-reference/next-config-js/incrementalCacheHandlerPath

このような機能が実現できていることを鑑みると、ローカル環境で確認できる.nextフォルダのキャッシュファイルも、ランタイムによっては静的ファイルではない、ということもあるかもしれません。