mcpField
N
nextjs-cache-architecture

nextjs-cache-architecture

github.com/mohamed-hossam1开发105.0k 次安装22 周前更新
代码仓库

关于 nextjs-cache-architecture

为 Next.js 16+ App Router 项目规划缓存架构的技能,涵盖标签注册、重新验证、数据获取和渲染边界,并提供模板与排查指南。

主要功能

  1. 01集中管理缓存标签
  2. 02统一重新验证工具
  3. 03缓存数据获取函数
  4. 04Suspense 边界模式
  5. 05模板与故障排查指南

适用场景

  1. 01为新项目设计缓存架构
  2. 02为变更操作添加缓存失效逻辑
  3. 03处理个性化或筛选页面

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mohamed-hossam1/nextjs-skills --skill nextjs-cache-architecture

安装后在 Claude Code 中输入/nextjs-cache-architecture确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

overlay.yaml
assets/
revalidate.ts
SuspenseOnSearchParams.tsx
tags.ts
evals/
evals.json
references/
core-concepts.md
debugging-and-checklist.md
migration-from-unstable-cache.md
personalized-content.md
scripts/
audit.mjs

详细介绍

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.ts → lib/cache/tags.ts
  • assets/revalidate.ts → lib/cache/revalidate.ts
  • assets/SuspenseOnSearchParams.tsx → components/SuspenseOnSearchParams.tsx

一句话概括架构

正确的缓存实现有三个关键组成部分。从第一天起就构建好这三部分——事后添加远比一开始就做好困难。

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

这三部分就位后,其余工作只需始终如一地应用它们。

步骤 1 — 启用 Cache Components

// 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 (skeleton — full template in assets/tags.ts)

export const CACHE_TAGS = {
  // Collection tags — one per logical data group, always present.
  [collection]: "[collection]",

  // Entity tag factories — only when a mutation targets a single entry.
  [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);
}

// Bulk — any entry in the collection changed.
export async function revalidate[Collection]Cache() {
  updateTags([CACHE_TAGS.[collection]]);
}

// Surgical — one specific entry changed.
// Only write this if `CACHE_TAGS.[entity]` factory exists in the registry.
export async function revalidate[Entity]Cache(id: string | number) {
  updateTags([
    CACHE_TAGS.[collection], // always invalidate the parent collection too
    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!;

// Good: collection fetch.
export async function get[Collection]() {
  "use cache";
  cacheLife("hours");
  cacheTag(CACHE_TAGS.[collection]);

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

// Good: entity fetch.
export async function get[Entity](id: string) {
  "use cache";
  cacheLife("hours");
  cacheTag(CACHE_TAGS.[collection]);
  // Add CACHE_TAGS.[entity](id) only if a mutation calls updateTag on this entry.

  const res = await fetch(`${BASE_URL}/[endpoint]/${id}`);
  return res.json();
}
// Bad: fetching in a page component bypasses caching and invalidation.
export default async function Page() {
  const res = await fetch("/api/items");
  const data = await res.json();
  return <View data={data} />;
}

步骤 5 — 构建渲染边界

每个页面都遵循以下结构:

Page component (sync, orchestration only — no data fetching)
  ├── Static shell (layout, nav — no data)
  ├── <Suspense> → cached shared content
  └── <Suspense> → dynamic personalized content
标准页面
// 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]);
  // Add CACHE_TAGS.[entity](id) only if a mutation needs surgical invalidation.

  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 is an argument → auto-keyed per unique param combination.

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

当只有 searchParams 发生变化时,标准 <Suspense> 在客户端导航中不会再次触发回退内容。对于带有搜索或筛选参数的每个页面,请使用 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); // requires the surgical utility to be exported
}
updateTag 与 revalidateTag

两个 API,满足两种不同需求:

API效果调用位置
updateTag(tag)立即生效 — 同一请求会读取到新数据服务端操作,通过 revalidate.ts 调用
revalidateTag(tag, "max")后台过期时重新验证 — 下一个请求会读取到新数据路由处理程序、webhook

revalidateTag 始终需要第二个参数("max" 表示过期时重新验证,{ 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 中。

评论

支持 Markdown