Next.jsによるコンポーザブルキャッシング
2025年1月3日金曜日 - Lee Robinson @ leerob による投稿
私たちはNext.jsのためのシンプルで強力なキャッシングモデルに取り組んでいます。以前の投稿では、キャッシングに関する私たちの取り組みと、どのようにして 'use cache' ディレクティブに辿り着いたかについて話しました。この投稿では、'use cache' のAPI設計と利点について説明します。
'use cache' とは何か?
'use cache' は、必要に応じてデータやコンポーネントをキャッシュすることで、アプリケーションを高速化します。これはJavaScriptの「ディレクティブ」—コードに追加する文字列リテラル—で、Next.jsコンパイラに異なる「境界」に入るよう信号を送ります。例えば、サーバーからクライアントへの移行などです。これは 'use client' や 'use server' などのReactディレクティブと似たアイデアです。
ディレクティブは、コードがどこで実行されるべきかを定義するコンパイラ命令で、フレームワークが個々の部分を最適化し、調整することを可能にします。
どのように動作するか?
シンプルな例から始めましょう:
async function getUser(id) {
'use cache';
let res = await fetch(`https://api.vercel.app/user/${id}`);
return res.json();
}
舞台裏では、Next.jsは 'use cache' ディレクティブにより、このコードをサーバー関数に変換します。コンパイル時に、このキャッシュエントリの「依存関係」が見つけられ、キャッシュキーの一部として使用されます。例えば、id がキャッシュキーの一部になります。
getUser(1) を複数回呼び出すと、キャッシュされたサーバー関数からメモ化された出力を返します。この値を変更すると、キャッシュに新しいエントリが作成されます。
クロージャを使用してサーバーコンポーネントでキャッシュされた関数を使用する例を見てみましょう:
function Profile({ id }) {
async function getNotifications(index, limit) {
'use cache';
return await db.select()
.from(notifications)
.limit(limit)
.offset(index)
.where(eq(notifications.userId, id));
}
return <User notifications={getNotifications} />;
}
この例はより複雑です。キャッシュキーの一部である必要があるすべての依存関係を見つけることができますか?
引数 index と limit は理にかなっています—これらの値が変更されると、通知の異なるスライスを選択します。しかし、ユーザー id はどうでしょうか?その値は親コンポーネントから来ています。
コンパイラは getNotifications が id にも依存していることを理解でき、その値は自動的にキャッシュキーに含まれます。これにより、キャッシュキーの不正確または欠落した依存関係によるキャッシングの問題のカテゴリ全体を防ぐことができます。
なぜcache関数を使わないのか?
最後の例を再考してみましょう。代わりに cache() 関数をディレクティブの代わりに使用できるでしょうか?
function Profile({ id }) {
async function getNotifications(index, limit) {
return await cache(async () => {
return await db.select()
.from(notifications)
.limit(limit)
.offset(index)
.where(eq(notifications.userId, id));
});
}
return <User notifications={getNotifications} />;
}
cache() 関数はクロージャを調べて、id 値がキャッシュキーの一部であるべきことを見ることができません。id がキーの一部であることを手動で指定する必要があります。これを忘れたり、間違って行ったりすると、キャッシュの衝突や古いデータのリスクがあります。
クロージャはあらゆる種類のローカル変数をキャプチャできます。素朴なアプローチでは、意図しない変数を誤って含めたり(または省略したり)する可能性があります。これは間違ったデータをキャッシュすることにつながったり、機密情報がキャッシュキーに漏れた場合にキャッシュポイズニングのリスクを生じさせる可能性があります。
'use cache' は、コンパイラにクロージャを安全に処理し、キャッシュキーを正しく生成するのに十分なコンテキストを提供します。cache() のようなランタイムのみのソリューションでは、すべてを手動で行う必要があり、間違いを犯しやすくなります。対照的に、ディレクティブは静的に分析でき、すべての依存関係を内部で確実に処理できます。
非シリアル化入力値はどのように処理されるか?
キャッシュする入力値には2つの異なるタイプがあります:
シリアル化可能:ここで「シリアル化可能」とは、入力が意味を失うことなく安定した文字列ベースの形式に変換できることを意味します。多くの人は最初に JSON.stringify を考えますが、実際にはReactのシリアル化(例:Server Componentsを介して)を使用して、promise、循環データ構造、その他の複雑なオブジェクトを含む、より広範囲の入力を処理します。これは純粋なJSONができることを超えています。
非シリアル化可能:これらの入力はキャッシュキーの一部ではありません。これらの値をキャッシュしようとすると、サーバー「参照」を返します。この参照は、Next.jsがランタイムで元の値を復元するために使用されます。
キャッシュキーに id を含めることを覚えていたとしましょう:
await cache(async () => {
return await db.select()
.from(notifications)
.limit(limit)
.offset(index)
.where(eq(notifications.userId, id));
}, [id, index, limit]);
これは入力値がシリアル化できる場合に機能します。しかし、id がReact要素やより複雑な値だった場合、入力キーを手動でシリアル化する必要があります。
id propに基づいて現在のユーザーを取得するサーバーコンポーネントを考えてみましょう:
async function Profile({ id, children }) {
'use cache';
const user = await getUser(id);
return (
<>
<h1>{user.name}</h1>
{/* childrenを変更してもキャッシュが壊れない...なぜ? */}
{children}
</>
);
}
これがどのように動作するかを段階的に見てみましょう:
-
コンパイル時に、Next.jsは 'use cache' ディレクティブを見て、キャッシングをサポートする特別なサーバー関数を作成するためにコードを変換します。コンパイル時にはキャッシングは発生せず、Next.jsはランタイムキャッシングに必要なメカニズムを設定しています。
-
コードが「キャッシュ関数」を呼び出すと、Next.jsは関数の引数をシリアル化します。JSXのように直接シリアル化できないものは、「参照」プレースホルダーに置き換えられます。
-
Next.jsは、与えられたシリアル化された引数に対してキャッシュされた結果が存在するかどうかをチェックします。
-
結果が見つからない場合、関数はキャッシュする新しい値を計算します。
-
関数が終了した後、戻り値がシリアル化されます。戻り値の非シリアル化可能な部分は参照に戻されます。
-
キャッシュ関数を呼び出したコードは出力を逆シリアル化し、参照を評価します。これにより、Next.jsは参照を実際のオブジェクトや値と交換でき、children のような非シリアル化可能な入力が元の、キャッシュされていない値を保持できます。
これは、<Profile> コンポーネントだけを安全にキャッシュし、childrenはキャッシュしないことができることを意味します。後続のレンダリングでは、getUser() は再び呼び出されません。children の値は動的であったり、異なるキャッシュライフを持つ別々にキャッシュされた要素である可能性があります。これがコンポーザブルキャッシングです。
これは見覚えがある...
「それはサーバーとクライアントの構成の同じモデルのように感じる」と思っているなら—まったくその通りです。これは時々「ドーナツ」パターンと呼ばれます:
- ドーナツの外側の部分は、データ取得や重いロジックを処理するサーバーコンポーネントです
- 真ん中の穴は、何らかのインタラクティビティを持つ可能性のある子コンポーネントです
export default function Page() {
return (
<ServerComponent>
{/* クライアントへの穴を作成 */}
<ClientComponent />
</ServerComponent>
);
}
'use cache' も同じです。ドーナツは外側のコンポーネントのキャッシュされた値で、穴はランタイムで埋められる参照です。これが、childrenを変更してもキャッシュされた出力全体が無効化されない理由です。childrenは後で埋められる単なる参照です。
タグ付けと無効化はどうなるか?
異なるプロファイルでキャッシュの寿命を定義できます。デフォルトのプロファイルセットを含んでいますが、必要に応じて独自のカスタム値を定義できます。
async function getUser(id) {
'use cache';
cacheLife('hours');
let res = await fetch(`https://api.vercel.app/user/${id}`);
return res.json();
}
特定のキャッシュエントリを無効化するには、キャッシュにタグを付けて revalidateTag() を呼び出すことができます。強力なパターンの1つは、データを取得した後(例:CMSから)にキャッシュにタグを付けることです:
async function getPost(postId) {
'use cache';
let res = await fetch(`https://api.vercel.app/blog/${postId}`);
let data = await res.json();
cacheTag(postId, data.authorId);
return data;
}
シンプルで強力
'use cache' での私たちの目標は、キャッシングロジックの作成をシンプルで強力にすることです。
シンプル:ローカルな推論でキャッシュエントリを作成できます。忘れられたキャッシュキーエントリやコードベースの他の部分への意図しない変更など、グローバルな副作用を心配する必要がありません。
強力:静的に分析可能なコードだけでなく、それ以上のものをキャッシュできます。例えば、ランタイムで変更される可能性がある値でも、評価された後の出力結果をキャッシュしたい場合があります。
'use cache' はNext.js内でまだ実験的です。テストしていただく際の早期フィードバックをお待ちしています。詳細はドキュメントをご覧ください。