【翻译】Next.js 中的 Cache Components

4 阅读14分钟

Next.js 中的 Cache Components

原文链接:certificates.dev/blog/cache-…

原文作者:Aurora Scharff

文章头图

Cache Components 是 Next.js 16 中支撑 Instant Navigations(即时导航)的缓存模型。了解 use cachecacheLifecacheTag,以及为何数据默认是动态的。

Aurora Scharff

Aurora Scharff

2026 年 7 月 9 日

本文由 Aurora Scharff 撰写,她在 Vercel 的 Next.js 团队工作。

如果你用过 Next.js App Router,大概花过不少时间追查:为什么某条路由在你没要求缓存时却被缓存了,或者为什么你以为该是静态的路由却跑成了动态。缓存默认开启,而且在代码里大多看不见。

Cache Components 在 Next.js 16 中引入,翻转了这一默认。数据是动态的,会在每次请求时运行,除非你用 "use cache" 指令把它缓存起来,于是缓存变成你在代码里主动做出的选择。商品列表对所有人看起来一样,因此可以缓存;而已登录用户的购物车必须每次都新鲜运行。Cache Components 就是你告诉 Next.js 哪一种是哪一种的方式。《Our Journey with Caching》一文讲了旧默认如何演变、以及为何要改变;Next.js 16.3 的 Instant Navigations 发布正是直接建立在这一模型之上。

在这篇文章里,我们会用一个商店页面贯穿示例,从缓存第一个组件讲到按需失效。假设你已经熟悉服务端组件(Server Components)与 Suspense。若需复习,可参阅 React Frameworks and Server-Side Features 以及 React Server Components in Practice。

开启它

你只需在 Next.js 配置里加一个开关,即可启用 Cache Components:

// 文件:next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;


这会改变整个应用的默认行为:所有数据访问在你缓存它之前都是动态的。它也取代了更早的 dynamicIOuseCacheppr 选项,这些都会并入这一项设置。因为它会一次性改变各处默认,在已有项目里打开它更像一次迁移,而不是随手拨一下开关。

下面是我们要处理的页面——商店首页。它会拉取商品目录,以及一组个性化推荐:

// 文件:app/page.tsx
export default async function StorePage() {
  const products = await getProducts();
  return (
    <main>
      <h1>Store</h1>
      <ProductGrid products={products} />
      <Recommendations />
    </main>
  );
}


在旧模型下,这个页面会运行,而它最终是静态还是动态,取决于 getProducts 内部做了什么。打开 Cache Components 后,它不会再默默渲染。在开发环境中,Next.js 会标出 getProducts 在 Suspense 边界之外读取了未缓存数据,因此该路由无法预渲染,并会给你三种解决方式。

代码里没有任何地方说明结果能否复用、还是必须按请求拉取,框架也不会去猜。于是它要求你来决定。

流式传输、缓存,或阻塞

路由所 await 的每一块数据,都会按以下三种方式之一处理:

  • 流式传输(Stream) 它,用 <Suspense>。用户立刻看到回退内容,真实内容在解析完成后流式进入。
  • 缓存(Cache) 它,用 "use cache"。结果会跨请求复用,因此页面渲染时它已经在那里。
  • 阻塞(Block) 在它上面,用 export const instant = false。路由会等服务器完成,然后才渲染任何东西。

流式传输与缓存都能让页面在加载的那一刻就先展示些东西,无论是加载回退还是先前已缓存的内容。阻塞是退出选项。用在你宁可等服务器、也不想先显示外壳的路由上,例如一篇不该闪一下骨架屏的博客文章。

我们的商店页面有两个数据源,答案并不相同。目录对每位访客都一样,所以我们会缓存它。推荐是按用户的,必须在每次请求时运行,所以我们会在回退后面把它们流式传进来。

为了缓存目录,我们把拉取从页面里抽出来,放进它自己的 Products 组件。这样 "use cache" 就有了可坐落的作用域,也让页面能把目录与推荐当作彼此独立的片段来渲染:

// 文件:app/page.tsx
import { Suspense } from 'react';

