ByteNoteByteNote
一文说清 Next.js 14 的多层缓存体系
字

字节笔记本

2026年10月7日 · 约 9 分钟读完

一文说清 Next.js 14 的多层缓存体系

API中转
¥120

升级到 Next.js 14 的 App Router 之后,许多开发者撞到的第一个坑不是新语法,而是缓存:接口数据在构建时就被"冻住",页面秒开得让人可疑,上游数据明明变了线上却纹丝不动。这些现象指向同一个根源——App Router 不只有一种缓存,而是把缓存拆成了好几层,而且大多数层默认开启。

这套设计有它的来路。App Router 把 React Server Components 变成了默认形态,渲染从 Pages Router 时代"SSG 与 SSR 二选一"变成了一条可以逐层截断的流水线:浏览器、服务器、单次渲染、单次取数,每一跳都有自己的缓存挡在前面。命中率叠起来性能确实惊人,代价是心智模型复杂——想真正驾驭它,第一步是分清每一层到底缓存了什么。

四层缓存,各管一段

App Router 的缓存体系可以拆成四层,按一次请求的流转顺序看最清楚:

Next.js 14 四层缓存链路示意

  • 数据缓存(Data Cache):缓存 fetch 等数据请求的返回结果,作用范围是一次数据获取。它持久存放在服务端,跨请求、跨用户共享。
  • 请求记忆化(Request Memoization):在一次页面渲染过程中,相同 URL 的 fetch 只会真正发出一次,其余复用结果。作用域仅限单次渲染,渲染结束即失效,无需任何配置。
  • 全路由缓存(Full Route Cache):缓存整条路由渲染完成后的 HTML 与 RSC payload。没有使用动态函数的路由会在构建时渲染一次并缓存,后续请求直接返回产物。
  • 客户端路由缓存(Router Cache):浏览器内缓存访问过的路由的 RSC payload,让前进、后退和链接预取几乎瞬时完成。

一句话概括:数据缓存管"取数",全路由缓存管"页面",请求记忆化管"单次渲染内去重",客户端路由缓存管"浏览器端导航"。四层各自独立开关,也各有各的失效方式,这就是它让人混乱的根源。

最大的坑:fetch 默认永久缓存

在服务端组件里写 fetch,不加任何选项时结果会被永久缓存——构建时请求一次,之后不再更新。这与大多数人熟悉的浏览器 fetch 直觉完全相反,也和 Pages Router 时代 getStaticProps、getServerSideProps 边界分明的做法不同:

javascript
// 不加选项:结果被永久缓存,构建时请求一次后不再变
const res = await fetch('https://api.example.com/products');

// 缓存 1 小时,到期后由下一次请求触发后台重新生成(ISR 思路)
const res = await fetch('https://api.example.com/products', {
  next: { revalidate: 3600 },
});

// 完全跳过数据缓存,每次都拿最新数据
const res = await fetch('https://api.example.com/live', {
  cache: 'no-store',
});

Next.js 14 fetch 缓存行为速查

由此产生两个典型事故。其一是"构建时数据被冻住":静态路由在 build 阶段完成渲染,此后即便上游接口变了,页面仍是老数据,除非显式设置了 revalidate。其二是"动态函数击穿静态渲染":只要组件里调用了 cookies()、headers() 这类动态函数,整条路由就会被标记为动态,构建时不再预渲染,全路由缓存随之失效。

另一个容易混淆的点:请求记忆化不等于数据缓存。记忆化只在"这一次渲染"内生效,比如布局和页面组件同时请求同一个接口,实际只发一次网络请求,但渲染结束它就作废;数据缓存则跨请求、跨用户长期存在。前者优化的是单次渲染的重复劳动,后者优化的才是重复流量。

内容更新需要立刻生效时,可以给 fetch 打上缓存标签,再通过 API 路由按需失效,比如由 CMS 的发布回调触发:

