- 发布日期
第 11 讲|数据获取:fetch 增强、unstable_cache、'use cache'
fetch 增强机制、unstable_cache 与 use cache 指令的数据获取体系
阶段二:App Router 核心机制 · 第 11 / 40 讲 难度:⭐⭐⭐⭐ · 预计耗时:3.5 小时 配套 fixture:
fixtures/lecture-11/
学习目标
- 理解
fetch在 Next.js 里被打了哪些"补丁":patchFetch/NextFetchRequestConfig/ 自动 dedupe / 自动 tag 收集。 - 看懂
unstable_cache的工作流:fixedKey + invocationKey + IncrementalCache.get/set。 - 掌握
'use cache'指令的"分层缓存"模型:cacheLife / cacheTag / 隐式拉取 cookies。 - 理解 CacheHandler 接口(5 个方法)与默认实现,能根据自定义后端实现 handler。
- 在 fixture 中体验数据失效的三种触发:时间过期、
revalidatePath/revalidateTag、Server Action 修改。
1. 三层缓存 API 全景
App Router 给开发者提供了三层数据缓存 API:
| API | 适用范围 | 何时启用 | 失效方式 |
|---|---|---|---|
fetch(url, { next: { revalidate, tags } }) | 任何 server-side fetch 调用(含 SC、Route Handler) | 自动(patchFetch) | 时间 / revalidateTag / revalidatePath |
unstable_cache(fn, keyParts, { revalidate, tags }) | 包裹任意纯函数(不一定是 fetch) | 显式 | 时间 / revalidateTag |
'use cache' 指令 + cacheLife / cacheTag | 整个函数 / 文件 / 整个组件树 | 配合 cacheComponents | cacheLife 决定 / cacheTag 收集 |
速记:fetch 适合"远程接口";unstable_cache 适合"本地计算或内部函数";'use cache' 适合"组件级"。
学完这一讲你会发现,三层都共享同一个底层基建:IncrementalCache + CacheHandler。理解这个底盘比记 API 名字重要得多。
2. fetch 增强:patchFetch
packages/next/src/server/lib/patch-fetch.ts 1300+ 行,但理解三个入口就够。
2.1 全局打补丁的"开关"
export const NEXT_PATCH_SYMBOL = Symbol.for('next-patch')
function isFetchPatched() {
return (globalThis as Record<symbol, unknown>)[NEXT_PATCH_SYMBOL] === true
}
patchFetch 在 server bootstrap 阶段被调用一次,把 globalThis.fetch 替换为 createPatchedFetcher 返回的版本。NEXT_PATCH_SYMBOL 防止重复 patch(test 重启 server 时会重复 init,必须幂等)。
2.2 NextFetchRequestConfig 的字段
打补丁后 fetch(url, init) 多了一个 init.next 字段:
interface NextFetchRequestConfig {
revalidate?: number | false; // 0 等价 no-store
tags?: string[]; // 用于 revalidateTag
}
// 业务示例:商品详情接口
const res = await fetch(`https://api.example.com/products/${id}`, {
next: {
revalidate: 60, // 60 秒后台重新拉
tags: [`product:${id}`, "product"], // 标签维度失效
},
});
2.3 校验逻辑
export function validateRevalidate(
revalidateVal: unknown,
route: string
): undefined | number {
try {
let normalizedRevalidate: number | undefined = undefined
if (revalidateVal === false) {
normalizedRevalidate = INFINITE_CACHE
} else if (
typeof revalidateVal === 'number' &&
!isNaN(revalidateVal) &&
revalidateVal > -1
) {
normalizedRevalidate = revalidateVal
} else if (typeof revalidateVal !== 'undefined') {
throw new Error(
`Invalid revalidate value "${revalidateVal}" on "${route}", must be a non-negative number or false`
)
}
return normalizedRevalidate
} catch (err: any) {
// ...
return undefined
}
}
要点:
false→INFINITE_CACHE(永久缓存,等价于"build 时静态化")。0→ 视为不缓存(动态)。- 必须
>=0。
2.4 tag 校验
export function validateTags(tags: any[], description: string) {
const validTags: string[] = []
const invalidTags: Array<{
tag: any
reason: string
}> = []
for (let i = 0; i < tags.length; i++) {
const tag = tags[i]
if (typeof tag !== 'string') {
invalidTags.push({ tag, reason: 'invalid type, must be a string' })
} else if (tag.length > NEXT_CACHE_TAG_MAX_LENGTH) {
invalidTags.push({
tag,
reason: `exceeded max length of ${NEXT_CACHE_TAG_MAX_LENGTH}`,
})
} else {
// Encode so a non-ASCII tag can be safely serialized into the
// `x-next-cache-tags` HTTP header without tripping Node's header
// validation. Length is checked on the raw input above.
validTags.push(encodeCacheTag(tag))
}
if (validTags.length > NEXT_CACHE_TAG_MAX_ITEMS) {
console.warn(
`Warning: exceeded max tag count for ${description}, dropped tags:`,
tags.slice(i).join(', ')
)
break
}
}
// ...
return validTags
}
要点:
- 长度上限
NEXT_CACHE_TAG_MAX_LENGTH(256);条数上限NEXT_CACHE_TAG_MAX_ITEMS(128)。 - 自动
encodeCacheTag(因为 tag 会落到x-next-cache-tagsHTTP 头里)。 - 超长/非字符串只 warn,不报错——保证旧代码兼容。
2.5 cache: 'force-cache' | 'no-store'
除了 next.revalidate,原生 fetch 的 cache 字段也被识别:
force-cache→ 等价revalidate: false(永久缓存)。no-store→ 等价revalidate: 0(不缓存)。
在 Next.js 14 之前,默认
fetch行为是"force-cache";从 15 开始改为"no-store"。这是历史性默认值切换,老项目升级常踩。
2.6 自动 dedupe
同一次 server 渲染中,对同一个 URL 多次 fetch 只会执行一次——这是 React 的 cache(fn) 套了一层。例如:
async function getUser(id: string) {
const res = await fetch(`/api/user/${id}`, { next: { revalidate: 60 } });
return res.json();
}
// Page 与 Layout 同时调用
// 实际只发 1 次 HTTP 请求
dedupe 仅在单次请求生命周期内有效(AsyncLocalStorage 隔离),不同请求互不干扰。
2.7 fetchMetric 收集
打补丁的 fetch 还会把每次调用记进 workStore.fetchMetrics:
function trackFetchMetric(
workStore: WorkStore,
ctx: Omit<FetchMetric, 'end' | 'idx'>
) {
if (!workStore.shouldTrackFetchMetrics) {
return
}
workStore.fetchMetrics ??= []
workStore.fetchMetrics.push({
...ctx,
end: performance.timeOrigin + performance.now(),
idx: workStore.nextFetchId || 0,
})
}
dev 模式 next dev 终端会打印每次 fetch 的耗时与 cache hit/miss,正是来自这里。生产里默认关闭。
生产排查提示:dev 跑得慢、Network 面板不直观时,看 dev server 终端的 fetch metric 日志,比浏览器端更准——它统计的是真正命中 server-side cache 的状态。
3. unstable_cache:函数级缓存
unstable_cache 是给"非 fetch"函数用的缓存包装。用例:
- 读数据库 ORM 查询。
- 走内部 SDK(不是 fetch)。
- 任意昂贵的纯计算。
3.1 签名
export function unstable_cache<T extends Callback>(
cb: T,
keyParts?: string[],
options: {
/**
* The revalidation interval in seconds.
*/
revalidate?: number | false
tags?: string[]
} = {}
): T {
3.2 fixedKey + invocationKey
// Stash the fixed part of the key at construction time. The invocation key will combine
// the fixed key with the arguments when actually called
// ...
const fixedKey = `${cb.toString()}-${
Array.isArray(keyParts) && keyParts.join(',')
}`
const cachedCb = async (...args: any[]) => {
const workStore = workAsyncStorage.getStore()
const workUnitStore = workUnitAsyncStorage.getStore()
// We must be able to find the incremental cache otherwise we throw
const maybeIncrementalCache:
| import('../../lib/incremental-cache').IncrementalCache
| undefined =
workStore?.incrementalCache || (globalThis as any).__incrementalCache
if (!maybeIncrementalCache) {
throw new Error(
`Invariant: incrementalCache missing in unstable_cache ${cb.toString()}`
)
}
const incrementalCache = maybeIncrementalCache
// ...
// Construct the complete cache key for this function invocation
const invocationKey = `${fixedKey}-${JSON.stringify(args)}`
const cacheKey = await incrementalCache.generateCacheKey(invocationKey)
精髓:
- fixedKey 在
unstable_cache(cb)调用时计算,包含cb.toString()+keyParts.join(',')。 - invocationKey 在每次调用时计算,加上
JSON.stringify(args)。 - 最终通过
incrementalCache.generateCacheKey加 hash 得到稳定 cacheKey。
注意 cb.toString() 的"潜规则"——修改函数体会自动失效缓存(key 变了)。这是非常方便的副作用:开发者改函数后无须手动清缓存。
3.3 keyParts 的必要场景
const getUser = unstable_cache(
async (id: string) => db.user.findUnique({ where: { id } }),
["getUser"], // 推荐:避免不同 unstable_cache 的 closure 撞键
{ revalidate: 60, tags: ["users"] },
);
强烈建议传 keyParts——cb.toString() 看似稳定,但 minify / SWC transform 可能改变字符串内容;传明确的 keyParts 能减少误判。
3.4 不能在请求作用域内嵌套 fetch revalidate
unstable_cache 的执行上下文是 unstable-cache 类型 store,不是 request。所以包在 unstable_cache 里的 fetch 会丢失"动态"上下文,比如读不到 cookies。这个限制是有意的——避免缓存数据泄漏每个用户的 cookie。
4. 'use cache' 指令:组件级缓存
Next.js 15 引入的新指令,需要 experimental.cacheComponents 开启。它的语义是 React 已实验的 'use cache' 指令——在 Next.js 中接入到 IncrementalCache 体系。
4.1 三种使用形态
// 形态 A:函数级
async function getProducts() {
"use cache";
const res = await fetch("/api/products");
return res.json();
}
// 形态 B:组件级
async function ProductList() {
"use cache";
const products = await db.product.findMany();
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
// 形态 C:文件级(顶部加指令)
("use cache");
export async function getProducts() {
/* ... */
}
4.2 cacheLife:定义缓存窗口
export type CacheLife = {
// How long the client can cache a value without checking with the server.
stale?: number
// How frequently you want the cache to refresh on the server.
// Stale values may be served while revalidating.
revalidate?: number
// In the worst case scenario, where you haven't had traffic in a while,
// how stale can a value be until you prefer deopting to dynamic.
// Must be longer than revalidate.
expire?: number
}
注释说得很清楚——它就是把 HTTP Cache-Control 三参数模型搬到代码里:
stale:客户端能用这个值多久而不回服务器。revalidate:服务器多久后台重新拉。expire:最坏情况下值还能用多久(必须大于 revalidate)。
4.3 cacheLife 内置 profile
type CacheLifeProfiles =
| 'default'
| 'seconds'
| 'minutes'
| 'hours'
| 'days'
| 'weeks'
| 'max'
| (string & {})
7 个内置档:default seconds minutes hours days weeks max。开发者可以在 next.config.js 自定义新 profile:
// next.config.js
module.exports = {
experimental: {
cacheLife: {
// 自定义 5 分钟 profile
"five-minutes": {
stale: 60,
revalidate: 300,
expire: 600,
},
},
},
};
async function getX() {
"use cache";
cacheLife("five-minutes");
// ...
}
4.4 cacheTag:标签收集
export function cacheTag(...tags: string[]): void {
if (!process.env.__NEXT_USE_CACHE) {
throw new Error(
'`cacheTag()` is only available with the `cacheComponents` config.'
)
}
const workUnitStore = workUnitAsyncStorage.getStore()
switch (workUnitStore?.type) {
case 'prerender':
case 'prerender-client':
case 'validation-client':
case 'prerender-runtime':
case 'prerender-ppr':
case 'prerender-legacy':
case 'request':
case 'unstable-cache':
case 'generate-static-params':
case undefined:
throw new Error(
'`cacheTag()` can only be called inside a "use cache" function.'
)
case 'cache':
case 'private-cache':
break
default:
workUnitStore satisfies never
}
const validTags = validateTags(tags, '`cacheTag()`')
if (!workUnitStore.tags) {
workUnitStore.tags = validTags
} else {
workUnitStore.tags.push(...validTags)
}
}
要点:
- 只能在
'cache'或'private-cache'workUnit 内调用——其它 store 都报错。 - 多次调用会累加到当前 cache scope 的 tags。
- 调用
revalidateTag('xxx')时会让所有标了xxx的 cache scope 全部失效。
4.5 cacheLife 与 cacheTag 的协同
if (profile.revalidate !== undefined) {
// Track the explicit revalidate time.
if (
workUnitStore.explicitRevalidate === undefined ||
workUnitStore.explicitRevalidate > profile.revalidate
) {
workUnitStore.explicitRevalidate = profile.revalidate
}
}
if (profile.expire !== undefined) {
// Track the explicit expire time.
if (
workUnitStore.explicitExpire === undefined ||
workUnitStore.explicitExpire > profile.expire
) {
workUnitStore.explicitExpire = profile.expire
}
}
if (profile.stale !== undefined) {
// Track the explicit stale time.
if (
workUnitStore.explicitStale === undefined ||
workUnitStore.explicitStale > profile.stale
) {
workUnitStore.explicitStale = profile.stale
}
}
注意 > 判断——取所有 cacheLife 中的最小值。这是因为缓存的 outer scope 持有 inner scope,inner 的 stale 必须不大于 outer。
5. CacheHandler:可插拔的缓存后端
packages/next/src/server/lib/cache-handlers/types.ts 定义了 5 个方法接口:
export interface CacheHandler {
/**
* Retrieve a cache entry for the given cache key, if available. Will return
* undefined if there's no valid entry, or if the given soft tags are stale.
*/
get(cacheKey: string, softTags: string[]): Promise<undefined | CacheEntry>
/**
* Store a cache entry for the given cache key. When this is called, the entry
* may still be pending, i.e. its value stream may still be written to. So it
* needs to be awaited first. If a `get` for the same cache key is called,
* before the pending entry is complete, the cache handler must wait for the
* `set` operation to finish, before returning the entry, instead of returning
* undefined.
*/
set(cacheKey: string, pendingEntry: Promise<CacheEntry>): Promise<void>
/**
* This function may be called periodically, but always before starting a new
* request. If applicable, it should communicate with the tags service to
* refresh the local tags manifest accordingly.
*/
refreshTags(): Promise<void>
/**
* This function is called for each set of soft tags that are relevant at the
* start of a request. The result is the maximum timestamp of a revalidate
* event for the tags. Returns `0` if none of the tags were ever revalidated.
* Returns `Infinity` if the soft tags are supposed to be passed into the
* `get` method instead to be checked for expiration.
*/
getExpiration(tags: string[]): Promise<Timestamp>
/**
* This function is called when tags are revalidated/expired. If applicable,
* it should update the tags manifest accordingly.
*/
updateTags(tags: string[], durations?: { expire?: number }): Promise<void>
}
5.1 CacheEntry 形态
export interface CacheEntry {
/**
* The ReadableStream can error and only have partial data so any cache
* handlers need to handle this case and decide to keep the partial cache
* around or not.
*/
value: ReadableStream<Uint8Array>
/**
* The tags configured for the entry excluding soft tags
*/
tags: string[]
/**
* This is for the client, not used to calculate cache entry expiration
* [duration in seconds]
*/
stale: number
/**
* When the cache entry was created [timestamp in milliseconds]
*/
timestamp: Timestamp
/**
* How long the entry is allowed to be used (should be longer than revalidate)
* [duration in seconds]
*/
expire: number
/**
* How long until the entry should be revalidated [duration in seconds]
*/
revalidate: number
}
注意 value 是 ReadableStream<Uint8Array> 而不是 string —— 因为 'use cache' 缓存的是 RSC payload 流,必须支持 partial / 错误中断。
5.2 默认 handler:内存 + 磁盘
cache-handlers/default.ts 的实现思路:
- 内存 LRU:常用 entry 存内存(按字节数限)。
- 磁盘 fallback:超出 LRU 的写到
.next/cache/...。 - tags manifest:JSON 文件维护 tag → revalidate timestamp 映射。
- 进程内单例(每个 worker 一份)。
5.3 自定义 handler:典型案例
业务里常见自定义场景:
- 多副本部署:
next start起多份,需要共享 cache → Redis handler。 - CDN 一体化:把 entry 直接发到 Cloudflare KV / Vercel Edge → 适配器 handler。
- 审计与脱敏:缓存写入前做敏感字段过滤。
模板代码:
// cache-handler.ts
import type {
CacheHandler,
CacheEntry,
} from "next/dist/server/lib/cache-handlers/types";
export default class RedisCacheHandler implements CacheHandler {
async get(key: string, softTags: string[]) {
// 从 Redis 读 entry,检查 softTags
return undefined;
}
async set(key: string, pendingEntry: Promise<CacheEntry>) {
const entry = await pendingEntry;
// 把 entry.value 流读完写到 Redis
}
async refreshTags() {
/* ... */
}
async getExpiration(tags: string[]) {
return 0;
}
async updateTags(tags: string[]) {
/* ... */
}
}
// next.config.js
module.exports = {
experimental: {
cacheHandler: require.resolve("./cache-handler.ts"),
},
};
生产排查提示:怀疑 cache handler 行为?在每个方法加
console.log把 cacheKey / tags 打出来;dev 模式下立刻能看。
6. revalidatePath / revalidateTag:失效入口
next/cache 暴露两个 API:
revalidatePath(path, type?):让某个 URL 路径下所有 cache 失效。revalidateTag(tag):让带某个 tag 的所有 cache 失效。
它们的实现都是在当前请求的 workStore 上记一个 "pending revalidate",请求结束时 flush 给 cacheHandler 的 updateTags。
6.1 业务案例:电商后台修改商品
// app/admin/products/[id]/edit/page.tsx
"use server";
export async function updateProduct(id: string, data: FormData) {
await db.product.update({ where: { id }, data: parse(data) });
// 失效 detail 与 list
revalidateTag(`product:${id}`);
revalidateTag("product:list");
// 也可以按路径
revalidatePath("/admin/products");
}
当下次 fetch('/api/products', { next: { tags: ['product:list'] } }) 被调用时,getExpiration(['product:list']) 返回的 timestamp 大于 entry timestamp,cache miss → 重新拉。
6.2 Soft tag vs Hard tag 区别
- hard tag:写入 entry 时随 entry 持久化(
CacheEntry.tags)。 - soft tag:在
get(cacheKey, softTags)时由调用方传入——典型如 layout 共享路径前缀。
soft tag 的好处:不必为每个 entry 重新写入即可让 layout 范围的失效生效。
7. 业务案例:商品列表 + 详情 + 后台编辑
// 1. 列表页:fetch + tag
async function getProducts() {
"use cache";
cacheLife("hours");
cacheTag("product:list");
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 3600, tags: ["product:list"] },
});
return res.json() as Promise<Product[]>;
}
// 2. 详情页:fetch + per-id tag
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 60, tags: [`product:${id}`, "product:list"] },
});
return res.json() as Promise<Product>;
}
// 3. ORM 调用:unstable_cache
const getProductCount = unstable_cache(
async () => db.product.count(),
["getProductCount"],
{ revalidate: 300, tags: ["product:list"] },
);
// 4. server action 修改商品
async function deleteProduct(id: string) {
"use server";
await db.product.delete({ where: { id } });
revalidateTag(`product:${id}`);
revalidateTag("product:list");
}
观察这套设计:
- 三层 API 共用同一个 tag 命名空间——
'product:list'同时被 fetch /'use cache'/ unstable_cache 持有。 - 一次
revalidateTag调用同时打掉这三个 entry。 - 默认 stale-while-revalidate:用户先看到老数据,后台静默更新——零等待。
8. 重难点
8.1 fetch 的 cache 字段与 next.revalidate 的优先级
如果同时给 cache: 'force-cache' 和 next: { revalidate: 60 },next.revalidate 优先(因为它更精确)。但建议只用一种避免歧义。
8.2 unstable_cache 不能调用 cookies()
unstable_cache 创建独立的 unstable-cache workUnit,与 request 隔离——无法读 cookies / headers。如果你需要"按用户缓存",应该把用户 ID 当 keyParts:
// 错的:cookies() 在 unstable_cache 里抛错
const getUserData = unstable_cache(async () => {
const c = cookies(); // throws
});
// 对的:用户 ID 当 keyParts
const getUserData = unstable_cache(
async (userId: string) => fetchUserData(userId),
["getUserData"],
{ revalidate: 60 },
);
8.3 'use cache' 与 cookies() 互斥
'use cache' 函数视为可缓存——内部不能调用 cookies() headers() searchParams,否则视为"动态 escape",编译期就会报错。要在 cache scope 中读 cookies,请改用 'use private cache'(仅 PPR 启用 cacheComponents 时)。
8.4 revalidate vs expire vs stale
记忆口诀:
revalidate:触发后台刷新。expire:到点必须丢弃。stale:客户端不用问服务器的舒适窗口。
expire >= revalidate 是硬性约束——前面已经看到代码强校验。
8.5 测试缓存行为的方法
- 用 dev mode 时,重启 server 会清空内存 cache(不会清磁盘)。
- 用 prod 模式:
pnpm build && pnpm start,此时缓存全部走磁盘 + LRU。 - 想强制清空:
rm -rf .next/cache。
9. 配套 fixture:动手观察三层缓存
fixtures/lecture-11/ 含 4 个示例:
app/example-1-fetch/— 不同next.revalidate取值的对比。app/example-2-unstable-cache/— 函数级缓存与 keyParts。app/example-3-use-cache/—'use cache'+ cacheLife + cacheTag(需开启 cacheComponents)。app/example-4-revalidate/— 触发 revalidatePath / revalidateTag 失效。
9.1 推荐实验
cd learning/nextjs-40-lectures/fixtures/lecture-11
pnpm install --ignore-workspace
pnpm dev
# 浏览器打开 http://localhost:3011/
实验 A:fetch revalidate 对比
- 进
/example-1,3 张卡片分别用revalidate: 0/60/false。 - 第一次访问都要拉 API;第二次访问只有
0重新拉。 - dev 终端打印 fetch metric 验证。
实验 B:unstable_cache 命中
/example-2计算斐波那契——首次慢,二次秒回。- 改
keyParts重新部署,验证缓存独立。
实验 C:'use cache' 与 cacheLife
/example-3显示一个商品列表('use cache'+cacheLife('seconds'))。- 服务端 console 打印 "regenerating list"——只有缓存过期时才出现。
- 配
next.config.js开experimental.cacheComponents: true。
实验 D:revalidate 触发失效
/example-4含 "Refresh products" 按钮,调用revalidateTag('product:list')。- 点击后再访问
/example-1或/example-3,看到列表 cache miss,重新生成。
10. 检验问题
- fetch 在 server 端被 patch 后,
init.next增加了哪些字段?分别约束什么? validateRevalidate的 3 个分支与INFINITE_CACHE的关系?unstable_cache的 fixedKey 与 invocationKey 各包含什么?为什么强烈建议传 keyParts?'use cache'与unstable_cache各自的"上下文限制"是什么?读 cookies 会发生什么?- CacheHandler 的 5 个方法各自承担什么职责?
get的softTags参数怎么理解? cacheLife中stalerevalidateexpire的语义?为什么expire >= revalidate?- 自定义 cacheHandler 的接入方式?最常见的业务场景是什么?
revalidatePath('/products')的失效粒度?与revalidateTag('product:list')哪个优先?- 为什么 unstable_cache 内嵌 fetch 也得显式传 keyParts?
- fixture 实验 D 中点击 Refresh 后,三张 example 哪些会立即 miss?为什么?
11. 延伸阅读
- 源码:
packages/next/src/server/lib/patch-fetch.ts - 源码:
packages/next/src/server/web/spec-extension/unstable-cache.ts - 源码:
packages/next/src/server/use-cache/{cache-life,cache-tag,use-cache-wrapper}.ts - 源码:
packages/next/src/server/lib/cache-handlers/{types,default}.ts - 文档:
docs/01-app/02-guides/incremental-static-regeneration.mdx - 配套 fixture:
fixtures/lecture-11/
下一讲预告
第 12 讲|Server Actions、Form 与 mutation:本讲讲数据"读"的缓存;下一讲讲数据"写"——Server Actions 的编译产物、encryptionKey、actionId 路由、useFormState / useActionState 流式状态。会展示一个真实的"商品 CRUD"案例。