Backブログに戻る

Next.js App Routerにおけるキャッシュの進化

Next.js App Routerでのキャッシュに関する私たちの取り組みについて学びましょう。

フロントエンドのパフォーマンス最適化は難しい課題です。高度に最適化されたアプリケーションであっても、最も一般的な問題はクライアント-サーバー間のウォーターフォール現象です。Next.js App Routerを導入する際、私たちはこの問題を解決したいと考えていました。そのためには、クライアント-サーバー間のRESTフェッチをサーバーサイドに移行し、React Server Componentsを使って単一の往復で処理する必要がありました。これは、サーバーが時として動的であることを意味し、Jamstackの優れた初期読み込みパフォーマンスを犠牲にすることになりました。私たちはこのトレードオフを解決し、両方の利点を得るために部分的なプリレンダリングを構築しました。

しかし、その過程で、私たちが提供したキャッシュのデフォルト設定と制御により、開発者体験が損なわれることになりました。fetch()のデフォルト設定はパフォーマンスを重視してキャッシュを有効にしましたが、迅速なプロトタイピングや高度に動的なアプリケーションには不向きでした。また、fetch()を使用しないローカルデータベースアクセスに対する十分な制御を提供していませんでした。unstable_cache()はありましたが、使い勝手が良くありませんでした。これにより、export const dynamic, runtime, fetchCache, dynamicParams, revalidate = ...といったセグメントレベルの設定が必要となり、緊急避難的な解決策として使われるようになりました。

もちろん、後方互換性のためにこれらを引き続きサポートします。しかし、ここで一旦それらを忘れてください。私たちはもっとシンプルな解決策を考えています。

私たちは、<Suspense>use cacheという2つの概念に基づいた新しい実験的なモードを開発中です。

冒険を選ぼう

最初に気づくのは、コンポーネントにデータを追加するとエラーが発生するようになったことです。

app/page.tsx
async function Component() {
  return fetch(...) // エラー
}
 
export default async function Page() {
  return <Component />
}

データ、クッキー、ヘッダー、現在時刻、またはランダムな値を使用する場合、今や選択肢があります:データをキャッシュ(サーバーサイドまたはクライアントサイド)したいのか、それとも毎リクエストごとに実行したいのか?ここではfetch()を例にしていますが、これはデータベースやタイマーなどの非同期Node APIにも適用されます。

動的(Dynamic)

まだ開発中だったり、高度に動的なダッシュボードを構築している場合、コンポーネントを<Suspense>境界でラップできます。<Suspense>は動的なデータ取得とストリーミングを選択します。

app/page.tsx
async function Component() {
  return fetch(...) // エラーなし
}
 
export default async function Page() {
  return <Suspense fallback="..."><Component /></Suspense>
}

これはルートレイアウトで行うことも、loading.tsxを使用することもできます。

これにより、アプリのシェルは即座に表示されることが保証されます。ページ内にさらにデータを追加しても、デフォルトで全てが動的になります。デフォルトでは何もキャッシュされません。隠れたキャッシュはもうありません。

静的(Static)

静的なものを構築していて動的機能を使用したくない場合、新しいuse cacheディレクティブを使用できます。

app/page.tsx
"use cache"
 
export default async function Page() {
  return fetch(...) // エラーなし
}

use cacheでページをマークすることで、そのセグメント全体をキャッシュすることを示します。これにより、フェッチしたデータはキャッシュ可能になり、ページを静的にレンダリングできます。静的コンテンツには<Suspense>境界は使用されません。ページにさらにデータを追加しても、すべてキャッシュされます。

部分的(Partial)

両方を組み合わせることもできます。例えば、ルートレイアウトにuse cacheを追加してキャッシュされるようにできます。各レイアウトやページは独立してキャッシュできます。

app/layout.tsx
"use cache"
 
export default async function Layout({ children }) {
  const response = await fetch(...)
  const data = await response.json()
  return <html>
    <body>
      <div>{data.notice}</div>
      {children}
    </body>
  </html>
}

一方、特定のページ内で動的データを使用する場合:

app/page.tsx
import { Suspense } from 'react'
async function Component() {
  return fetch(...) // エラーなし
}
 
