nextjs-cache-architecture

nextjs-cache-architecture

当用户希望在 Next.js 16+ App Router 项目中设计或实现缓存时使用此技能——包括设置 "use cache" 指令、构建缓存标签注册表、将数据变更与失效工具连接、为部分预渲染构建 Suspense 边界、在缓存边界附近处理个性化内容、选择 cacheLife 配置文件、正确调用 cacheTag / updateTag / revalidateTag、从 unstable_cache 迁移,或调试过期或错误的新鲜数据。即使用户仅描述其领域(例如“我有一个 posts 表”)并询问如何正确缓存时,也会触发。

8Star
0Fork
更新于 2026/5/30
SKILL.md
readonly只读
name
nextjs-cache-architecture
description

当用户希望在 Next.js 16+ App Router 项目中设计或实现缓存时使用此技能——包括设置 "use cache" 指令、构建缓存标签注册表、将数据变更与失效工具连接、为部分预渲染构建 Suspense 边界、在缓存边界附近处理个性化内容、选择 cacheLife 配置文件、正确调用 cacheTag / updateTag / revalidateTag、从 unstable_cache 迁移,或调试过期或错误的新鲜数据。即使用户仅描述其领域(例如“我有一个 posts 表”)并询问如何正确缓存时,也会触发。

Next.js 缓存架构

从第一天起就在 Next.js 16+ App Router 项目中架构缓存——不仅仅是随意放置 "use cache",而是构建标签注册表、重新验证工具、Suspense 边界和变更连接,以便随着代码库的增长缓存保持正确。

如何使用此技能

将以下所有规则和模板应用于用户的实际项目。在编写任何代码之前,将 [Entity][collection] 等占位符替换为代码库中的名称。

$ARGUMENTS

下一步参考

大多数实现只需要此文件。当任务需要时,加载参考。

如果用户... 阅读
询问缓存键如何派生、cacheLife 配置文件含义或遇到 "use cache" 限制 references/core-concepts.md
缓存任何依赖于登录用户的内容 references/personalized-content.md
报告过期数据,或进行最终审查 references/debugging-and-checklist.md
将现有代码库从 unstable_cache 迁移 references/migration-from-unstable-cache.md

assets/ 中的即用模板(将占位符重命名为用户代码库中的名称):

  • assets/tags.tslib/cache/tags.ts
  • assets/revalidate.tslib/cache/revalidate.ts
  • assets/SuspenseOnSearchParams.tsxcomponents/SuspenseOnSearchParams.tsx

架构概述

一个正确的缓存实现包含三个关键部分。从第一天起构建所有三个——稍后添加它们比一开始就正确构建要困难得多。

  1. 标签注册表 (lib/cache/tags.ts) —— 所有标签字符串都放在这里。其他地方没有原始字符串。
  2. 重新验证工具 (lib/cache/revalidate.ts) —— 所有 updateTag() 调用都放在这里。变更从此文件导入。
  3. 缓存放在数据上,而不是页面上 —— "use cache" 放在数据获取函数或缓存的子组件上。页面组件编排 Suspense 边界;子组件负责获取。

一旦这三个部分就位,剩下的就是一致地应用它们。

步骤 1 — 启用缓存组件

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

步骤 2 — 构建缓存标签注册表

