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

我们实际遇到的缓存问题
让我描绘一下这个情景。在Next.js 14的App Router中,Server Components中的fetch请求默认被缓存。数据缓存在部署之间持久化。完整路由缓存在构建时存储了渲染的HTML和RSC有效负载。而客户端的Router缓存保存预获取的片段,时间长度超出你的预期。
对于一个拥有91,000个页面的网站,这种默认缓存一切的方法产生了两类问题:
到处都是陈旧数据。 产品价格在我们的无头CMS(在我们的情况下是Sanity)中更新,但缓存的fetch结果仍然存在。我们有分散在47个不同服务器动作中的revalidateTag调用。漏掉一个标签?客户看到的是昨天的价格。我们甚至有一个叫#cache-crimes的Slack频道,内容团队在那里报告陈旧的页面。
构建时间来自地狱。 91,000个页面的完整静态生成耗时超过3小时。我们已经转向使用revalidate: 3600的ISR来处理大多数页面,但ISR、数据缓存和按需重新验证之间的交互确实很难理解。团队中的新开发人员会花费他们前两周的时间就来理解缓存层。
心智模型成本
以下是我认为人们低估的内容:隐式缓存的认知成本。当缓存是默认的且你选择退出时,每个新组件都需要你问"这应该被缓存吗?",然后如果答案是否定的,记住添加正确的指令。当无缓存是默认的且你选择加入时,你只在主动想要缓存时才思考缓存。这是一个根本上不同的——而且更好的——心智模型。
Next.js 15和16中的变化
Next.js 15是个大的哲学转变。该团队翻转了默认值:
| 行为 | Next.js 14 | Next.js 15 | Next.js 16 |
|---|---|---|---|
Server Components中的fetch() |
默认缓存 | 默认不缓存 | 默认不缓存 |
| Route Handlers (GET) | 默认缓存 | 默认不缓存 | 默认不缓存 |
| 客户端Router缓存 | 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'); // custom cache profile
cacheTag(`product-${params.slug}`);
const product = await fetchProduct(params.slug);
return (
<div>
<h1>{product.name}</h1>
<ProductDetails product={product} />
<DynamicPricing productId={product.id} /> {/* 这个组件NOT被缓存 */}
</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窗口期间,缓存的内容立即被提供。在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 webhooks来用正确的标签调用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"缓存布局shell
// 子页面仍然独立呈现
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%。
性能结果和基准
以下是迁移后30天内测量的真实数字,在Vercel的Pro计划上:
| 指标 | 之前(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页面,每个有独特参数,缓存密钥空间是巨大的。我们在第一周打击了Vercel的边缘缓存限制,不得不对哪些页面获得长expire值更具战略性。低流量的长尾页面获得更短的缓存持续时间。
`Date.now()`陷阱
任何使用"use cache"的组件如果在缓存函数内部调用Date.now()或new Date()将缓存该时间戳。我们在显示"最后更新"的时间发现了这个,该时间显示了几小时相同的时间。修复:将时间敏感逻辑移到客户端组件或无缓存服务器组件。
嵌套缓存边界
当你在其他缓存组件内部嵌套缓存组件时,内部缓存有自己的生命周期。这很强大但令人困惑。我们建立了一个团队约定:在页面级别或组件级别缓存,除非有明确的原因,否则不能两者都缓存。
何时应该和不应该使用cacheComponents
何时使用:
- 你有数百个以上的页面,ISR构建时间很痛苦
- 你的内容有清晰的新鲜度要求,按部分变化
- 你需要对什么被缓存vs.总是新鲜的进行细粒度控制
- 你在Vercel或支持Next.js缓存层的平台上运行
- 你想减少高流量网站的基础设施成本
何时不使用:
- 你的网站足够小,完整SSG效果很好
- 每个页面都是完全动态的(用户特定的内容无处不在)
- 你不在支持Next.js缓存基础设施的托管平台上
- 你的团队对Next.js新手——先学会基础知识
如果你在评估你的项目是否需要这个级别的缓存控制,或者Astro等不同框架可能对你的内容重型网站更合适,这在承诺迁移之前值得思考。
对于来自多个无头CMS源的项目,Next.js 16中的cacheTag系统与无头CMS架构配合精妙——每个内容类型都有自己的失效通道。
常见问题
Next.js 16中的cacheComponents是什么?
Next.js 16中的cacheComponents是一个实验配置选项,启用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等平台正在增加支持,但截至2026年中期,Vercel仍是最完整的实现。
我如何在Next.js 16中失效缓存的组件?
你在缓存组件内使用cacheTag()来分配标签,然后从服务器动作、路由处理程序或webhook端点调用revalidateTag('tag-name')。这失效所有带有该标签的缓存组件。这与Next.js 15的相同API,但现在更有用,因为你标记的是显式缓存组件,而不是隐式框架缓存。
cacheComponents会减少我的Vercel账单吗? 它可以显著降低成本。在我们的情况下,函数调用下降了54%,因为缓存的组件响应从缓存层而不是调用无服务器函数而提供。构建时间缩短也节省了构建分钟。你的里程数会根据流量模式和缓存命中率而异——使用你的当前用量检查Vercel的定价计算器。
如果我添加"use cache"到接收不可序列化props的组件会发生什么?
你会得到一个构建错误。"use cache"指令创建了一个序列化边界,所以所有props必须是可序列化的(字符串、数字、普通对象、数组)。函数、React元素、类实例和其他不可序列化的值将导致构建失败。重组你的组件以仅接受数据props,并在子客户端组件中处理互动性。
我可以与其他框架的React Server Components一起使用cacheComponents吗?
不。cacheComponents是建立在React Server Components之上的Next.js特定特性。虽然"use cache"指令语法最终可能成为React标准,但cacheLife配置文件和cacheTag系统是Next.js API。如果你使用Remix或自定义RSC设置等框架,你需要不同的缓存策略。
迁移一个大型Next.js网站到cacheComponents需要多长时间? 对于我们的91,000页面网站,有4个开发人员的团队,完整迁移耗时7周,包括测试和监控。一个较小的网站(少于10,000页)且有更简单的数据模型可能可以在1-2周内做到。实际的代码变化很直接——时间用在审计你的缓存需求、测试失效流和部署后监控缓存命中率上。如果你不想单独进行,联系我们——我们现在已经做过几次了。