Next.js の App Router のキャッシュ方式が変わるらしい

本業のいくつかあるプロジェクトの中で App Router 自体の GA がちょうど発表された直後くらいのタイミングで開発が始まったものがあり、せっかくなのでということで App Router で書かれているものがある。
プロジェクト特性として認証などの要件も緩くキャッシュ周りは不安だが全体としては概ね好意的な印象を持っていたがそのキャッシュ周りも変わりそうということで Our Journey with Caching を読んだのでメモに残す。

結論から言ってしまえば、 use cache というキーワードを新規に導入して、これまで暗黙的に扱う方向に倒してたものを明示的に「キャッシュが必要である」という宣言を開発者にさせるという方針となるらしい。

自分のプロジェクトでも、この記事の中にあるような以下の言及通り暗黙的にキャッシュが利用されてしまうという部分をうまく使いこなせずになんとかしてこの機能をオプトアウトする (実際には全てオプトアウトできているわけではない) ような設定を入れている。
おそらく静的なサイトなどであればこれによって自動的にパフォーマンスの恩恵を受けることができるという狙いなのだと思うがなかなか直感に反していたと思うので今回の変更は割と好意的に受け止めている。

This led to the need for segment-level configs, such as export const dynamic, runtime, fetchCache, dynamicParams, revalidate = …, as an escape hatch.

基本的には Our Journey with Caching とその参照先である Directives: use cache を見てもらえればいいような気はしつつ自分で見返した時のためにも気になったところをメモする。

おそらく根幹となる考え方としては、 「外部からデータを取得するようなものについては、それは常に動的かキャッシュを利用するかを選択する必要がある」 というもの。
※「外部からデータを取得するようなもの」はより正確には、 async Node API 全てに適用されるので API コールだけではなく、データベースへの接続はタイマーなども該当するらしい

async function Component() {
  return fetch(...) // error
}
 
export default async function Page() {
  return <Component />
}

全てについて明示的に選ばないとエラーになるというのは幾分かやりすぎな気もしつつ、静的型付け言語が好きな私としてはコンパイルタイム (js の世界だとビルドタイム?) で問題を教えてくれるというのは非常に嬉しく思う。

キャッシュを使わない場合 (Dynamic と記事では呼んでいる)

async function Component() {
  return fetch(...) // no error
}
 
export default async function Page() {
  return <Suspense fallback="..."><Component /></Suspense>
}

<Suspense> を利用することでこれはキャッシュを使わないということを明示することになるらしい。
<Suspense> - React にもある通りだが、<Suspense> 自体はフォールバック先を指定するためのコンポーネントの認識なのでこれがあるだけで「常にデータを取得しにいく必要がある」ことを明示していると考えるのは若干飛躍があるようには思ってしまうもののわかってしまえば飲み込める。

キャッシュを利用する場合 (Static と記事では呼んでいる)

一方でキャッシュを利用する場合には use cache を利用するとのこと。

"use cache"
 
export default async function Page() {
  return fetch(...) // no error
}

ちなみに以下のように use serveruse client は React 自体の機能 (React Server Component を利用している場合の機能) だが use cache は Next.js の機能となるらしい。

This is an experimental Next.js feature, and not a native React feature like use client or use server.

キャッシュの破棄 (revalidate)

キャッシュを利用する際には、キャッシュの破棄のコントロールが重要になる。
Next.js では、Revalidating にある通り time-based revalidationon-demand revalidation の2つの方法があるようだ。  

time-based revalidation

Time-based revalidation with cacheLife で紹介されている方法。
要するにキャッシュの有効時間を設定しておき時間が経過したら破棄されるという考え方。
Next.js の App Router はそのコンポーネントが client で動作したり、 server で動作したりする関係で設定も3種類ある。

なんだかわかるようなわからないような、、、
ドキュメント内では以下のような記述もあった

  1. Cache HIT: If a request is made within the 15 minute window, the cached data is served, and is a cache HIT.
  2. Stale data: If the request happens after 15 minutes the cached value is still served, but is now considered stale. Next.js will recompute a new cache entry in the background.
  3. Cache MISS: If the cache entry expires and a subsequent request is made, then Next.js will treat this as a cache MISS, and the data will be recomputed and fetched again from the source.

動作確認などはしていないので正確なことはわかってはないが上記の説明から察するに stale-while-revalidating のような考え方 (参考: Stale-While-Revalidate ヘッダによるブラウザキャッシュの非同期更新) で stalerevalidate はリクエストが来たタイミングで処理し expire のタイミングが来ると完全にキャッシュ自体を破棄してオリジンなどからデータを取得するようになる (つまり Cache MISS 状態になる?) のかなと想像している。
そのうち動作検証もしてみたい (しているブログを読みたい)。

もうちょっとイメージしやすいようにデフォルトで用意されている設定も紹介しておく (とはいえこれを読んでもなんとなくしか理解できていない)

Profile Stale Revalidate Expire Description (意訳)
default undefined 15 minutes INFINITE_CACHE デフォルト、更新性がそこまでないコンテンツの場合に有効
seconds undefined 1 second 1 minute リアルタイムに近い更新が必要なもののための設定
minutes 5 minutes 1 minute 1 hour 毎時くらいで更新されるようなもののための設定
hours 5 minutes 1 hour 1 day 日次で更新されるようなコンテンツだが古い情報が一定出てしまうことを許容する
days 5 minutes 1 day 1 week 週次で更新されるようなコンテンツで1日遅れでも良いようなもの
weeks 5 minutes 1 week 1 month 月次で更新があるようなコンテンツで1週間くらい古いものを見せても良い
max 5 minutes 1 month INFINITE_CACHE ほとんど更新がないようなコンテンツのための設定

Default cache profiles より抜粋

これらは、以下のように利用可能とのこと

'use cache'
import { unstable_cacheLife as cacheLife } from 'next/cache'
 
cacheLife('minutes')

on-demand revalidation

こっちはもうちょっとシンプルで Revalidate on-demand with cacheTag でも紹介されている通り、 cacheTag を使って (≒ key を指定して) 破棄をコントロールするもの。

こんな感じで使えるよう
※ time-based revalidation の cacheLife と併せて使うことも可能らしい

import {
  unstable_cacheTag as cacheTag,
  unstable_cacheLife as cacheLife,
} from 'next/cache'
 
export async function getData() {
  'use cache'
  cacheLife('weeks')
  cacheTag('my-data')
 
  const data = await fetch('/api/data')
  return data
}
'use server'
 
import { revalidateTag } from 'next/cache'
 
export default async function submit() {
  await addPost()
  revalidateTag('my-data')
}

revalidateTag の API の詳細は revalidateTag にあるとのこと

感想

App Router の初期の方のキャッシュは暗黙的すぎて一部の人たちには刺さるんだろうけれど、難しいと感じていたが今回の変更でより明示的になり個人的には良いように思える。
一方で time-based revalidation のそれぞれの挙動はまだいまいち理解しきれていないのと、例えば client side のキャッシュがどのような制限があるかなどについても見つけれていない。
実際に GA になり利用する際にはパフォーマンス上は便利なものの、やはり認証などが絡むと大きな問題になりやすい部分でもあるし検証が難しい部分でもあるので気をつけたいところではある。

参考