文件: lib/cache/tags.ts(模板:assets/tags.ts

使用 assets/tags.ts 模板。as const satisfies TagRegistry 形状提供字面量类型,并在编译时拒绝格式错误的条目。

// lib/cache/tags.ts(骨架——完整模板在 assets/tags.ts 中)

export const CACHE_TAGS = {
  // 集合标签——每个逻辑数据组一个,始终存在。
  [collection]: "[collection]",

  // 实体标签工厂——仅当变更针对单个条目时。
  [entity]: (id: string | number) => `[entity]:${id}`,
} as const;

步骤 3 — 构建重新验证工具

文件: lib/cache/revalidate.ts(模板:assets/revalidate.ts

所有 updateTag() 调用都放在这里。变更导入这些函数——它们从不直接调用 updateTag()

// lib/cache/revalidate.ts
"use server";

import { updateTag } from "next/cache";
import { CACHE_TAGS } from "./tags";

function updateTags(tags: string[]) {
  for (const tag of tags) updateTag(tag);
}

// 批量——集合中的任何条目发生更改。
export async function revalidate[Collection]Cache() {
  updateTags([CACHE_TAGS.[collection]]);
}

// 精确——一个特定条目发生更改。
// 仅当注册表中存在 `CACHE_TAGS.[entity]` 工厂时才编写此函数。
export async function revalidate[Entity]Cache(id: string | number) {
  updateTags([
    CACHE_TAGS.[collection], // 始终同时使父集合失效
    CACHE_TAGS.[entity](id),
  ]);
}

步骤 4 — 实现数据获取

"use cache" 放在数据获取函数中。永远不要在页面组件内获取数据——页面组件负责编排,不负责获取。

// lib/data/[domain].ts
import { cacheLife, cacheTag } from "next/cache";
import { CACHE_TAGS } from "@/lib/cache/tags";

const BASE_URL = process.env.API_BASE_URL!;

// 好:集合获取。
export async function get[Collection]() {
  "use cache";
  cacheLife("hours");
  cacheTag(CACHE_TAGS.[collection]);

  const res = await fetch(`${BASE_URL}/[endpoint]`);
  return res.json();
}

// 好:实体获取。
export async function get[Entity](id: string) {
  "use cache";
  cacheLife("hours");
  cacheTag(CACHE_TAGS.[collection]);
  // 仅当某个变更在此条目上调用 updateTag 时才添加 CACHE_TAGS.[entity](id)。

  const res = await fetch(`${BASE_URL}/[endpoint]/${id}`);
  return res.json();
}
// 坏:在页面组件中获取数据会绕过缓存和失效。
export default async function Page() {
  const res = await fetch("/api/items");
  const data = await res.json();
  return <View data={data} />;
}

步骤 5 — 构建渲染边界

每个页面遵循以下结构:

页面组件(同步,仅编排——不获取数据)
  ├── 静态外壳(布局、导航——无数据)
  ├── <Suspense> → 缓存的共享内容
  └── <Suspense> → 动态个性化内容

标准页面

// app/[route]/page.tsx
import { Suspense } from "react";
import { cacheLife, cacheTag } from "next/cache";
import { CACHE_TAGS } from "@/lib/cache/tags";
import { get[Collection] } from "@/lib/data/[domain]";

export default function AnyPage() {
  return (
    <>
      <StaticShell />

      <Suspense fallback={<SharedSkeleton />}>
        <SharedContent />
      </Suspense>

      <Suspense fallback={<PersonalizedSkeleton />}>
        <PersonalizedSection />
      </Suspense>
    </>
  );
}

async function SharedContent() {
  "use cache";
  cacheLife("hours");
  cacheTag(CACHE_TAGS.[collection]);

  const data = await get[Collection]();
  return <[Collection]List data={data} />;
}

动态路由页面

// app/[domain]/[id]/page.tsx
import { Suspense } from "react";
import { cacheLife, cacheTag } from "next/cache";
import { CACHE_TAGS } from "@/lib/cache/tags";
import { get[Entity] } from "@/lib/data/[domain]";

export default function EntityPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  return (
    <Suspense fallback={<EntitySkeleton />}>
      <EntityDetail params={params} />
    </Suspense>
  );
}

async function EntityDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <CachedEntityView id={id} />;
}

async function CachedEntityView({ id }: { id: string }) {
  "use cache";
  cacheLife("hours");
  cacheTag(CACHE_TAGS.[collection]);
  // 仅当某个变更需要精确失效时才添加 CACHE_TAGS.[entity](id)。

  const item = await get[Entity](id);
  return <[Entity]View item={item} />;
}

筛选/搜索参数页面

// app/[route]/page.tsx
import { cacheLife, cacheTag } from "next/cache";
import { CACHE_TAGS } from "@/lib/cache/tags";
import { get[Collection]ByFilter } from "@/lib/data/[domain]";
import SuspenseOnSearchParams from "@/components/SuspenseOnSearchParams";

