コンテンツへスキップ
ブログに戻る

2024年10月24日木曜日

キャッシングとの旅

投稿者

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

しかし、その過程で、提供されたキャッシングのデフォルト設定と制御により、開発者体験が損なわれました。fetch() のデフォルトはパフォーマンスを優先してデフォルトでキャッシュするように変更されましたが、迅速なプロトタイプ作成や動的なアプリでは問題が発生しました。fetch() を使用しないローカルデータベースアクセスに対する十分な制御を提供していませんでした。unstable_cache() はありましたが、使い勝手が良くありませんでした。これが、エスケープハッチとして export const dynamic, runtime, fetchCache, dynamicParams, revalidate = ... のようなセグメントレベルの設定が必要となる原因となりました。

もちろん、後方互換性のために引き続きサポートします。しかし、少しの間、それらすべてを忘れてください。私たちは、もっとシンプルなものに対するアイデアを持っています。

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

あなたの冒険を選ぼう

コンポーネントにデータを追加すると、エラーが発生することにまず気づくでしょう。

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

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

動的

まだ反復作業を行っている場合や、非常に動的なダッシュボードを構築している場合は、コンポーネントを <Suspense> の境界でラップすることができます。<Suspense> は動的なデータフェッチとストリーミングを有効にします。

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

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

これにより、アプリのシェルは常に高速に保たれます。ページ内にさらにデータを追加し続けることができ、それらはすべてデフォルトで動的になることを知っておいてください。デフォルトでは何もキャッシュされません。隠れたキャッシュはもうありません。

静的

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

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

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

部分的

組み合わせて使用することもできます。例えば、ルートレイアウトに 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(...) // no error
}
 
export default async function Page() {
  return <Suspense fallback="..."><Component /></Suspense>
}

キャッシュされた関数

このようなハイブリッドアプローチを使用する場合、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 を追加すると、エラーなしでキャッシュされます。どのアプローチを選択するかは、ユースケースによります。

キャッシュにタグを付ける

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

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

その後、これまでと同様にServer Actionから 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;
}

キャッシュの有効期間を定義する

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

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

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

  • 時間
  • 最大

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

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

実験的

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

より詳細なアップグレードパスを公開する予定です。初期のエラーを除けば、ここでの主な破壊的変更は 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 の詳細については、ドキュメントをご覧ください。