我们在Next.js 14的App Router上运行一个大型电商目录已经有约18个月,当Next.js 16发布时。91,247个页面。产品列表、分类树、编辑内容、跨14个市场的本地化变体。旧的缓存模型——Server Components默认被缓存——已经成为陈旧数据错误和revalidateTag混乱的地雷阵。当Next.js团队宣布cacheComponents并在Next.js 15中转向默认不缓存(在v16中继续改进)时,我们知道是时候了。这是那次迁移的故事:什么有效,什么无效,以及另一端的性能数字。

目录

Next.js 16 cacheComponents: 迁移91,000页面的App Router缓存

我们实际遇到的缓存问题

让我描绘一下这个情景。在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天
      },
    },
  },
};

三层模型(stalerevalidateexpire)很好地映射到过期-在重新验证时提供语义。在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配合得更好,因为你标记的是特定缓存组件,而不是试图失效不透明的框架级缓存。

Next.js 16 cacheComponents: 迁移91,000页面的App Router缓存 - 架构

我们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周内做到。实际的代码变化很直接——时间用在审计你的缓存需求、测试失效流和部署后监控缓存命中率上。如果你不想单独进行,联系我们——我们现在已经做过几次了。