export default function FilteredPage({
  searchParams,
}: {
  searchParams: Promise<Record<string, string>>;
}) {
  return (
    <SuspenseOnSearchParams fallback={<FilteredListSkeleton />}>
      <FilteredList searchParams={searchParams} />
    </SuspenseOnSearchParams>
  );
}

async function FilteredList({
  searchParams,
}: {
  searchParams: Promise<Record<string, string>>;
}) {
  "use cache";
  cacheLife("minutes");
  cacheTag(CACHE_TAGS.[collection]);
  // searchParams 是参数 → 每个唯一参数组合自动生成键。

  const { q = "", page = "1" } = await searchParams;
  return await get[Collection]ByFilter(q, page);
}

标准的 <Suspense> 在客户端导航时,如果只有 searchParams 发生变化,不会重新触发其 fallback。在每个带有搜索或筛选参数的页面上使用 SuspenseOnSearchParams(模板:assets/SuspenseOnSearchParams.tsx)。

步骤 6 — 处理个性化内容

在缓存边界外部读取 cookies() / headers() / auth(),并将值作为 prop 传递。该参数成为自动生成的缓存键的一部分,因此每个用户都有自己的条目。在 "use cache" 函数内部调用这些 API 会抛出错误或产生错误行为。

有关完整的“外部读取/内部缓存”模式以及罕见的 "use cache: private" 异常,请参阅 references/personalized-content.md

步骤 7 — 将变更与失效连接

变更调用重新验证工具,并且从不自己调用 updateTag()。这使缓存层保持机械性,并可从单个文件进行审计,并且允许您在一个地方添加可观察性(日志记录、跟踪)。

// app/actions/[domain].ts
"use server";

import {
  revalidate[Collection]Cache,
  revalidate[Entity]Cache,
} from "@/lib/cache/revalidate";

export async function create[Entity](payload: unknown) {
  await db.[entity].create(payload);
  await revalidate[Collection]Cache();
}

export async function update[Entity](id: string | number, payload: unknown) {
  await db.[entity].update(id, payload);
  await revalidate[Entity]Cache(id); // 需要导出精确工具
}

updateTagrevalidateTag

两个 API 用于两种不同的需求:

API 效果 调用位置
updateTag(tag) 立即——同一请求看到新鲜数据 服务器操作,通过 revalidate.ts
revalidateTag(tag, "max") 后台 stale-while-revalidate——下一个请求看到新鲜数据 路由处理程序、webhooks

revalidateTag 始终接受第二个参数("max" 用于 stale-while-revalidate,{ expire: 0 } 用于立即硬过期)。单参数形式已弃用,在某些配置中静默地不执行任何操作。

常见错误

当缓存行为异常时,按顺序检查这些。前六个几乎涵盖所有情况;仅在其余检查通过后运行 next build。完整的调试步骤和签收清单在 references/debugging-and-checklist.md 中。

症状或气味 修复
函数每次请求都未缓存运行 "use cache"await 之后——将其移到第一个语句。
缓存函数抛出错误或为每个用户返回错误数据 cookies() / headers() / auth() 移到外部;将值作为参数传递。
updateTag 不起作用 标签字符串拼写错误,或没有 cacheTag 注册过匹配的标签。
变更完成但列表仍然读取过期数据 重新验证工具在写入之前调用,或根本没有调用。
整个页面重新渲染,即使只有一个部分更改了 动态子组件位于缓存的父组件内部——使用 <Suspense> 拆分。
筛选 UI 在导航时不显示加载状态 普通的 <Suspense>——切换到 SuspenseOnSearchParams
页面标记为动态,但您期望静态 运行 next build;在路由的源树中跟踪泄漏的动态 API。
页面组件直接获取数据 将获取移到缓存的子组件中;页面应编排,而不是获取。

有关完整的调试步骤和签收清单,请参阅 references/debugging-and-checklist.md。要对照用户项目验证已完成实现的静态部分,请运行 scripts/audit.mjs <project-root>——用法和检查内容在 README.md 中有文档说明。