export default function StorePage() {
  return (
    <main>
      <h1>Store</h1>
      <Products />
      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations />
      </Suspense>
    </main>
  );
}

async function Products() {
  'use cache';
  const products = await getProducts();
  return <ProductGrid products={products} />;
}


错误消失了,页面现在分两层渲染。标题与商品网格被预渲染:Next.js 在请求之前就算好它们的 HTML,并立刻作为外壳发出。推荐按每次请求运行,并在骨架屏后面流式进入。用户会立刻看到目录,而不是在最慢的那条查询完成前盯着空白屏。

因为外壳不依赖请求,它也可以缓存在 CDN 上、从靠近用户的地方提供,而无需往返你的服务器。每次请求只有推荐需要服务器。

这种两层拆分就是部分预渲染(Partial Prerendering,PPR)。它曾经是一个单独的实验性开关。现在它就是 Cache Components 所建立的渲染模型,因此你无需再配置任何东西就能得到它。

use cache 指令

我们通过在组件顶部放置 "use cache" 来缓存 Products。与 React 的其他指令 "use client""use server" 一样,它是作用域顶部的一个字符串,会改变其中代码被如何对待。它可在三个层级工作,取决于你想缓存多少。

在文件顶部时,每一个导出都会被缓存。这是整页版本,适用于完全没有按请求数据的页面:

// 文件:app/about/page.tsx
'use cache';

export default async function AboutPage() {
  const team = await getTeamMembers();
  return <TeamList members={team} />;
}


在单个组件上,像上面的 Products 那样,当页面只有一部分应当被缓存时:

async function Products() {
  'use cache';
  const products = await getProducts();
  return <ProductGrid products={products} />;
}


或者在普通的异步函数上,以便无论从哪里调用都能缓存一次查询:

export async function getProducts() {
  'use cache';
  return db.product.findMany();
}


函数层级往往最有用。如果多个页面调用 getProducts,它们共享同一个缓存条目,而调用它的组件不必知道它已被缓存。

缓存函数第一次运行时,Next.js 会存下结果。下一次用相同输入调用时,会返回已存的值,而不是再跑一遍函数。

你从不自己写缓存键。Next.js 从函数身份及其序列化后的参数推导出键,因此用不同参数调用同一缓存函数时,每个变体各自成一条缓存:

async function getProduct(slug: string) {
  'use cache';
  return db.product.findUnique({ where: { slug } });
}


调用 getProduct('shoes')getProduct('hats') 会产生两条独立条目,因为参数是键的一部分。闭包变量也算,因此如果函数从外层作用域读取某个值,该值也会成为键的一部分。

因为键来自参数,那些参数必须可序列化:原始类型、普通对象、数组、DateMapSet。返回值使用 React 已用于服务端组件输出的同一套序列化,这就是为什么 Products 可以返回 JSX,而不只是普通数据。

cacheLife 设定生命周期

缓存的目录有效多久?默认情况下,条目会在一段设定时间内保持新鲜,但你通常会想自己设定。这就是 cacheLife 的用途。在 use cache 作用域内用命名配置档调用它:

import { cacheLife } from 'next/cache';

export async function getProducts() {
  'use cache';
  cacheLife('hours');
  return db.product.findMany();
}


一个配置档打包三个值:

  • stale 是客户端在再次向服务器核对之前,复用该条目的时长。
  • revalidate 是服务器在后台刷新该条目的频率。
  • expire 是硬上限,到了这一点条目太旧而不能再提供,会按需重新生成。

内置配置档从 secondsmax。上面的 hours 配置档每小时在后台刷新一次,并在一天后过期,适合一周只变几次的目录。当没有合适的命名配置档时,用秒传入确切数字:

cacheLife({ stale: 300, revalidate: 900, expire: 3600 });


如果你用过 React Query,stalestaleTime 是同一思路:一个值在值得重新拉取之前被当作新鲜的时长。revalidate 窗口则像 SWR 这类库提供的后台刷新。差别在于这跑在服务器上,并按每个缓存函数生效,因此你是为每一块数据单独调参,而不是给整条路由设一条策略。

cacheTag 做失效

