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 16 cacheComponents:将 91,000 个页面从 App Router 缓存迁移

我们实际遇到的缓存问题

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

三层模型(stalerevalidateexpire)很好地映射到 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 配合使用效果更好,因为你是标记特定的缓存组件,而不是尝试失效不透明的框架级缓存。

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 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 周内完成。实际的代码更改很简单——时间用于审计你的缓存需求、测试失效流程和在部署后监控缓存命中率。如果你宁愿不孤身一人,联系我们——我们现在已经做过几次了。