javascript
// 取数时打标签
fetch('https://api.example.com/products', { next: { tags: ['products'] } });

// app/api/revalidate/route.js:外部回调触发失效
import { revalidateTag } from 'next/cache';
export async function GET() {
  revalidateTag('products');
  return Response.json({ revalidated: true });
}

页面缓存与数据缓存的分界线

排查缓存问题时,把它分成"页面缓存"和"数据缓存"两条线来看,混乱会消掉大半:

页面缓存(全路由缓存)数据缓存
缓存什么整页 HTML 与 RSC payloadfetch 返回的原始数据
作用范围整条路由单次数据获取
对渲染的影响直接跳过整个渲染过程页面仍会重新渲染,只是不再重新取数
默认行为静态路由默认缓存fetch 默认永久缓存
禁用方式dynamic = 'force-dynamic'cache: 'no-store'

关键在第三行。页面缓存命中时,服务器连渲染都不做,直接吐出缓存的 HTML;数据缓存命中时,页面照常执行渲染,只是数据来自缓存。所以"页面保持动态、数据走缓存"是完全成立的:仪表板可以每次请求都重新渲染,同时让其中商品列表的数据缓存一小时,两层互不干扰。

把一次真实请求串起来看:请求到达后先查客户端路由缓存(浏览器内),未命中才到服务器查全路由缓存;也未命中则进入渲染,渲染过程中的 fetch 先过请求记忆化,再落到数据缓存;数据缓存也未命中,才真正打到上游接口或数据库。任何一层命中,后面的环节全部跳过——这就是"页面秒开得可疑"的全部解释。

与服务端、客户端组件的关系

App Router 中组件默认是服务端组件,缓存体系也围绕它展开。服务端组件在服务器上渲染,可以直接读数据库,直接享受数据缓存和全路由缓存,还不给客户端 JS 包增加体积。客户端组件(文件顶部声明 'use client')在浏览器中运行,不参与服务端缓存;但如果它通过 API 路由取数,而路由处理器内部用了缓存的 fetch,它就间接受益。

常见组合是:页面骨架与初始数据放在服务端组件里完成取数与缓存,再把结果作为 props 传给交互密集的客户端子组件。组件的边界也很清晰:服务端组件不能使用 useState、useEffect,客户端组件摸不到数据库。这条组件边界,同时就是缓存的边界——想吃到缓存,就把取数留在服务端。

实战清单

  1. 内容页(文章、商品详情):什么都不用改,按需加 revalidate 控制新鲜度即可,性能收益最大。
  2. 实时仪表板:页面级声明 dynamic = 'force-dynamic',内部数据按需 cache: 'no-store' 或设短 revalidate。
  3. 后台改数据要立刻生效:用 revalidateTag 或 revalidatePath 做按需失效,不要干等 TTL 过期。
  4. 排查"数据不更新":按顺序查三处——是否构建时渲染、是否设置了 revalidate、客户端路由缓存是否返回了旧快照。
  5. 缓存位置因部署而异:开发环境多在内存,SSG 产物落在文件系统,静态资源与 ISR 页面可能进入 CDN,自托管时也可以接 Redis 等外部缓存作存储后端。

还有一点值得知道:revalidate 的语义是"过期后先返回旧内容,同时在后台重新生成",也就是 stale-while-revalidate 模式。所以设置 revalidate: 60 的页面,不是每分钟整点刷新,而是最多陈旧一分钟,且用户几乎不会被重新生成阻塞。

写在最后

Next.js 14 的缓存体系能力很强,但"默认缓存一切"确实反直觉,社区的抱怨也高度集中于此。后续版本已经作出回应:fetch 的默认行为改为不再缓存,路由处理器与客户端路由缓存的默认过期策略也一并收紧。方向很明确——缓存应当是显式的选择,而不是隐式的行为。而仍停留在 14.x 的项目,理解上面这几层的分界与开关,就是避免线上数据陈旧的第一步。

相关文章

分享: