Chapter 05

缓存模型与三代翻转

上一章讲了在组件里取数、并行与去重。取来的数据要不要跨请求缓存、缓存多久、何时失效,是 App Router 最容易让人困惑、也是近两年变化最大的部分。这一章把缓存讲清楚,并理清 v14→v15→v16 三代默认行为的翻转——读老教程时这是最大的过时来源。

本章你将建立的 schema

  • 历史上有四层缓存:Request Memoization(单次渲染内)、Data Cache(跨请求持久)、Full Route Cache(构建出的路由产物)、Router Cache(浏览器端导航 payload)
  • 默认行为三代翻转:v14 默认缓存 fetch → v15 默认全部关闭 → Next 16 改为 'use cache' 显式开启(Cache Components)
  • 'use cache' + cacheLife + cacheTag:显式声明"什么被缓存、活多久、带什么 tag"
  • 失效:按时间(revalidate)、按 tag(revalidateTag,Next 16 起需带 cacheLife profile 参数)、按路径(revalidatePath)、读己写(updateTag)

5.1四层缓存:各缓存什么、活多久

Next 的缓存不是一个开关,而是四层各管一段:请求内去重、跨请求数据、整条路由产物、客户端导航。

逐层说清位置(比文档深一层)

这四层位置不同、生命周期不同,混在一起谈是困惑的根源。Request Memoization 就是 04 章那层:在单次渲染内对相同 fetch 去重,把多个组件发出的同一请求合成一次,请求结束即丢,不跨请求。Data Cache 持久存数据结果(fetch 响应或被缓存函数的返回),跨请求、跨部署存活,按时间或 tag 失效——它是唯一真正"持久"的那层。Full Route Cache 存的是构建或重新验证时算出的路由产物(HTML + RSC payload),服务端复用,对应 03 章的静态渲染结果。Router Cache 在浏览器内存里缓存已访问路由的 RSC payload,让前进/后退秒回,是唯一活在客户端的那层。

一次请求进来,会依次穿过这四层。把它画成一条自上而下的管线最清楚:前两层在数据维度去重与持久化,第三层是渲染产物,最后一层在浏览器里。

服务端 客户端 一次请求进来 ① Request Memoization 单次渲染内去重 · 内存 · 请求结束即丢 ② Data Cache 跨请求 / 跨部署持久 · 按时间或 tag 失效 渲染 RSC ③ Full Route Cache 路由产物 HTML+RSC · 服务端 · 静态结果复用 ④ Router Cache 已访问路由 payload · 浏览器内存 · 前进后退秒回
图 5.1四层缓存是一条自上而下的管线,不是一个开关。注意:只有 ② Data Cache 跨请求与跨部署持久;① 随渲染结束即丢,③ 是服务端的渲染产物,④ 唯一活在浏览器。谈"缓存"前先确认谈的是哪一层。
表 5.1 · 四层缓存对照
层缓存内容生命周期位置如何失效
Request Memoization单次渲染内相同 fetch 的结果一次请求内,渲染结束即清服务端内存无需失效,请求结束自动消失
Data Cachefetch 响应 / 被缓存函数的返回跨请求、跨部署持久服务端持久层按时间 revalidate / 按 tag revalidateTag / 按路径 revalidatePath
Full Route Cache路由的 HTML + RSC payload构建后持久,到重新验证才更新服务端路由依赖的数据失效时级联失效,或 revalidatePath
Router Cache已访问路由的 RSC payload会话内短时(按版本有时限)浏览器内存导航刷新 / router.refresh() / Server Action 后

5.2三代默认行为:为什么翻转

同一行 fetch 代码,在 v14 默认被缓存、在 v15 默认不缓存、在 Next 16 要靠 'use cache' 才缓存——默认行为翻了两次。

为什么翻转(比文档深一层)

v14(2023 年)默认缓存 fetch,理由是"利好性能":同一个 URL 取一次后跨请求复用。但快速原型和高动态应用深受其害——开发者写下一行普通 fetch,拿到的却是旧数据,且"哪儿被缓存了"无法一眼看出。为了关掉这层隐藏缓存,代码里被迫散布 export const dynamic = 'force-dynamic'、export const revalidate = 0、fetchCache 这类逃生舱,心智负担极重。

v15(2024 年末)把默认翻转为不缓存:fetch 默认 no-store,GET Route Handler 默认不缓存。这是把"快但有读到旧数据的风险"换成"慢但默认正确"——要缓存就显式声明,而不是默认偷偷缓存再到处关。Next 16(截至 2026 初)的 Cache Components 把它收敛成一个显式模型:"要动态就用 <Suspense>,要缓存就 'use cache'",目标是不再有任何隐藏缓存——缓存与否一律写在代码里。

