KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · fetch 的四种写法:结果存在哪、存多久、失效时先给新值还是旧值 — keel 龙骨
这一章回答:同样是 fetch 一个外部地址,四种写法分别把结果存在哪里、存多久、什么时候失效——以及最关键的一点:失效后的第一次请求,你拿到的是新值还是旧值。
这一章回答:同样是 fetch 一个外部地址,四种写法分别把结果存在哪里、存多久、什么时候失效——以及最关键的一点:失效后的第一次请求,你拿到的是新值还是旧值。
现场:页面明明每次都在重渲染,源却只被打了一次
先摆一个会让你立刻停下来的现象。/fetch-force 这一页,顶上写着显式声明,页面每次请求都在服务端重新渲染,绝无静态回放:
const SRC = "http://127.0.0.1:3999/tick";
export const dynamic = "force-dynamic";
export default async function Page() {
const res = await fetch(SRC, { cache: "force-cache" });
const tick = await res.json();
return <b data-probe="hits">{tick.hits}</b>;
}
按直觉推:页面每次请求都重渲染,那每一轮渲染里的 fetch 也该每次都去打源吧? 既然每次都打源,mock 源上的 hits 就该一直往上跳。请求两次,实测却长这样:
第1次请求:页面 hits = 5 本路由区间内源命中: 4 → 5 (打了一次)
第2次请求:页面 hits = 5 本路由区间内源命中: 5 → 5 (+0,没打)
两次请求,源只被打了一次。页面在重新渲染,fetch 却没再回源,第二次渲染拿到的还是存住的那个 5。
先别往下翻,给出你的预测: 既然"页面在重渲染"和"fetch 打了源"是两件事,那这两件事中间夹着的,是哪一层?换句话说——"页面"和"fetch 的结果",是不是被存在两个不同的地方? 押"是"还是"不是",决定了你后面能不能读懂 revalidate 到底在做什么。
这个实验台还是那一套:Next.js 16.3.8(Turbopack 默认)、react / react-dom 19.3.0、Node 22.22.2,Windows 本机。计数源是一个独立进程,监听 127.0.0.1:3999,每被打到一次就 hits += 1 并把时刻记进 /log。页面里显示的 hits(从 mock 读回来的值)和 mock 的真实 hits(源上发生过几次命中)这两个数,是这一章全部的判据。
这一章就顺着"页面在重渲染、fetch 却没回源"这条线,把四种写法逐一拆开:结果存在哪一层、存多久、失效时给你的到底是新值还是旧值。
一、四种写法摆一起
四条路由,只有 fetch 的选项不同:
await fetch(SRC); // 不带选项
await fetch(SRC, { cache: "no-store" }); // 每次都要新的
await fetch(SRC, { cache: "force-cache" }); // 尽量复用
await fetch(SRC, { next: { revalidate: 30, tags: ["tick"] } }); // 30 秒窗口 + 一个标签
把它们摆进同一张表,先看两张"外部读数"——构建表里的符号,和两次请求的源命中:
| 写法 | 页面构建表里的符号 | 两次请求:页面显示 hits | 两次请求:源命中区间 |
|---|---|---|---|
fetch(SRC)(不带选项) |
○ Static |
2 → 2 | +0 |
cache: "no-store" |
ƒ Dynamic |
3 → 4 | +2(每次请求都打源) |
cache: "force-cache" |
ƒ Dynamic |
5 → 5 | +1(只第一次打源) |
next: { revalidate: 30, tags: ["tick"] } |
ƒ Dynamic |
见第三节时间线 | 窗口内 +0,到期后 +1 |
配上一列响应头,四种写法的落点立刻分明:
| 写法 | x-nextjs-cache |
cache-control |
|---|---|---|
fetch(SRC)(不带选项) |
HIT |
s-maxage=31536000 |
cache: "no-store" |
(无) | private, no-cache, no-store, max-age=0, must-revalidate |
cache: "force-cache" |
(无) | 同上 |
next: { revalidate: 30, … } |
(无) | 同上 |
这张表要两列一起读。 只看第二列(页面 hits)你会以为 force-cache 那一页"缓存住了",只看第一列(构建符号)你会以为它"和 no-store 一样是动态页"。两列合起来才说清:页面判定、页面级缓存、fetch 级缓存,是三件可以被分开设置的事。
先记住两条反差最大的:
no-store每次都打源(区间+2,页面 hits3 → 4在往上爬),因为它就是要"每次都要新的";force-cache只第一次打源(区间+1,页面 hits5 → 5卡住不动),因为结果被存住了。
而不带选项那一页最特别:它的构建符号是 ○ Static——不是"fetch 被缓存了",而是整个页面在构建期就定稿了。上一章已经量过:构建期真的替它打了一次源(mock hits 从 1 涨到 2,时间戳 2026-10-06T12:22:12.676Z 落在 Generating static pages ... 11/11 窗口里),之后所有请求回放构建产物,fetch 一次都不再发生(区间 +0,页面 hits 恒为 2)。这一条和另外三条不是同一个量级的事,它是"整页级"的;另外三条都是"页面动态、但 fetch 的结果各有去处"。
还要趁早说清一个判据的适用范围:/search 和 /cookies-read 这两页也是 ƒ,但它们的源命中区间是 +0——因为它们根本不 fetch,没有源可打。 所以"区间命中数"只对"页面上有 fetch"的路由有意义;对不 fetch 的动态页,+0 只是"没什么可缓存",不代表它被缓存了。别把"来源命中没涨"错当成"这页是静态的"。
二、"页面级缓存"和"fetch 级缓存"是两个独立的层
现在回到开场那个现象,把它拆到底。
/fetch-force 顶上写着 export const dynamic = "force-dynamic"。上一章讲过,这行的作用面是整页判定——它让这一页每次请求都重新渲染。所以页面级这一层,是关掉的:没有 x-nextjs-cache: HIT,cache-control 是 no-store 那一串,服务端每次都在跑你的组件函数。
但 fetch 那一层,是开着的:cache: "force-cache" 让这次 fetch 的结果被存住、并被下一次渲染复用。证据就是第二次请求——页面重新渲染了,源命中却是 +0,页面显示的还是 5。
这两层是独立设置的,互不派生。 页面是不是静态,取决于判定(碰没碰请求);fetch 的结果存不存、存多久,取决于你给 fetch 的选项。你可以:
- 页面静态 +
fetch结果被构建期取一次(不带选项,整页定稿); - 页面动态 +
fetch结果被存住(force-cache,本页就是); - 页面动态 +
fetch每次回源(no-store); - 页面动态 +
fetch结果按窗口过期(revalidate)。
四种组合都能搭出来,因为它们本来就分属两层。这就是开场那个问题的答案:页面在重渲染,和 fetch 打没打源,不是同一件事——页面级缓存和 fetch 级缓存是两个独立的层。
把这条落实到排查上:下次你看到"我明明设了动态,怎么数据不更新",第一刀要切在两层的哪一层上。是页面没重渲染(页面级缓存命中,想看响应头有没有 x-nextjs-cache: HIT),还是页面重渲染了但 fetch 拿了旧值(fetch 级结果被复用了)?这两者的修法完全不同,混起来会白排查半天。
三、revalidate: 30 的真实语义:先给旧值,后台去取新值
第四种写法最值得单独一节,因为它的语义和大多数人的直觉相反。
/fetch-revalidate 用的是 fetch(SRC, { next: { revalidate: 30, tags: ["tick"] } }),页面顶上同样 force-dynamic。意思是:"这次 fetch 的结果,允许存活 30 秒;30 秒之内的请求,复用这份结果。"
问题就出在"30 秒到了之后"。很多人的直觉是"到期了,那下一次请求会阻塞一下、去源上取个新的,然后返回新的"——也就是把过期当成一次会等一下的前台刷新。实际不是。实验每 4 秒采样一次,把"页面里的 hits"和"mock 源的真实 hits"并排记下来:
t 页面里的 hits 源的真实 hits 两者是否一致
43ms 6 7 不一致(页面旧值 6,源已到 7)
4054ms 7 7 一致
8078ms 7 7 一致
12102ms 7 7 一致
16123ms 7 7 一致
20140ms 7 7 一致
24165ms 7 7 一致
28175ms 7 7 一致
32193ms 7 8 不一致(页面旧值 7,源已到 8)
36218ms 8 8 一致
40242ms 8 8 一致
盯着两处"不一致"看,那是两个过期边界:
28175ms时页面和源都是 7——还在窗口内,一路命中缓存,源不动;- 到
32193ms,页面给的是 7,而源已经被打到 8 了。这一次请求返回的是旧值 7,同时它在后台打了源(源从 7 涨到 8)——只是这一轮不等源,直接把旧的 7 发给你了; - 下一次请求
36218ms,页面终于变成 8——新值要等到"再下一次"才拿到。
第一处不一致(43ms 的 6 vs 7)是同一现象在更早一个边界上的重演:页面拿的还是上一个窗口的旧值。
所以 revalidate: N 的真实语义是 stale-while-revalidate——过了窗口之后,第一次请求返回旧值(stale),同时后台去取新值(revalidate),下一次请求才拿到新的。
用户看到的是什么? 不是报错、不是转圈等待,是旧数据。 页面上没有任何信号告诉你"你正在看一份旧的"——那 30 秒到期后你刷新一下,拿到的和刚才一模一样,只有再刷一次才会变。这几乎总是把一个可复现的"更新时间"问题变成一个偶发问题:单看你刷的那一下,你会以为缓存没失效;连刷两下,你才看到新值。
这一节把它记成一句判据:revalidate: N 不阻塞、不报错,它把一次请求的延迟藏进了"下一次",代价是你可能先看到一份旧值。
四、revalidateTag("tick") 的语义不同:失效后第一次就给新值
revalidate 那条路的"先给旧值"很容易被推广成一条错误结论——"手动失效也是先给旧值吧"。实测不是。
/fetch-revalidate 的那次 fetch 除了 revalidate: 30,还挂了 tags: ["tick"]。实验台有一个手动入口 /api/revalidate?tag=tick,它调的是 revalidateTag("tick")。同一次实验的后半段,在时间线采完之后手动触发一次:
revalidate 返回: {"ok":true,"done":["revalidateTag(tick)"],"at":"2026-10-06T12:24:15.036Z"}
失效后第一次: 页面 hits = 9 | 源 hits = 9 (源采样前值 8)
失效后第二次: 页面 hits = 9
看清楚这里发生了什么:revalidateTag("tick") 之后,第一次请求就拿到了新值——源从 8 涨到 9,页面上写的也是 9。没有"先给旧值"这一步。
把两种失效路径并排:
| 失效方式 | 触发 | 第一次请求返回 | 源 |
|---|---|---|---|
窗口过期(revalidate: 30) |
时间到了 | 旧值(7) |
后台同时打源(7 → 8) |
手动 revalidateTag("tick") |
有人调接口 | 新值(9) |
这次请求就打源(8 → 9) |
同样是"让缓存失效",观感完全不同。 过期走的是"先给旧的、后台再补"(SWR);revalidateTag 走的是"下一次请求直接重建、当场给新的"。
这条差别有一个很实际的用法:如果你要的是"内容一改,用户下一次看到的就是新的",用 tag 失效;如果你只是设了个时间窗口让它自己轮换,就得接受用户先看到一次旧值。 把两者当同一回事,就会出现"我明明手动失效了,用户还说看到旧的"——多数时候那个人看的是没失效的那一份(比如另一层缓存,或者根本没有 tags 声明的那个 fetch)。
顺带一句边界,正文不替它下结论:revalidateTag 之所以能命中,是因为那个 fetch 声明了 tags: ["tick"]——标签是一对一对上的。没有这对声明,调接口不会影响它。(本实验台还有 revalidatePath 的入口,但本课只取证了 revalidateTag,revalidatePath 的实际行为没量,见 ## 生产边界。)
五、no-store 会把整页拖成动态
回到四种写法里的第二种,它在两章之间反复出现,因为它能同时说明两件事。
/fetch-nostore 只改了一个变量——fetch 的选项:
const res = await fetch(SRC, { cache: "no-store" });
它没有 force-dynamic,页面里也没读 cookie / 查询串,按理说"其他什么都不碰"。但它在构建表里是 ƒ Dynamic,运行时每次请求都打源(两次请求区间 +2,页面 hits 3 → 4 在往上走)。
这就是上一章第四条结论的"另一半":fetch 级的 no-store 不只是让这一次 fetch 不缓存,它会把整页拖成动态。 机制上是连着的——既然"每次渲染都要去源上拿一份不缓存的",这次渲染的结果当然就不能在构建期定稿,判定只能给 ƒ。
把它和 force-cache 那一页对着看,五种写法的差异就收盘了:
页面里那一次 fetch |
页面判定 | 页面每次请求都重渲染吗 | fetch 每次请求都回源吗 |
|---|---|---|---|
| 不带选项 | ○ Static |
不(回放构建产物) | 不(构建期跑过一次) |
cache: "no-store" |
ƒ Dynamic |
是 | 是(区间 +2) |
cache: "force-cache" |
ƒ Dynamic |
是 | 不(区间 +1) |
next: { revalidate: 30, … } |
ƒ Dynamic |
是 | 窗口内不、到期后一次一次补 |
最后两列是本章最想让你带走的东西。 "页面重渲染"和"fetch 回源"是两个可以被独立决定的开关:force-cache 那一行就是"页面每次重渲染、fetch 不回源";no-store 那一行是"两个都发生"。谁在管哪一列,你按第二节的"两个层"去对,就清楚了。
六、hits 这个判据本身:为什么需要一个独立的计数源
这一整章的证据都压在一个数字上:mock 源的 hits。有必要把这个判据是怎么造出来的讲清楚,否则你在自己项目里复现不出来。
要回答的问题是"这次渲染到底有没有打源"。 最自然的想法是:在 Next 自己的 app/ 里加一条 API route 当计数器,比如 app/api/tick/route.js,它维护一个计数,页面 fetch 它。问题是——
next build 时,Next 自己的服务器还没起。 而构建期要预渲染页面(上一章量过:不带选项的页面真的会在构建期跑 fetch)。这时页面里的 fetch 去请求 app/api/tick,那个 endpoint 还不存在——于是构建直接报错。
所以这套实验用的计数源,必须是一个先于 next build 启动、独立于 Next 进程的进程。这就是那个 127.0.0.1:3999 的 mock 源:它自己是一个独立的 node http server,/tick 自增并记时刻、/log 吐出全部记录,在跑 next build 之前就起来。这样构建期页面里的 fetch 能落到它身上,hits 才会在构建窗口里从 1 涨到 2。
这条设计里有两个判据要分清楚,它们各自回答不同的问题:
- 页面里显示的
hits(从 mock 读回来的那个值)回答的是:"这一份输出依赖的源数据,是哪个版本?"——它是构建期冻的、还是刚取的、还是复用存下来的。 - mock 源的真实
hits(/log里的计数)回答的是:"源到底被打了几次、什么时候?"——它是判断"有没有真的回源"的唯一外部事实。
两个数之间的关系,才是判据。 页面 hits 不动 + 源 hits 不涨 → 结果被存住并复用了(force-cache);页面 hits 往上爬 + 源 hits 也在涨 → 每次都回源(no-store);页面 hits 是旧值 + 源 hits 已经涨了 → 后台刷新(revalidate 过期那一刻,第三节里 7 vs 8 那一行);页面 hits 是旧值 + 源 hits 也还是旧的 → 纯粹的窗口内命中。
所以一个"独立于被测进程的计数源"不是讲究,是这类实验的硬前提。 你要在自己项目里量同类问题,也得把计数从"被缓存的那个进程里"挪出去——挪到一个每次都会被真的打到的地方。用 Next 自己进程内的计数器来量 Next 的缓存,是量不出来的:你会把"缓存命中"和"计数服务本身也被缓存了"两份不确定混在一起。
本章脉络
flowchart TD
A["fetch(url, 选项)"] --> B{"选项是哪一种?"}
B -->|"不带选项"| C["页面判 ○ Static<br/>构建期打源一次<br/>之后回放构建产物"]
B -->|"cache: no-store"| D["页面判 ƒ<br/>每次请求都打源"]
B -->|"cache: force-cache"| E["页面仍是 ƒ<br/>但 fetch 结果被存住<br/>不停回源"]
B -->|"next: revalidate 30 tags tick"| F["页面仍是 ƒ<br/>结果存 30 秒窗口"]
F --> G{"窗口怎么结束?"}
G -->|"时间到了"| H["第一次请求给旧值<br/>后台同时打源<br/>下一次才给新值"]
G -->|"手动 revalidateTag"| I["第一次请求就打源<br/>当场给新值"]
C --> J["页面级缓存"]
D --> K["fetch 级缓存"]
E --> K
F --> K
J --> L["两个层独立设置<br/>页面判定 与 fetch 存活 互不派生"]
K --> L
图里在说什么。 顶上那个菱形是四种写法唯一的分岔点——同一次 fetch,只换选项。 左边一支(不带选项)走到的是页面级:整页在构建期定稿、构建时打源一次、之后回放,这是上一章那条线。右边三支都落到 fetch 级:页面照旧是 ƒ,但结果各有去处——no-store 每次都打源、force-cache 存住不回源、revalidate 存一个 30 秒窗口。窗口这一支还要再分一次岔,也是本章最反直觉的一处:时间到了走"先给旧值、后台补新"(SWR),手动 revalidateTag 走"第一次就给新值"。 底下两个方框合成最后一句——页面级缓存和 fetch 级缓存是两个独立层,互不派生;所以"页面在重渲染"和"fetch 回没回源"可以同时出现、也可以各自单独出现。整张图的关键是:所有分岔都在构建期或缓存配置里定好,请求时只是照做。
生产边界
- 本课只量了自托管
next start。 所有读数(x-nextjs-cache: HIT、s-maxage=31536000、no-store那一串、两次请求的命中区间)都来自本机 3000 端口直连。把这份产物放到 CDN 或平台缓存后面,revalidate窗口怎么和边缘缓存叠加、静态页会不会在边缘先被拦一道——本课一个都没量,不能拿这里的头去推断 CDN 行为。 - 没有量多实例部署。 实验台始终是单进程。
revalidate的窗口、force-cache的条目在多副本下是否各实例独立、revalidateTag会不会跨实例广播——本课没有对照组,不下结论。 - 没有量
use cache指令。 它和本章这四种写法是不是同一层、能不能叠加,本实验台里根本没有验证过它,所以正文一个字都不写——不许猜。 revalidatePath没取证。 实验台有/api/revalidate?path=…这个入口,代码也在,但本课只真跑了revalidateTag的那条时间线。revalidatePath的具体行为(失效范围、给新值还是旧值)本课没量。fetch级缓存的落盘位置没取证。 本课能证明的是"结果被存住并被下一次渲染复用"(源命中区间+1而不是+2),但它具体存在磁盘哪个文件、用的什么键,本课没有去翻。页面级缓存能看到产物里的痕迹(静态页被服务过一次之后,server/route-cache/APP_PAGE/<hash>/$/x.html会出现),fetch级没有做同样的取证。force-cache的失效路径没量全。 本课只量到"第二次请求不回源"这一个事实。除了重新构建整站,force-cache的条目还有没有别的过期或失效路径,本课没量。- 没有量源站返回的
Cache-Control对fetch缓存的影响。 本课的 mock 源只吐content-type: application/json,没有设任何Cache-Control/ETag。真实后端带上这些头之后会不会改变这里的判定,本课无数据。 - 时间分辨率是 4 秒。 第三节时间线每 4 秒采一次,所以"窗口到期的那一刻"只能定位到一个 4 秒区间内(
28175ms到32193ms之间),到期瞬间的精确时刻没量。 - 记的是 Next.js 16.3.8 的默认值。 本章的形状(默认不带动、
no-store拖动态、SWR 先给旧值、tag 失效直接给新值)可以带走,但"哪些写法在哪个版本落到哪一支",请在你自己的版本上重跑一遍复核。
动手:可观察结果
下面五件事各做一遍。每一步的答案都必须来自命令输出,不能来自记忆。
- 先起计数源,再构建。 把那个独立的 mock server(
127.0.0.1:3999)在next build之前启动。构建完之后请求一次http://127.0.0.1:3999/log,看hits是不是已经大于 0。如果是——构建期真的打过源;如果不是——检查你的计数源是不是起晚了。 - 量一遍"页面重渲染、
fetch不回源"。 请求/fetch-force两次,每次记下页面显示的hits,中间去/log读源计数。第二次的页面 hits 变了吗?源涨了吗?把这两个答案填进第二节的结论里。 - 把
no-store和force-cache并排跑。 同样请求两次,一次打/fetch-nostore、一次打/fetch-force,各自记"页面 hits 变了没"和"源命中区间"。两张读数应该一个在爬、一个卡着不动。 - 亲眼看一次 SWR。 跑采样脚本量
/fetch-revalidate(每 4 秒一次,连采 11 次),把"页面 hits"和"源 hits"两列并排打印。找到那两行"不一致"——那里就是过期边界,页面给的是旧值,源已经涨了。然后立刻再请求一次,看它是不是变成新值了。 - 手动失效一次。 先让窗口自然过期、看到"先给旧值"那一幕;再调一次
http://127.0.0.1:3000/api/revalidate?tag=tick,然后第一次请求/fetch-revalidate。这次拿到的是旧值还是新值?和第四步对比,把两种失效的差别写下来。
完成标志:你能对任意一个"数据不更新"的现象,先问一句"卡在页面级还是 fetch 级",然后用"页面 hits + 源 hits + 响应头"三样读数把它定位到具体的某一层、某一种写法,而不是下意识去加缓存清除代码。
故障注入
五种注入,每一种都有明确的观察点。先写下预测,再跑。
| 注入 | 怎么做 | 观察什么 | 期望行为 |
|---|---|---|---|
| 把窗口缩到几秒 | 把 /fetch-revalidate 的 revalidate: 30 改成 revalidate: 3,重跑采样 |
两列"不一致"出现的间隔 | 形态不变、只是频率变高——仍是"到期后第一次给旧值、下一次给新值",别指望改小窗口能拿到"到期即新值" |
| 拆掉 tag 声明 | 把那个 fetch 的 tags: ["tick"] 删掉,保留 revalidate: 30 |
调 /api/revalidate?tag=tick 之后第一次请求的页面 hits |
页面 hits 不再被这次调用影响——标签失效要成对:fetch 上声明了 tag,调用才命中;没声明就落空 |
把 force-cache 换成 no-store |
把 /fetch-force 的 cache: "force-cache" 改成 cache: "no-store" |
两次请求的源命中区间 | 从 +1(只第一次打源)变成 +2(每次请求都打源)——只动这一个词,fetch 层的命运就翻了面 |
| 拿掉顶上的显式声明 | 把 /fetch-force 顶上的 export const dynamic = "force-dynamic" 删掉 |
构建表里这一行的符号 | 变成 ○ Static——页面这一层改由构建期定稿,不再每次重渲染(fetch 层怎么走,取决于它自己的选项) |
| 构建时把计数源关掉 | 不启动 mock,直接 next build |
构建能不能过 | 构建失败——因为不带选项的页面会在构建期真的 fetch 一次,源不在就报错。这一条反过来证明了第六节的前提 |
第二种注入最值得停下来想一会儿。它证明的不是"tag 失效失效了",而是"tag 是一对一对上的"——你 fetch 上不声明 tags: ["tick"],revalidateTag("tick") 就跟你这次 fetch 没有关系。很多"我手动清除缓存了但它没清掉"的现场,根源就在这里:清除调用和被清对象之间那条标签线断了。
自测题
/fetch-force页面顶上写着force-dynamic,页面每次请求都重渲染,但第二次请求源命中是+0。请说清"页面重渲染"和"fetch回源"分别由哪一层决定,以及这一页为什么两者可以不同时发生。- 把四种写法(不带选项 /
no-store/force-cache/revalidate)按"页面判定"分成两组,再按"fetch是否每次回源"分成两组。这两次分组的结果一样吗?如果不一样,说明什么? revalidate: 30到期后的第一次请求,返回的是新值还是旧值?请引用时间线里那行32193ms 7 8 不一致说明:为什么这一行能同时证明"给了旧值"和"后台打了源"。revalidateTag("tick")之后第一次请求的行为,和revalidate: 30到期后第一次请求的行为,差在哪一步?这个差别对"内容一改就要用户立刻看到新的"这个需求意味着选哪个?- 为什么计数器不能写成 Next 自己
app/里的一条 API route?请说清它在构建期会遇到的具体问题,以及这说明了计数源必须满足什么条件。 /search是ƒ Dynamic,但它的源命中区间是+0。这个+0能不能说明"它是静态的"?为什么"源命中数"这个判据有它的适用范围?- 有人报告"我设了
revalidate: 30,用户说看到旧数据,是不是缓存没生效?"请按本章的语义解释:用户看到的确实是旧数据,但缓存正在按你设的规则工作;再说明要"改了立刻看到新的"应该换用哪种手段。 - 你手上有两页:一页"页面每次重渲染、
fetch也每次回源",一页"页面每次重渲染、fetch完全不复用"。请各给出一种能落到这两个行为的fetch选项组合,并说出你会用什么两个读数来验证你的选择是对的。
现在能解释什么
- 为什么"页面明明在重渲染,源却只被打了一次"——页面级缓存和
fetch级缓存是两个独立的层,force-dynamic关的是前者,force-cache开的是后者; - 为什么"我设了
revalidate,用户还是看到旧数据"——revalidate: N是 stale-while-revalidate:到期后第一次请求返回旧值并后台刷新,下一次才拿到新的; - 为什么"手动失效之后,第一次请求就给新值了"——
revalidateTag和窗口过期走的是两条不同的路,前者没有"先给旧值"这一步; - 为什么"我只是给
fetch加了个选项,整页都变慢了"——fetch级no-store会把整页拖成动态,每次请求都回源; - 为什么"我手动清除缓存了但它没清掉"——标签要成对:
fetch上声明了tags,revalidateTag才命中; - 为什么要专门造一个独立计数源——构建期 Next 自己的服务器还没起,用它自己的 endpoint 当计数器会直接把构建打挂;而"页面 hits + 源 hits"这对读数,是量出缓存行为唯一的硬判据。
下一步:04 章 · 一个响应为什么能分两批到——这一章把"什么时候取数据"讲完了,下一章换个角度:同一份数据,为什么可以先发一半、等慢的那半好了再补上,以及为什么 fallback 之后的内容不用跟着一起等。