2025年1月3日 金曜日
Next.js によるコンポーザブル・キャッシング
投稿者Next.js のためにシンプルで強力なキャッシングモデルを開発しています。以前の投稿では、キャッシングにおける私たちの道のりと、'use cache' ディレクティブにたどり着いた経緯についてお話ししました。
この記事では、'use cache' の API デザインとメリットについて説明します。
'use cache' とは?
'use cache' は、必要に応じてデータやコンポーネントをキャッシュすることで、アプリケーションを高速化します。
これは JavaScript の「ディレクティブ」—コードに追加する文字列リテラル—であり、Next.js コンパイラに別の「境界」に入るように指示します。例えば、サーバーからクライアントへの遷移などです。
これは、React の 'use client' や 'use server' といったディレクティブと似た考え方です。ディレクティブはコードの実行場所を定義するコンパイラ命令であり、フレームワークが個々の部分を最適化およびオーケストレーションできるようにします。
どのように機能するのでしょうか?
簡単な例から始めましょう。
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)
// Oops! Where do we include id in the cache key?
.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>
{/* Changing children doesn’t break the cache... why? */}
{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>
{/* Create a hole to the client */}
<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() を呼び出すことができます。強力なパターンとして、データ(例: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 内ではまだ実験的な機能です。テストしてフィードバックをお寄せいただけると幸いです。