基于时间的过期适合会漂移的数据,但不适合在已知时刻发生变化的数据。当团队里有人在管理后台重命名一件商品时,目录不该继续把旧名字再展示最多一小时。

为此,在读取数据处用 cacheTag 给条目打标签,在写入时清除该标签。如果你用过 React Query,这就是服务端版的按键使查询失效:queryClient.invalidateQueries({ queryKey: ['products'] })

在你读取数据的地方给该数据打上标签:

import { cacheLife, cacheTag } from 'next/cache';

export async function getProducts() {
  'use cache';
  cacheLife('hours');
  cacheTag('products');
  return db.product.findMany();
}


然后从执行写入的服务端操作(Server Action)里使该标签失效:

'use server';
import { revalidateTag } from 'next/cache';

export async function renameProduct(id: string, formData: FormData) {
  await db.product.update({
    where: { id },
    data: { name: formData.get('name') },
  });
  revalidateTag('products', 'max');
}


这里在 Next.js 16 有两处变化。revalidateTag 现在接受第二个参数——一个 cacheLife 配置档,控制你失效之后条目如何表现。并且对服务端操作新增了 updateTag,用于用户必须立刻看到自己改动的场景,无论是同一屏还是下一屏:

'use server';
import { updateTag } from 'next/cache';

export async function renameProduct(id: string, formData: FormData) {
  await db.product.update({
    where: { id },
    data: { name: formData.get('name') },
  });
  updateTag('products'); // 在响应返回前使条目过期并重新读取
}


两者的差别在于时机。updateTag 会使条目过期,并在响应返回前重新读取,因此重命名商品的管理员在表单提交后立刻看到新名字。revalidateTag 清除标签并在后台刷新,因此管理员可能还会短暂看到旧名字,但下一位访客会得到新的。

在读己之写(read-your-own-writes)流程里用 updateTag,那种场景下陈旧结果会看起来像写入失败了。短延迟可以接受时用 revalidateTag。两者都会在同一次调用里清除服务器上的条目以及客户端路由器缓存中的条目。

读取 cookie 与请求头

商店页面还剩一块:推荐。假设它们基于地区 cookie,而查询慢到你想按地区缓存。把 "use cache" 挪进组件并不管用:

import { cookies } from 'next/headers';

async function Recommendations() {
  'use cache';
  const region = (await cookies()).get('region')?.value; // 错误
  const products = await getRecommendations(region);
  return <ProductGrid products={products} />;
}


use cache 作用域不能调用 cookies()headers(),也不能读取 searchParams,这会在构建或请求时失败。原因是缓存条目跨请求共享,而这些值对每个请求都不同。如果你能在缓存内读取 cookie,第一个用户的地区就会被烤进之后所有人收到的条目里。这条规则防止你意外把一个用户的数据泄漏给另一个。

修复办法是:在未缓存的数据拉取函数里读取请求值,再把它作为参数传入缓存函数。它会成为缓存键的一部分,因此每个地区有自己的条目:

import { cookies } from 'next/headers';

async function Recommendations() {
  const products = await getRecommendations();
  return <ProductGrid products={products} />;
}

async function getRecommendations() {
  const region = (await cookies()).get('region')?.value ?? 'us';
  return getRegionRecommendations(region);
}

async function getRegionRecommendations(region: string) {
  'use cache';
  return db.recommendations.findMany({ where: { region } });
}


现在读取 cookie 发生在 getRecommendations 里,它未缓存且每次请求都会运行,因此 Recommendations 仍留在页面上的 Suspense 边界内。地区作为参数传入 getRegionRecommendations,于是其后的昂贵查询按地区缓存,而不是为每位访客都跑一遍。

如果没法重构成把值传进去,还有 "use cache: private" 变体,按用户缓存,并允许读取请求 API。大多数代码不会需要它。

你不再需要的东西

旧模型难用的一部分原因,是控制缓存的 API 数量多,且各有各的规则。Cache Components 去掉了大部分这类表面。一旦打开该开关,这些是你不必再碰的 API:

不再需要改用什么
export const dynamic = 'force-dynamic'什么都不用,动态是默认
export const dynamic = 'force-static''use cache' 配合 cacheLife('max')
export const revalidate = 3600use cache 作用域里用 cacheLife('hours')
fetch(url, { next: { revalidate, tags } })'use cache' 配合 cacheLifecacheTag
unstable_cache(fn, keys, { tags })'use cache' 配合 cacheTag,且无需手写键
unstable_noStore()什么都不用,默认即未缓存
experimental_ppr = true什么都不用,PPR 已内建

旧的 fetchunstable_cache 层若留在原处仍可工作,因此没有什么强迫你立刻重写。有一点差别要记住:那些更旧的缓存在部署之间会持久存在,而 use cache 默认把条目存在内存里,因此在无服务器(serverless)上它们的作用域是单个实例。若要持久或共享缓存,你配置缓存处理器,或使用 "use cache: remote"

由于该开关会一次性改变整个应用的默认,在已有项目里采用它需要一些谨慎。Next.js 附带了一个 agent skill,可让编码 agent 一次迁移一个子树。开发错误也同样写成了可供 agent 行动的形式,见 Next.js 16.3: AI Improvements。

这如何让导航变得即时

商店页面的外壳——标题与已缓存的目录——对每位访客都一样。因为它是共享的,Next.js 可以预取它,并在客户端保留一份副本。当用户点击指向商店的链接时,路由器无需往返服务器就能渲染该外壳,推荐则在骨架屏后面流式进入,就像新鲜加载时一样。服务器仍会为首次访问生成初始 HTML,因此首次绘制与 SEO 不变。

这就是 Instant Navigations,目前在 Next.js 16.3 中预览。与此同时,新的部分预取(Partial Prefetching)模式改变了路由器拉取的内容:不再为每个链接各发一次预取请求,Next.js 为每条路由预取一个可复用的外壳,并缓存在客户端。该外壳正是你的 "use cache" 调用所预渲染的同一个。公告涵盖其余内容,包括用于捕捉回归的 instant() Playwright 辅助,以及用于查看每条路由预取了什么的 Navigation Inspector。

关键要点

  • Cache Components 翻转了默认。cacheComponents: true 下,数据默认是动态的,你用 "use cache" 选择加入缓存。
  • 被 await 的数据会被流式传输、缓存,或阻塞。<Suspense> 包住它,用 "use cache" 缓存它,或用 export const instant = false 让路由退出。Next.js 会在开发中标出任何尚未决定的部分。
  • "use cache" 可在文件、组件或函数层级工作。 Next.js 从函数及其序列化参数推导缓存键,因此你从不自己写键。
  • cacheLife 设定生命周期,cacheTag 处理失效。 用配置档按定时重新验证,或用 revalidateTagupdateTag 按需清除标签。
  • 缓存作用域不能读取按请求的 API。 在缓存外读取 cookies()headers()searchParams,再把值传进去。

结论

商店页面一开始是一个 Next.js 无法自行决策的异步组件。把它拆成已缓存的目录与流式传输的推荐只需要两处小改动;之后每一种需求——无论是生命周期、按需失效,还是按地区缓存——都只有一个 API 可找。数据在你缓存它之前保持动态,而 Next.js 会在开发中标出仍需决策的部分,因此你清楚什么是静态、什么是动态,而不是到生产环境才发现。加速首次加载的同一个预渲染外壳,也是客户端复用以让导航即时的那个外壳。

你可以在真实应用里看到这一切。Next Beats 是一个建立在启用了 Cache Components 的 Next.js 16.3 Preview 上的音乐播放器,源码在 GitHub 上。如果你在用 Next.js 16,不妨在分支里试着迁移自己的一个应用,用 adoption skill 一次处理一个子树,看看它在新模型下如何渲染。


参考资料:

  • Next.js 文档 > Cache Components
  • Next.js 文档 > use cache
  • Next.js 文档 > cacheComponents
  • Next.js 文档 > cacheLife
  • Next.js 文档 > cacheTag
  • Next.js 文档 > updateTag
  • Next.js 16.3:Instant Navigations(即时导航)
  • Next.js 16.3:AI Improvements(AI 改进)
  • Next.js 博客 > Our Journey with Caching