v14 · 2023 默认缓存 fetch 同一段 fetch 的命运 → 被缓存 代价:拿到旧数据 标志性逃生舱 force-dynamic revalidate = 0 v15 · 2024 末 默认不缓存 同一段 fetch 的命运 → 不缓存 默认:no-store GET handler 不缓存 慢但默认正确 Next 16 · 2026 初 显式开启 同一段 fetch 的命运 → 仍不缓存 新指令 'use cache' Cache Components 不再有隐藏缓存
图 5.2同一段 fetch 的默认命运翻了两次:v14 缓存 → v15 不缓存 → Next 16 仍不缓存、要 'use cache' 才缓存。注意:v14 的逃生舱(force-dynamic 等)是为"关掉"隐藏缓存而生;Next 16 反过来要求显式"打开"缓存。读教程先看它对应哪一代。
过时陷阱(dated)

从 v14 项目升级、或照 2023 年的老教程写代码,会以为 fetch 还自动缓存。v15(2024 年末)起默认不缓存:结果要么每次请求都猛打源站、要么困惑于"为什么数据不缓存了"。判断依据只有一条——看项目的 Next 版本,再决定那行 fetch 默认是缓存还是不缓存。

想一想

在 Next 16 里写一个普通的 await fetch(url),不加任何指令。它的结果会被跨请求缓存吗?

展开答案(先停 10 秒)

不会。Next 16 沿用 v15 的默认——普通 fetch 不进 Data Cache,每次请求都会真正去取。要让它跨请求缓存,得把它放进一个标了 'use cache' 的函数或组件里(见 §5.3)。"默认缓存"是 v14 时代的记忆,已经被翻转两次。

5.3'use cache' / cacheLife / cacheTag:现在怎么显式缓存

在一个函数 / 文件 / 组件顶部写 'use cache',它的返回结果进入 Data Cache;cacheLife 设定新鲜期与最长寿命,cacheTag 给它打可失效的标签。

机制(dated · Next 16)

'use cache' 把被标记的工作产物缓存起来,跨请求复用——放在函数顶部缓存该函数的返回,放在文件顶部缓存该文件导出的全部,放在组件顶部缓存该组件的渲染结果。cacheLife('hours')(或 'days' / 自定义 profile)控制这条缓存的 stale(多久后算陈旧)、revalidate(多久后后台刷新)、expire(多久后彻底过期)。cacheTag('product-42') 给缓存条目打一个标签,供之后按 tag 精确失效。这套是 Next 16 把旧的 unstable_ 前缀去掉后的稳定成品,cacheLife、cacheTag 现在直接从 next/cache 导入;它替代了旧的 unstable_cache。

lib/get-product.ts tsx
import { cacheLife, cacheTag } from 'next/cache'  // Next 16:稳定导入,无 unstable_

export async function getProduct(id: string) {
  'use cache'                       // ← 返回值进入 Data Cache,跨请求复用
  cacheLife('hours')                // 新鲜期/重验/过期,用内置 'hours' profile
  cacheTag('product-' + id)         // 打标签,供之后按 tag 精确失效

  const res = await fetch(`https://api.shop.com/products/${id}`)
  return res.json()
}

'use cache'是这段缓存的总开关。没有它,里面那行 fetch 在 Next 16 默认不缓存(见 §5.2);有了它,getProduct 的返回值被存进 Data Cache。

cacheTag标签可以由数据派生(这里用 id 拼出 product-42),所以之后能针对"这一个商品"精确失效,而不波及别的缓存条目——这正是 §5.4 的基础。

现状速览(截至 2026 初)

'use cache' 经由 next.config 里的 cacheComponents: true 启用;开启后 03 章的 PPR(部分预渲染)默认随之开启——静态壳 + 流式动态成为默认形态。旧的 unstable_cache 与 v14 的隐式 fetch 缓存均已被这套显式模型取代。读 2024 年及更早的教程见到 unstable_cache 时,对应到现在就是 'use cache'。

next.config.ts tsx
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  // 截至 2026 初:开启后才能用 'use cache',且 PPR 默认随之开启
  cacheComponents: true,
}

export default nextConfig

5.4失效:让缓存更新

缓存靠三种方式更新——到时间自动重新验证、按 tag 精确失效、按路径失效;写操作后通常显式触发其一。

机制(比文档深一层)

revalidateTag('product-42') 之所以能精确工作,是因为 cacheTag() 在数据加载时给缓存条目打了标签,而标签可以是数据派生的(如 product-42)。失效时按"tag → 条目"的索引把带这个 tag 的条目作废,并级联到依赖这些数据的路由:那些路由的 Full Route Cache 也随之失效,下次请求重新渲染。revalidatePath 则是另一条路径——按路由路径直接失效 Full Route Cache,不经过 tag 索引。

两个 dated 要点:Next 16 起 revalidateTag 需要第二个参数指定 cacheLife profile(如 'max'),单参数形式已废弃、会报 TypeScript 错误;'max' 给出 stale-while-revalidate 语义(先返回旧值、后台刷新)。另有 updateTag(单参数,仅限 Server Action)用于"写完立刻读到自己刚写的值"的 read-your-writes 场景——它立即作废而非后台重验。这些失效的实际触发点,通常在 06 章的 Server Action 里。

revalidateTag ('product-42', 'max') 按 tag 索引 Data Cache 条目 带 tag: product-42 作废 级联 依赖路由的 Full Route Cache 随之失效 下次请求 → 重新渲染
图 5.3revalidateTag('product-42') 的级联:失效调用沿 tag 索引找到条目作废,依赖它的路由产物随之失效,下次请求才重新渲染。注意:精确性来自 cacheTag 当初打的派生标签——只波及带该 tag 的条目,别的商品页缓存照旧。
失效三种方式(示意) tsx
import { revalidateTag, revalidatePath, updateTag } from 'next/cache'

// 1. 按 tag 失效:Next 16 起必须带第二个 cacheLife profile 参数
//    单参数 revalidateTag('product-42') 已废弃,会报 TS 错误
revalidateTag('product-42', 'max')   // 'max' = stale-while-revalidate

// 2. 按路径失效:直接作废该路由的 Full Route Cache
revalidatePath('/product/42')

// 3. 读己写:写完立刻读到自己刚写的值,仅限 Server Action
updateTag('product-42')              // 单参数;立即作废,非后台重验
想一想

一个后台改了商品 42 的价格,调用了 revalidateTag('product-42', 'max')。此时另一个用户立刻刷新商品 42 的页面,他第一眼看到的是新价格还是旧价格?

展开答案(先停 10 秒)

'max' 给的是 stale-while-revalidate 语义:第一眼很可能仍是旧价格(陈旧值被立即返回),同时后台触发一次重新验证;之后的请求才稳定拿到新价格。要让"写完这一刻就看到新值",用的是 updateTag(read-your-writes,立即作废),而不是 revalidateTag。两者的取舍:revalidateTag('…','max') 快但短暂陈旧,updateTag 保证即时一致但更贵。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. 列出四层缓存,每一层用一句话说清"缓存什么、在服务端还是客户端"。
  2. Next 16 里一段普通 await fetch(url) 不加任何指令,结果会被跨请求缓存吗?要让它缓存该怎么写?
  3. v14 为什么默认缓存 fetch,又为什么在 v15 被翻转成默认不缓存?各自的代价是什么?
  4. (设计层)一个商品页价格变了,你只想让用到这个商品的页面更新、别的页面缓存照旧。该用哪种失效方式?为什么它能做到精确失效?
答案(先做完再展开)
  1. Request Memoization——单次渲染内对相同 fetch 去重,服务端,请求结束即丢;Data Cache——fetch/被缓存函数的返回,服务端,跨请求持久;Full Route Cache——路由的 HTML+RSC 产物,服务端;Router Cache——已访问路由的 RSC payload,客户端(浏览器内存)。
  2. 不会缓存。Next 16 沿用 v15 默认,普通 fetch 不进 Data Cache。要缓存就把它放进标了 'use cache' 的函数/组件里,并用 cacheLife 设寿命、cacheTag 打标签。
  3. v14 默认缓存是为了性能(跨请求复用同一 URL),代价是开发者拿到旧数据、且要到处写 force-dynamic / revalidate=0 等逃生舱去关闭隐藏缓存,心智负担重。v15 翻转为默认不缓存(no-store),代价是更慢/更多源站请求,换来的是"默认正确、缓存需显式声明"。
  4. 用 按 tag 失效:revalidateTag('product-42', 'max')。它能精确,是因为加载数据时 cacheTag('product-42') 给条目打了数据派生的标签;失效按"tag→条目"索引只作废带该 tag 的条目,并级联到依赖它的路由,其余商品页的缓存不受影响。
进阶挑战 · 刚好够不着

给一个新闻站设计三类内容的缓存策略

一个新闻站有三类内容,更新频率差别很大:文章正文一天更新几次、首页头条几分钟更新一次、用户评论要实时。请为这三类内容分别选择缓存/失效策略(候选:'use cache' + cacheLife、按 tag 失效、不缓存 / <Suspense> 动态),并说明在 Next 16 的默认行为下,哪些内容需要你显式声明才会被缓存。

提示(卡住再展开)

按"能容忍多旧"分档。文章正文:'use cache' + cacheLife('hours'),并 cacheTag('article-' + id)——编辑发稿时 revalidateTag 精确刷新这一篇。首页头条:'use cache' + 短 profile(自定义 minutes 量级),靠时间自动重验即可。用户评论:不缓存,放进 <Suspense> 走 03 章的动态/流式渲染,永远取最新。关键的 dated 点:Next 16 默认不缓存,所以正文和头条都必须显式写 'use cache' 才会缓存;评论反而是"什么都不写"就符合预期。这也正好是 PPR 的典型形态——静态壳(正文/头条)+ 流式动态(评论)。