Next.js 16 cacheComponents:从应用路由缓存迁移91,000个页面
Next.js 16 cacheComponents:将 91,000 个页面从 App Router 缓存迁移
我们已经在 Next.js 14 的 App Router 上运行一个大型电商目录大约 18 个月,然后 Next.js 16 发布了。91,247 个页面。产品列表、分类树、编辑内容、跨 14 个市场的本地化变体。旧的缓存模型——Server Components 默认被缓存——已经成为了过时数据 bug 和 revalidateTag 意大利面式代码的地雷。当 Next.js 团队宣布 cacheComponents 和 Next.js 15 中默认不缓存的转变(在 v16 中继续推进和改进)时,我们知道是时候了。这是那次迁移的故事:什么有效、什么无效,以及另一端的性能数字。
目录
- 我们实际遇到的缓存问题
- Next.js 15 和 16 中的变化
- 理解 cacheComponents
- 我们的 91,000 页面迁移策略
- 实现:逐步进行
- 性能结果和基准
- 陷阱和注意事项
- 何时应该和不应该使用 cacheComponents
- FAQ

我们实际遇到的缓存问题
让我描绘一下这个场景。在 Next.js 14 的 App Router 中,Server Components 中的 fetch 请求默认被缓存。数据缓存跨部署持久化。完整路由缓存在构建时存储呈现的 HTML 和 RSC 负载。客户端的路由缓存保留预取的段……好吧,比你期望的要长。
对于有 91,000 个页面的网站,这种默认缓存一切的方法产生了两类问题:
到处都是过时的数据。 产品价格在我们的无头 CMS(在我们的案例中是 Sanity)中更新,但缓存的 fetch 结果仍然存在。我们在 47 个不同的服务器操作中分散了 revalidateTag 调用。漏掉一个标签?客户看到的是昨天的价格。我们实际上有一个名为 #cache-crimes 的 Slack 频道,内容团队在其中报告过时的页面。
构建时间太长了。 91,000 个页面的完整静态生成花费了 3 个多小时。我们已经转向了 ISR,大多数页面使用 revalidate: 3600,但 ISR、数据缓存和按需重新验证之间的交互确实很难推理。团队中的新开发者会花他们前两周的时间来理解缓存层。
隐式缓存的心智模型代价
这是我认为人们低估的:隐式缓存的认知成本。当缓存是默认的并且你选择不用时,每个新组件都需要你问"这应该被缓存吗?"然后记住在答案是否定的情况下添加正确的指令。当不缓存是默认的并且你选择加入时,你只在主动想要缓存时思考缓存。那是一个从根本上不同的——也是更好的——心智模型。
Next.js 15 和 16 中的变化
Next.js 15 是一个大的哲学转变。团队翻转了默认值:
| 行为 | Next.js 14 | Next.js 15 | Next.js 16 |
|---|---|---|---|
Server Components 中的 fetch() |
默认缓存 | 默认不缓存 | 默认不缓存 |
| 路由处理器 (GET) | 默认缓存 | 默认不缓存 | 默认不缓存 |
| 客户端路由缓存 | 30 秒(动态)/ 5 分钟(静态) | 页面段为 0 秒 | 默认 0 秒,可配置 |
| 完整路由缓存 | 为静态路由启用 | 相同 | 相同,带有 cacheLife 优化 |
| 组件级缓存 | unstable_cache |
use cache 指令(实验性) |
cacheComponents API(稳定) |
Next.js 15 引入了 use cache 指令作为标志后面的实验性功能。Next.js 16,在 2025 年初发布,将其稳定为 cacheComponents 配置选项和相关的 "use cache" 指令,以及用于定义自定义缓存配置文件的 cacheLife 和用于目标化失效的 cacheTag。
关键见解:缓存从隐式框架行为转变为组件级别的显式开发者选择。 这对大型网站是一件大事。
理解 cacheComponents
next.config.js 中的 cacheComponents 功能通过 "use cache" 指令启用组件级缓存。这是基本设置:
// next.config.js (Next.js 16)
const nextConfig = {
experimental: {
cacheComponents: true,
},
};
module.exports = nextConfig;
启用后,你可以在任何异步 Server Component、服务器操作甚至布局文件的顶部添加 "use cache":
// app/products/[slug]/page.tsx
"use cache";
import { cacheLife, cacheTag } from 'next/cache';
export default async function ProductPage({ params }: { params: { slug: string } }) {
cacheLife('products'); // 自定义缓存配置文件
cacheTag(`product-${params.slug}`);
const product = await fetchProduct(params.slug);
return (
<div>
<h1>{product.name}</h1>
<ProductDetails product={product} />
<DynamicPricing productId={product.id} /> {/* 这个组件不被缓存 */}
</div>
);
}
cacheLife 配置文件
对于大型网站,这就是有趣的地方。你在 next.config.js 中定义命名的缓存配置文件:
const nextConfig = {
experimental: {
cacheComponents: true,
cacheLife: {
products: {
stale: 300, // 5 分钟内提供过时内容
revalidate: 3600, // 1 小时后重新验证
expire: 86400, // 24 小时后硬过期
},
editorial: {
stale: 3600,
revalidate: 86400,
expire: 604800, // 7 天
},
navigation: {
stale: 86400,
revalidate: 604800,
expire: 2592000, // 30 天
},
},
},
};
三层模型(stale、revalidate、expire)很好地映射到 stale-while-revalidate 语义。在 stale 窗口期间,缓存内容立即被提供。在 stale 之后但在 expire 之前,后台重新验证启动。在 expire 之后,缓存条目消失。
用于失效的 cacheTag
cacheTag 函数用更可组合的东西替换了旧的 revalidateTag 模式:
import { revalidateTag } from 'next/cache';
// 在 webhook 处理器或服务器操作中:
export async function handleProductUpdate(productSlug: string) {
revalidateTag(`product-${productSlug}`);
revalidateTag('product-listing'); // 也失效列表页面
}
这部分与 Next.js 15 相比没有太大变化,但它与 cacheComponents 配合使用效果更好,因为你是标记特定的缓存组件,而不是尝试失效不透明的框架级缓存。

我们的 91,000 页面迁移策略
我们没有一次完成。跨 14 个地区的 91,000 个页面,一次性迁移会很鲁莽。以下是我们如何分解的:
第 1 阶段:升级到 Next.js 16,无缓存更改(第 1-2 周)
我们从 Next.js 14.2 升级到 16.0,而没有启用 cacheComponents。这本身就改变了行为,因为 fetch 请求不再默认被缓存。我们预期 TTFB 会出现回归,确实出现了:
- 产品页面的平均 TTFB 从 180ms 增加到 340ms
- 源服务器负载增加约 60%(我们的 Sanity CDN 表现良好,但我们的自定义 API 端点没有)
- ISR 重新验证实际上更快,因为需要管理的缓存状态较少
这证实了我们怀疑的:我们一直在大量使用隐式缓存,许多页面确实需要缓存——只是显式、有意的缓存。
第 2 阶段:审计和分类页面(第 3 周)
我们对应用中的每条路由进行了分类:
| 页面类型 | 数量 | 缓存策略 | cacheLife 配置文件 |
|---|---|---|---|
| 产品详情页 | 42,000 | 使用产品标签缓存 | products(5 分钟陈旧 / 1 小时重新验证) |
| 分类列表页 | 3,200 | 使用分类标签缓存 | products(5 分钟陈旧 / 1 小时重新验证) |
| 编辑/博客页面 | 8,400 | 积极缓存 | editorial(1 小时陈旧 / 24 小时重新验证) |
| 本地化变体 | 31,647 | 与基础页面相同 | 从基础继承 |
| 帐户/动态页面 | 6,000 | 无缓存 | N/A |
第 3 阶段:启用 cacheComponents,添加指令(第 4-6 周)
我们启用了标志并开始添加 "use cache" 指令。关键决定:我们为大多数路由在页面级别进行缓存,但对于具有混合静态/动态内容的页面在组件级别进行缓存。
对于产品页面,产品信息和图像被缓存,但定价组件和库存状态不被缓存:
// components/ProductInfo.tsx
"use cache";
import { cacheLife, cacheTag } from 'next/cache';
export async function ProductInfo({ slug }: { slug: string }) {
cacheLife('products');
cacheTag(`product-${slug}`, 'product-info');
const product = await getProduct(slug);
return (
<section>
<h1>{product.name}</h1>
<p>{product.description}</p>
<ProductImages images={product.images} />
</section>
);
}
// components/DynamicPricing.tsx
// 无 "use cache" 指令——始终新鲜
export async function DynamicPricing({ productId }: { productId: string }) {
const pricing = await getPricing(productId); // 每次请求都命中定价 API
return (
<div className="pricing">
<span className="price">${pricing.current}</span>
{pricing.onSale && <span className="was-price">${pricing.original}</span>}
</div>
);
}
第 4 阶段:Webhook 集成(第 7 周)
我们重新调整了 Sanity webhook 以使用正确的标签调用 revalidateTag。这实际上比我们的旧设置更简单,因为标签现在在代码中是显式的,而不是分散在 fetch 选项中。
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.json();
const secret = request.headers.get('x-webhook-secret');
if (secret !== process.env.REVALIDATION_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
switch (body._type) {
case 'product':
revalidateTag(`product-${body.slug.current}`);
revalidateTag('product-listing');
break;
case 'category':
revalidateTag(`category-${body.slug.current}`);
revalidateTag('navigation');
break;
case 'article':
revalidateTag(`article-${body.slug.current}`);
break;
}
return new Response('OK', { status: 200 });
}
实现:逐步进行
如果你在进行类似的迁移,这是我们推荐的实用手册(以及我们现在在 Social Animal 的 Next.js 开发项目 中使用的):
步骤 1:启用标志
// next.config.js
module.exports = {
experimental: {
cacheComponents: true,
cacheLife: {
// 从合理的默认值开始
default: {
stale: 60,
revalidate: 900,
expire: 86400,
},
},
},
};
步骤 2:找到你的热路径
使用你的分析来识别获得最多流量的页面以及 TTFB 最重要的地方。对我们来说,这是分类页面(高流量、相对稳定的内容)和产品页面(高流量、中等动态内容)。
步骤 3:自上而下添加 `"use cache"`
从布局开始。如果你的根布局获取导航数据,首先缓存它——这是最高影响、最低风险的改变:
// app/layout.tsx
// 注意:布局上的 "use cache" 缓存布局外壳
// 子页面仍然独立呈现
import { Navigation } from '@/components/Navigation';
export default async function RootLayout({ children }) {
return (
<html>
<body>
<Navigation /> {/* 这个组件有它自己的 "use cache" */}
{children}
</body>
</html>
);
}
步骤 4:设置监控
我们使用 Vercel 的内置分析加上自定义日志来跟踪缓存命中率。在启用 cacheComponents 的第一周,我们的缓存命中率只有 34%。调整 stale 持续时间后,它上升到 78%。
性能结果和基准
这些是在 Vercel Pro 计划上 30 天期间测得的完整迁移后的真实数字:
| 指标 | 之前(Next.js 14) | 第 1 阶段之后(v16,无缓存) | 完整迁移之后 |
|---|---|---|---|
| 平均 TTFB(产品页面) | 180ms | 340ms | 95ms |
| 平均 TTFB(分类页面) | 220ms | 410ms | 72ms |
| 平均 TTFB(编辑页面) | 150ms | 280ms | 45ms |
| P99 TTFB(所有页面) | 1,200ms | 2,100ms | 380ms |
| 构建时间(完整) | 3 小时 12 分钟 | 2 小时 48 分钟 | 48 分钟 |
| Vercel 函数调用/天 | 2.4M | 3.8M | 1.1M |
| 每月 Vercel 账单 | 约 $840 | 约 $1,200 | 约 $520 |
| 缓存命中率 | 未知(隐式) | N/A | 78% |
| 过时内容事件(#cache-crimes) | 8-12/周 | 0 | 1-2/月 |
构建时间改进值得解释。使用 cacheComponents,我们远离了在构建时生成全部 91,000 个页面。相反,我们仅静态生成前 5,000 个页面(按流量),让其余页面按需生成并进行缓存。cacheComponents 指令意味着那些按需页面在首次访问后被缓存,并由 cacheLife 控制陈旧性。
Vercel 账单下降很显著。更少的函数调用(由于显式组件缓存)加上更短的构建时间意味着真实的成本节省。那大约 $320/月的降低会自己支付。
陷阱和注意事项
序列化边界
"use cache" 指令创建一个序列化边界。作为 props 传入缓存组件的所有内容都必须是可序列化的。我们有几个组件接收回调函数或 React 元素作为 props——那些立即破坏了。修复是重构为使用组合模式:
// ❌ 这与 "use cache" 会破坏
"use cache";
export async function ProductCard({ product, onAddToCart }) {
// onAddToCart 是一个函数——不可序列化!
}
// ✅ 这有效
"use cache";
export async function ProductCard({ product }) {
return (
<div>
<h2>{product.name}</h2>
{/* AddToCart 是一个客户端组件,不被缓存 */}
<AddToCartButton productId={product.id} />
</div>
);
}
动态参数和缓存密钥爆炸
有 91,000 个页面,每个都有唯一的 params,缓存密钥空间巨大。我们在第一周撞上了 Vercel 的边缘缓存限制,不得不更具策略性地考虑哪些页面获得长的 expire 值。低流量长尾页面获得更短的缓存持续时间。
`Date.now()` 陷阱
任何使用 "use cache" 的组件,在缓存函数内调用 Date.now() 或 new Date() 将缓存该时间戳。我们在一个显示"最后更新"的显示中发现了这个,它显示了数小时相同的时间。修复:将时间敏感逻辑移到客户端组件或未缓存的服务器组件。
嵌套缓存边界
当你在其他缓存组件内嵌套缓存组件时,内部缓存有它自己的生命周期。这很强大但令人困惑。我们建立了一个团队约定:在页面级别或组件级别缓存,除非有明确的原因,否则不同时进行。
何时应该和不应该使用 cacheComponents
在以下情况使用:
- 你有超过几百个页面,ISR 构建时间很痛苦
- 你的内容有清晰的新鲜度要求,因部分而异
- 你需要对什么被缓存与总是新鲜的东西进行粒度控制
- 你在 Vercel 或支持 Next.js 缓存层的平台上运行
- 你想减少高流量网站上的基础设施成本
不要在以下情况使用:
- 你的网站足够小,完整的 SSG 效果良好
- 每个页面都是完全动态的(到处都是用户特定的内容)
- 你不在支持 Next.js 缓存基础设施的托管平台上
- 你的团队对 Next.js 是新的——首先熟悉基础知识
如果你在评估你的项目是否需要这个级别的缓存控制,或者不同的框架如 Astro 可能更适合你的内容密集网站,这值得在提交迁移之前思考。
对于来自多个无头 CMS 来源的内容的项目,Next.js 16 中的 cacheTag 系统与 无头 CMS 架构 配合得很好——每种内容类型都获得它自己的失效频道。
FAQ
Next.js 16 中的 cacheComponents 是什么?
cacheComponents 是 Next.js 16 中的一个实验性配置选项,为 Server Components 启用 "use cache" 指令。它让你显式地标记哪些组件应该被缓存,并使用 cacheLife 定义自定义缓存配置文件。它是 Next.js 15 中实验性 use cache 指令的稳定演变。
cacheComponents 与 ISR(增量静态再生成)有什么不同?
ISR 缓存整个页面,并根据时间表重新验证它们。cacheComponents 让你缓存页面内的各个组件,每个具有不同的缓存生命周期。一个页面可以将标头缓存 24 小时,产品信息缓存 1 小时,定价永不缓存。ISR 不能做到这一点——在页面级别是全有或全无。
我需要在 Vercel 上才能使用 cacheComponents 吗?
不需要,但在 Vercel 上的体验最好,因为缓存基础设施是紧密集成的。自托管 Next.js 部署可以使用带有文件系统缓存适配器的 cacheComponents,但你不会获得边缘分发优势。Netlify 和 Cloudflare 等平台正在添加支持,但截至 2025 年中期,Vercel 仍然是最完整的实现。
我如何在 Next.js 16 中失效缓存的组件?
你在缓存组件内使用 cacheTag() 来分配标签,然后从服务器操作、路由处理器或 webhook 端点调用 revalidateTag('tag-name')。这失效所有具有该标签的缓存组件。这是来自 Next.js 15 的相同 API,但现在更有用,因为你是标记特定的缓存组件,而不是尝试失效不透明的框架级缓存。
cacheComponents 会降低我的 Vercel 账单吗? 它可以显著降低成本。在我们的案例中,函数调用下降了 54%,因为缓存的组件响应是从缓存层提供的,而不是调用无服务器函数。构建时间的减少也节省了构建分钟。你的里程会根据流量模式和缓存命中率而有所不同——查看 Vercel 的定价计算器,使用你当前的用法。
如果我向接收不可序列化的 props 的组件添加 "use cache" 会发生什么?
你会得到一个构建错误。"use cache" 指令创建一个序列化边界,所以所有 props 都必须是可序列化的(字符串、数字、普通对象、数组)。函数、React 元素、类实例和其他不可序列化的值将导致构建失败。重新构造你的组件以仅接受数据 props,并在子客户端组件中处理交互。
我能否使用来自其他框架的 React Server Components 中的 cacheComponents?
不能。cacheComponents 是一个 Next.js 特定的功能,基于 React 的 Server Components。虽然 "use cache" 指令语法最终可能成为 React 标准,但 cacheLife 配置文件和 cacheTag 系统是 Next.js API。如果你使用像 Remix 或自定义 RSC 设置这样的框架,你需要不同的缓存策略。
将一个大型 Next.js 网站迁移到 cacheComponents 需要多长时间? 对于我们拥有 4 名开发者的 91,000 页面网站,包括测试和监控的完整迁移花费了 7 周。一个更小的网站(少于 10,000 页面)具有更简单的数据模型可能可以在 1-2 周内完成。实际的代码更改很简单——时间用于审计你的缓存需求、测试失效流程和在部署后监控缓存命中率。如果你宁愿不孤身一人,联系我们——我们现在已经做过几次了。