第 4 章 · 数据获取与缓存:fetch 策略、四层缓存、revalidate、静态 vs 动态
本章主线一句话:Next 把"数据"和"页面"各包了一层缓存,再让它们联动失效——理解"哪一层缓存了什么、谁能让谁失效",就能解释一个页面到底是静态还是动态、数据多久刷新一次、改数据后为什么页面没变。
4.0 为什么需要这一章(动机)
第 1 章讲了"页面是静态还是动态",第 3 章讲了"服务端组件怎么直接 await 取数"。但有个问题一直没回答透:
服务端组件每次请求都 await fetch() 吗?那缓存还有什么意义?反过来,缓存了为什么数据又"不新鲜"?
答案藏在 Next 的四层缓存里。先记住一句话锚点:
Next 15 起
fetch默认不缓存——这是理解本章一切的前提。14 及以前的"隐式缓存"被取消了,现在你必须显式告诉 Next 什么时候缓存、什么时候刷新。
4.1 先建立一个"纵向视图":一次请求穿过哪几层缓存
一次服务端渲染请求,数据会经过下面四层(面试直接背这张表):
| 层 | 缓存什么 | 在哪 | 生命周期 | 作用 |
|---|---|---|---|---|
| 请求记忆化 Request Memoization | 同一个请求内相同 fetch 的返回值 | 服务器 | 单次请求 | 避免一次渲染里重复发请求 |
| 数据缓存 Data Cache | fetch / unstable_cache 的结果 | 服务器 | 跨请求、跨部署持久化 | 少打后端/数据库 |
| 完整路由缓存 Full Route Cache | 静态页的 HTML + RSC Payload | 服务器 | 跨请求,部署时清空 | 少做整页渲染 |
| 路由器缓存 Router Cache | 各段的 RSC Payload | 客户端内存 | 会话内 | 导航时少请求服务器 |
记忆锚点:前两层是"数据"的缓存,后两层是"页面/路由"的缓存。数据缓存失效会"级联"让完整路由缓存失效——因为页面渲染结果依赖数据。
4.2 第一层:请求记忆化(Request Memoization)
是什么
React 的特性(不是 Next 独有):同一个渲染 pass 里,URL 和选项完全相同的 fetch 只真正执行一次,结果在组件树里共享。
// 同一个请求里,Page 和 UserCard 各自 fetch 同一个 URL
// → 网络层只发一次请求,两个组件拿到同一份结果
async function Page() {
const user = await fetch("/api/user").then(r => r.json()); // 真正请求
return <UserCard />; // UserCard 里也 fetch 同一个 URL,命中记忆化,不再请求
}
关键边界(面试常考)
- 只对
GET有效:POST / DELETE 不被记忆化。 - 只在一个请求内生效:渲染完成后内存就重置,下一个请求重新来。它不跨请求共享(那是下一层数据缓存的事)。
- 只在 React 组件树里生效:
generateMetadata、generateStaticParams、layout、page、Server Component 里都算;但 Route Handler(route.ts)里的 fetch 不享受。
一句话:请求记忆化解决"同一次渲染重复请求"的问题,不解决"跨请求少打后端"的问题。
4.3 第二层:数据缓存(Data Cache)—— 本章核心
是什么
Next 内置的持久化数据缓存,把 fetch 的结果在服务端存下来,跨请求、跨部署复用,直到被重新验证或主动退出。
Next 15/16 的关键变化:fetch 默认不缓存
这是最容易答错、也最该主动讲出来的点:
| 版本 | fetch 默认行为 |
|---|---|
| Next 14 及以前 | 默认 force-cache(隐式缓存) |
| Next 15 起 | 默认不缓存,需显式声明 |
不加任何选项的 fetch,在 Next 15/16 里:
- 动态渲染时:每次请求都重新请求数据(返回新数据)
- 静态渲染时:数据进数据缓存、渲染结果进完整路由缓存
// 显式"加入缓存"(相当于老版本的默认行为)
fetch(url, { cache: "force-cache" })
// 显式"退出缓存"(每次请求都拿新数据)
fetch(url, { cache: "no-store" })
// 基于时间的重新验证:最多缓存 3600 秒,过期后下次请求后台刷新
fetch(url, { next: { revalidate: 3600 } })
// 打标签:配合 revalidateTag 按需失效
fetch(url, { next: { tags: ["posts"] } })
为什么这么改?(承接第 1 章 1.9 的伏笔)为了避免"服务端组件用了 fetch 却没拿到新数据"的隐式缓存陷阱,把"是否缓存"的决策显式化、交给开发者。
失效方式(面试重点)
- 基于时间的重新验证(Time-based):
next: { revalidate: N },SWR 思路——过期后首次请求先返回旧值、后台刷新。 - 按需重新验证(On-demand):
revalidateTag("posts"):按标签清掉一批数据缓存revalidatePath("/blog"):按路径清掉对应路由的数据缓存 + 完整路由缓存
注意:
revalidateTag/revalidatePath必须在 Server Action 或 Route Handler 里调用(服务端上下文),客户端不能直接调。
非 fetch 的数据怎么缓存:unstable_cache
不是所有数据都来自 fetch(比如直接查数据库、调 SDK)。这时用 unstable_cache 手动包一层:
import { unstable_cache } from "next/cache";
const getPosts = unstable_cache(
async () => db.post.findMany(),
["posts"], // 缓存 key
{ revalidate: 60, tags: ["posts"] } // 可选:时间 + 标签
);
4.4 第三层:完整路由缓存(Full Route Cache)
是什么
Next 在构建时把静态路由渲染成 HTML + RSC Payload 存下来。请求来时直接返回缓存,不再跑一遍组件。
关键点
- 只有静态渲染的路由会被缓存;动态渲染的路由每次请求才渲染,不缓存。
- 部署时被清空(重新构建 = 重新生成)。
- 退出方式(和"页面变动态"是同一件事,呼应第 1 章 1.6):
- 用了动态 API:
cookies()、headers()、searchParams等 fetch(..., { cache: "no-store" })- 段配置
export const dynamic = "force-dynamic"或export const revalidate = 0
- 用了动态 API:
与数据缓存的联动(本章最常考)
重新验证/退出 数据缓存 → 级联使 完整路由缓存 失效
退出 完整路由缓存 → 不影响 数据缓存(可以"动态渲染 + 用缓存数据")
记忆锚点:数据缓存失效会拖垮页面缓存;反过来页面不缓存,不代表数据不缓存。
4.5 第四层:路由器缓存(Router Cache,客户端)
是什么
客户端内存里存各路由段的 RSC Payload,按 layout / loading / page 分块。导航时优先命中,减少服务器往返。
Next 15 的关键变化:页面段默认不缓存
- layout、loading 段:导航时被缓存并复用。
- page 段:Next 15 起默认退出缓存(浏览器后退/前进时仍复用)。
- 自动失效:默认预取静态页 5 分钟、动态页不缓存;
prefetch={true}或router.prefetch则静态动态都 5 分钟。 - 页面刷新时清空。
谁能让它失效
- Server Action 里
revalidatePath/revalidateTag/cookies.set:同时清数据缓存 + 路由器缓存。 - 客户端
router.refresh():只清路由器缓存,不动数据缓存和完整路由缓存。
常见误区(面试加分点):
router.refresh()不等于revalidatePath。前者只在客户端重新拉一次当前路由的 RSC,不改数据缓存;后者才是真正让服务端数据/页面缓存失效。
4.6 一张"谁失效谁"总表(背下来)
| API | 数据缓存 | 完整路由缓存 | 路由器缓存 |
|---|---|---|---|
fetch(默认/GET) | 不缓存(15+) | — | — |
fetch(force-cache) | 缓存 | — | — |
fetch(next.revalidate) | 时间刷新 | 时间刷新 | — |
revalidateTag / revalidatePath | 清除 | 清除 | 清除(Server Action 内) |
router.refresh() | 不动 | 不动 | 清除 |
cookies(动态 API) | — | 退出(变动态) | — |
const dynamic = "force-dynamic" | — | 退出 | — |
const revalidate = N | 时间刷新 | 时间刷新 | — |
generateStaticParams | — | 缓存(SSG) | — |
4.7 静态 vs 动态:缓存视角重新理解
第 1 章从"渲染模型"讲了静态/动态,这里从"缓存"再收一次口:
- 静态渲染:构建时跑一次 → 数据进数据缓存、页面进完整路由缓存 → 之后请求直接命中缓存。
- 动态渲染:每次请求跑一次 → 不缓存(除非显式
force-cache)。
决定一个页面是静态还是动态的,本质是"它依赖的输入是否在构建时可确定":
- 依赖
cookies/searchParams/request→ 每个请求输入都不同 → 只能动态。 - 依赖
fetch且默认不缓存 → 数据可能变化 → 动态。 - 什么都不依赖(或依赖
generateStaticParams枚举好的值)→ 静态。
一句话收束:缓存是"静态 vs 动态"的底层机制,静态 vs 动态是缓存对外呈现的结果。
4.8 代码实验
位置:src/app/lab/data/,入口 /lab/data。数据源用共享的内联 async 函数 _data.ts 的 getRawData()(返回带时间戳 + 随机数,模拟一次 IO),配合 unstable_cache / revalidate / tags 观察缓存命中/失效。
| 实验 | 目录 | 验证目标 |
|---|---|---|
| 不缓存(默认) | fetch-default/(force-dynamic + 裸取数) | pnpm build 看标记为 ƒ,刷新时间变 |
| 数据缓存 | fetch-cache/(unstable_cache 无 revalidate) | 构建期 ○,时间戳冻结 |
| 时间重新验证 | revalidate-time/(unstable_cache + revalidate: 10) | 过期后首次请求后台刷新(SWR) |
| 标签失效 | revalidate-tag/(unstable_cache + tags + Server Action 里 revalidateTag) | 点按钮后数据立刻更新 |
| 非 fetch 缓存 | unstable-cache/(对比裸函数 vs unstable_cache) | 跨请求复用、按标签失效 |
| 静态 vs 动态 | static-vs-dynamic/(force-dynamic,同数据对比裸取数与缓存取数) | 页面 ƒ;刷新时裸取数变、缓存取数不变 |
说明:为何不用
fetch指向/api/...演示缓存选项?因为服务端组件里的fetch会在构建期预渲染时执行,而 build 阶段 localhost 上还没有服务,会ECONNREFUSED导致构建失败。改用内联getRawData()+unstable_cache更稳定,且unstable_cache是 Next 15+ 官方推荐的"非 fetch 数据缓存"方式,语义与fetch的cache选项等价。
观察方法(沿用前三章):
pnpm dev:所有页面每次请求都重新渲染,看不到缓存效果(属正常)。pnpm build && pnpm start:- build 日志看每个路由
○(静态)/ƒ(动态)标记。 .next/server/app/.../*.html存在静态 HTML = 该页被静态化。- 在
fetch-cache页刷新,时间戳不变 → 命中完整路由缓存;改数据源后再刷新仍不变 → 数据缓存未失效。 - 在
revalidate-tag页点按钮触发revalidateTag,立刻看到新数据 → 数据缓存 + 路由器缓存被清。
- build 日志看每个路由
注:
revalidate-tag里用到的revalidateTag在 Next 16 起需要第二个参数profile(缓存生命周期档位,如"default"),调用形如revalidateTag("tag", "default")。实验代码已按新签名实现。
4.9 面试自测题
<details> <summary>1. Next 15 起 fetch 的默认缓存行为是什么?为什么这么改?</summary>默认不缓存。14 及以前 fetch 默认 force-cache,导致"服务端组件用了 fetch 却拿到旧数据"的隐式缓存陷阱;15 起取消隐式缓存,把是否缓存的决策显式化,让开发者用 cache 选项或 next.revalidate 自己声明。
</details> <details> <summary>2. Next 有哪四层缓存?分别缓存什么?</summary>请求记忆化(单次请求内相同 fetch 的返回值,服务器内存)、数据缓存(fetch/unstable_cache 结果,跨请求持久化)、完整路由缓存(静态页的 HTML+RSC Payload,部署时清空)、路由器缓存(各段 RSC Payload,客户端内存)。前两层管"数据",后两层管"页面/路由"。
</details> <details> <summary>3. 请求记忆化和数据缓存有什么区别?</summary>请求记忆化只在单次请求内生效,解决"同一次渲染重复发请求",渲染完就重置,不跨请求;数据缓存跨请求、跨部署持久化,解决"跨请求少打后端",需要显式缓存并配合重新验证。
</details> <details> <summary>4. 怎么让一个 fetch 结果缓存?怎么让它永远不缓存?</summary>缓存:fetch(url, { cache: "force-cache" });不缓存:fetch(url, { cache: "no-store" })。也可以用 next: { revalidate: N } 做时间缓存、next: { tags: [...] } 打标签配合 revalidateTag 按需失效。
</details> <details> <summary>5. revalidateTag 和 revalidatePath 的区别?在哪里调用?</summary>revalidateTag 按缓存标签清一批数据缓存;revalidatePath 按路径清对应路由的数据缓存+完整路由缓存。都必须在服务端上下文(Server Action 或 Route Handler)调用,客户端不能直接调。
</details> <details> <summary>6. 数据缓存失效会影响完整路由缓存吗?反过来呢?</summary>会。数据缓存重新验证/退出会级联使完整路由缓存失效(页面渲染依赖数据)。反过来退出完整路由缓存不影响数据缓存——可以"动态渲染 + 用缓存数据"。
</details> <details> <summary>7. 完整路由缓存什么时候被清空?</summary>重新部署(重新构建)时清空;或数据缓存被重新验证导致级联失效;或用动态 API、fetch no-store、force-dynamic、revalidate=0 退出缓存(此时路由变动态,不再进完整路由缓存)。
</details> <details> <summary>8. 路由器缓存和完整路由缓存有什么本质区别?</summary>位置不同:路由器缓存在客户端内存,完整路由缓存在服务器。内容不同:路由器缓存各段 RSC Payload(按 layout/loading/page 分块),完整路由缓存整页 HTML+RSC。失效方式不同:路由器缓存页面刷新即清、可被 router.refresh 清,完整路由缓存部署才清。
</details> <details> <summary>9. router.refresh() 和 revalidatePath 有什么不同?</summary>router.refresh 是客户端 API,只清路由器缓存、重新拉一次当前路由 RSC,不动数据缓存和完整路由缓存;revalidatePath 是服务端 API,真正让数据缓存+完整路由缓存失效。前者是"刷新视图",后者是"刷新数据与页面"。
</details> <details> <summary>10. 非 fetch 的数据(如直接查数据库)怎么缓存?</summary>用 next/cache 的 unstable_cache 手动包一层,传入缓存 key,可选 revalidate(时间)和 tags(标签),之后可配合 revalidateTag 按需失效。
</details> <details> <summary>11. 从缓存视角,"静态 vs 动态"的本质是什么?</summary>本质是页面依赖的输入在构建时能否确定。不依赖动态 API、且 fetch 可静态化的页面构建时跑一次进缓存(静态);依赖 cookies/searchParams/request 或默认不缓存 fetch 的页面每个请求输入不同,只能每次渲染(动态)。缓存是底层机制,静态/动态是它对外呈现的结果。
</details> <details> <summary>12. 一个页面用了 fetch 且没写任何缓存选项,它是静态还是动态?</summary>动态(Next 15+)。因为 fetch 默认不缓存,数据每次请求都可能变,Next 无法在构建时确定输出,路由会被标记为 ƒ。要静态需显式 force-cache 或 next: { revalidate: N }(配合静态化条件)。
</details>4.10 常见追问(加分项)
- "为什么数据缓存失效会级联清完整路由缓存?":完整路由缓存存的是"用某份数据渲染出的 HTML",数据变了、渲染结果就必然过期,两者强耦合,所以数据缓存失效必须连带页面缓存失效。
- "Request Memoization 和 React.cache 是一回事吗?":机制相近,都是"同一渲染内复用"。React.cache 是 React 的通用函数记忆化,能包任意函数(不止 fetch);请求记忆化是 React 对 fetch 的内建优化。两者都只在单次请求内生效。
- "Route Handler 里 revalidateTag 能让客户端立刻看到新数据吗?":能清数据缓存,但不会立即使路由器缓存失效(Route Handler 不绑定特定路由),所以客户端要
router.refresh()才能立刻看到;Server Action 里调用则同时清路由器缓存。 - "Next 15 页面段默认退出路由器缓存,意味着什么?":意味着页面切换回来时会重新向服务器请求该页 RSC(layout/loading 仍被复用),减少"返回旧页面数据"的陈旧感,代价是导航可能多一点请求;需要缓存可用实验性 staleTimes 配置。
- "ISR 和这一章的数据缓存是什么关系?":ISR(第 1 章)依赖两层缓存叠加——数据缓存(fetch 的 revalidate)+ 完整路由缓存(页面的 revalidate)。Next 15+ fetch 默认不缓存后,ISR 需要显式给 fetch 加 next.revalidate 或段配置 revalidate,否则页面会被拖成纯动态。
下一章:第 5 章 Server Actions ——
'use server'原理、与 API Route 的区别、渐进增强。本章的revalidateTag/revalidatePath正是 Server Action 里最常见的落地场景,两章强衔接。
