Backブログに戻る

Next.js におけるコンポーザブルなキャッシュ

キャッシュ機能 'use cache' の API 設計と利点について学びます

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} />;
}

この例はより複雑です。キャッシュキーの一部となるべきすべての依存関係を見つけられますか?

引数 indexlimit は理解しやすい—これらの値が変更されると、通知の異なるスライスを選択します。しかし、ユーザー id はどうでしょうか?その値は親コンポーネントから来ています。

コンパイラは getNotificationsid にも依存していることを理解でき、その値は自動的にキャッシュキーに含まれます。これにより、キャッシュキーにおける依存関係の不足や誤りによるキャッシュ問題の全カテゴリを防ぎます。

キャッシュ関数を使用しない理由

前の例を再考しましょう。ディレクティブの代わりに cache() 関数を使用できないでしょうか?

function Profile({ id }) {
  async function getNotifications(index, limit) {
    return await cache(async () => {
      return await db
        .select()
        .from(notifications)
        .limit(limit)
        .offset(index)
        // おっと! id をキャッシュキーに含める場所は?
        .where(eq(notifications.userId, id));
    });
  }
 
  return <User notifications={getNotifications} />;
}

cache() 関数はクロージャを調べて id の値をキャッシュキーの一部にすべきか判断できません。手動で id をキーの一部として指定する必要があります。これを忘れたり、誤ったりすると、キャッシュの衝突や古いデータのリスクが生じます。

クロージャはあらゆる種類のローカル変数を捕捉できます。単純なアプローチでは、意図しない変数が誤って含まれたり(または除外されたり)する可能性があります。これにより、誤ったデータがキャッシュされたり、機密情報がキャッシュキーに漏洩してキャッシュ汚染のリスクが生じたりする可能性があります。

'use cache' はコンパイラにクロージャを安全に扱い、キャッシュキーを正しく生成するための十分なコンテキストを提供します。cache() のようなランタイムのみのソリューションでは、すべてを手動で行う必要があり、ミスを犯しやすくなります。対照的に、ディレクティブは静的に分析でき、すべての依存関係を確実に処理できます。

シリアライズ不可能な入力値の扱い

キャッシュする入力値には2種類あります:

  • シリアライズ可能: ここで「シリアライズ可能」とは、意味を失うことなく安定した文字列ベースの形式に変換できる入力を指します。多くの人が最初に JSON.stringify を思い浮かべますが、実際には React のシリアライゼーション(例えばサーバーコンポーネント経由)を使用して、プロミス、循環データ構造、その他の複雑なオブジェクトなど、より広範な入力を処理します。これはプレーンな 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 プロップに基づいて現在のユーザーを取得するサーバーコンポーネントを考えてみましょう:

async function Profile({ id, children }) {
  'use cache';
  const user = await getUser(id);
 
  return (
    <>
      <h1>{user.name}</h1>
      {/* children を変更してもキャッシュが壊れない...なぜ? */}
      {children}
    </>
  );
}

この動作を順を追って説明します:

  1. コンパイル中、Next.js は 'use cache' ディレクティブを認識し、キャッシュをサポートする特別なサーバー関数を作成するためにコードを変換します。コンパイル時にキャッシュが行われるわけではなく、Next.js はランタイムキャッシュに必要なメカニズムを設定しています。
  2. コードが「キャッシュ関数」を呼び出すと、Next.js は関数の引数をシリアライズします。JSX のように直接シリアライズできないものは、「参照」プレースホルダーに置き換えられます。
  3. Next.js は指定されたシリアライズされた引数に対してキャッシュされた結果が存在するかチェックします。結果が見つからない場合、関数はキャッシュする新しい値を計算します。
  4. 関数が終了すると、戻り値がシリアライズされます。戻り値のシリアライズ不可能な部分は参照に戻されます。
  5. キャッシュ関数を呼び出したコードは出力をデシリアライズし、参照を評価します。これにより、Next.js は参照を実際のオブジェクトや値と交換でき、children のようなシリアライズ不可能な入力は元の、キャッシュされていない値を保持できます。

これは、<Profile> コンポーネントだけを安全にキャッシュし、子をキャッシュしないことを意味します。後続のレンダリングでは、getUser() は再度呼び出されません。children の値は動的であるか、異なるキャッシュ寿命を持つ別個にキャッシュされた要素である可能性があります。これがコンポーザブルなキャッシュです。

これは見覚えが...

「サーバーとクライアントのコンポジションと同じモデルのように感じる」と思ったなら、まったく正しいです。これは時々「ドーナツ」パターンと呼ばれます:

  • 外側の部分はデータ取得や重いロジックを処理するサーバーコンポーネント
  • 中央のはインタラクティブ性を持つ可能性のある子コンポーネント
app/page.tsx
export default function Page() {
  return (
    <ServerComponent>
      {/* クライアントへの穴を作成 */}
      <ClientComponent />
    <ServerComponent />
  );
}

'use cache' も同じです。ドーナツは外側のコンポーネントのキャッシュされた値で、穴はランタイム時に埋められる参照です。これが 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 内部ではまだ実験的です。テストする際の早期フィードバックをお待ちしています。

ドキュメントで詳細を確認