export default async function Page() {
  return <Suspense fallback="..."><Component /></Suspense>
}

キャッシュ関数(Cached functions)

このようなハイブリッドアプローチを使用する場合、API呼び出しの近くにキャッシュを追加する方が便利かもしれません。

use serverと同様に、任意の非同期関数にuse cacheを追加できます。サーバーを呼び出すのではなく、キャッシュを呼び出すサーバーアクションと考えてください。JSON以外の豊富な引数と戻り値の型をサポートしています。キャッシュキーには自動的に引数とクロージャが含まれるため、手動でキャッシュキーを指定する必要はありません。

app/layout.tsx
async function getNotice() {
  "use cache"
  const response = await fetch(...)
  const data = await response.json()
  return data.notice;
}
 
export default async function Layout({ children }) {
  return <html>
    <body>
      <h1>{await getNotice()}</h1>
      {children}
    </body>
  </html>
}

このレイアウトで他のデータが使用されていないため、静的のままにできます。このアプローチの利点は、誤ってレイアウトに新しい動的データを追加すると、ビルド時にエラーが発生し、新しい選択を強制されることです。レイアウト全体にuse cacheを追加すると、エラーなくキャッシュされます。どちらのアプローチを選ぶかはユースケースによります。

キャッシュのタグ付け(Tagging a cache)

タグで明示的にキャッシュエントリをクリアしたい場合、use cache関数内で新しいcacheTag() APIを使用できます。

app/utils.ts
import { cacheTag } from 'next/cache';
 
async function getNotice() {
  'use cache';
  cacheTag('my-tag');
}

その後、以前と同様にサーバーアクションからrevalidateTag('my-tag')を呼び出します。

このAPIはデータ読み込み後に呼び出せるため、データを使用してキャッシュエントリにタグを付けられるようになりました。

app/actions.ts
import { unstable_cacheTag as cacheTag } from 'next/cache';
 
async function getBlogPosts(page) {
  'use cache';
  const posts = await fetchPosts(page);
  for (let post of posts) {
    cacheTag('blog-post-' + post.id);
  }
  return posts;
}

キャッシュの寿命定義(Defining the lifetime of a cache)

特定のエントリやページがキャッシュに保持される期間を制御したい場合、cacheLife() APIを使用できます:

app/page.tsx
"use cache"
import { unstable_cacheLife as cacheLife } from 'next/cache'
 
export default async function Page() {
  cacheLife("minutes")
  return ...
}

デフォルトで以下の値を受け入れます:

  • "seconds"(秒)
  • "minutes"(分)
  • "hours"(時間)
  • "days"(日)
  • "weeks"(週)
  • "max"(最大)

ユースケースに最適な大まかな範囲を選択してください。1週間が何秒(またはミリ秒?)か正確な数を指定して計算する必要はありません。ただし、特定の値を指定したり、独自の名前付きキャッシュプロファイルを設定することもできます。

revalidateに加えて、このAPIはクライアントキャッシュのstale時間と、トラフィックが少ない場合にページがいつ期限切れになるかを決定するexpireを制御できます。

実験的(Experimental)

これはまだ非常に実験的なプロジェクトです。まだ本番環境での使用に耐えるものではなく、不足している機能やバグがあります。特に、この新しいタイプのエラーに対するエラースタックの改善が必要であることを認識しています。しかし、冒険心がある方には、早期のフィードバックをお待ちしています。

より詳細なアップグレードパスを公開する予定です。早期のエラーを除けば、ここでの主な破壊的変更はfetch()のデフォルトキャッシュを元に戻すことです。とはいえ、この初期実験段階では新規プロジェクトでのみ試すことをお勧めします。うまくいけば、マイナーリリースでオプトイン版をリリースし、将来のメジャーリリースでデフォルトにしたいと考えています。

試すには、Next.jsのcanaryバージョンを使用する必要があります:

npx create-next-app@canary

また、next.config.tsで実験的なdynamicIOフラグを有効にする必要があります:

next.config.ts
import type { NextConfig } from 'next';
 
const nextConfig: NextConfig = {
  experimental: {
    dynamicIO: true,
  }
};
 
export default nextConfig;

use cachecacheLifecacheTagについての詳細はドキュメントをご覧ください。