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 级缓存,是三件可以被分开设置的事。

先记住两条反差最大的:

而不带选项那一页最特别:它的构建符号是 ○ 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 级缓存是两个独立的层。

把这条落实到排查上:下次你看到"我明明设了动态,怎么数据不更新",第一刀要切在两层的哪一层上。是页面没重渲染(页面级缓存命中,想看响应头有没有 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   一致

盯着两处"不一致"看,那是两个过期边界:

第一处不一致(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 不动 + 源 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 回没回源"可以同时出现、也可以各自单独出现。整张图的关键是:所有分岔都在构建期或缓存配置里定好,请求时只是照做。

生产边界

动手:可观察结果

下面五件事各做一遍。每一步的答案都必须来自命令输出,不能来自记忆。

  1. 先起计数源,再构建。 把那个独立的 mock server(127.0.0.1:3999)在 next build 之前启动。构建完之后请求一次 http://127.0.0.1:3999/log,看 hits 是不是已经大于 0。如果是——构建期真的打过源;如果不是——检查你的计数源是不是起晚了。
  2. 量一遍"页面重渲染、fetch 不回源"。 请求 /fetch-force 两次,每次记下页面显示的 hits,中间去 /log 读源计数。第二次的页面 hits 变了吗?源涨了吗?把这两个答案填进第二节的结论里。
  3. 把 no-store 和 force-cache 并排跑。 同样请求两次,一次打 /fetch-nostore、一次打 /fetch-force,各自记"页面 hits 变了没"和"源命中区间"。两张读数应该一个在爬、一个卡着不动。
  4. 亲眼看一次 SWR。 跑采样脚本量 /fetch-revalidate(每 4 秒一次,连采 11 次),把"页面 hits"和"源 hits"两列并排打印。找到那两行"不一致"——那里就是过期边界,页面给的是旧值,源已经涨了。然后立刻再请求一次,看它是不是变成新值了。
  5. 手动失效一次。 先让窗口自然过期、看到"先给旧值"那一幕;再调一次 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 没有关系。很多"我手动清除缓存了但它没清掉"的现场,根源就在这里:清除调用和被清对象之间那条标签线断了。

自测题

  1. /fetch-force 页面顶上写着 force-dynamic,页面每次请求都重渲染,但第二次请求源命中是 +0。请说清"页面重渲染"和"fetch 回源"分别由哪一层决定,以及这一页为什么两者可以不同时发生。
  2. 把四种写法(不带选项 / no-store / force-cache / revalidate)按"页面判定"分成两组,再按"fetch 是否每次回源"分成两组。这两次分组的结果一样吗?如果不一样,说明什么?
  3. revalidate: 30 到期后的第一次请求,返回的是新值还是旧值?请引用时间线里那行 32193ms 7 8 不一致 说明:为什么这一行能同时证明"给了旧值"和"后台打了源"。
  4. revalidateTag("tick") 之后第一次请求的行为,和 revalidate: 30 到期后第一次请求的行为,差在哪一步?这个差别对"内容一改就要用户立刻看到新的"这个需求意味着选哪个?
  5. 为什么计数器不能写成 Next 自己 app/ 里的一条 API route?请说清它在构建期会遇到的具体问题,以及这说明了计数源必须满足什么条件。
  6. /search 是 ƒ Dynamic,但它的源命中区间是 +0。这个 +0 能不能说明"它是静态的"?为什么"源命中数"这个判据有它的适用范围?
  7. 有人报告"我设了 revalidate: 30,用户说看到旧数据,是不是缓存没生效?"请按本章的语义解释:用户看到的确实是旧数据,但缓存正在按你设的规则工作;再说明要"改了立刻看到新的"应该换用哪种手段。
  8. 你手上有两页:一页"页面每次重渲染、fetch 也每次回源",一页"页面每次重渲染、fetch 完全不复用"。请各给出一种能落到这两个行为的 fetch 选项组合,并说出你会用什么两个读数来验证你的选择是对的。

现在能解释什么

下一步:04 章 · 一个响应为什么能分两批到——这一章把"什么时候取数据"讲完了,下一章换个角度:同一份数据,为什么可以先发一半、等慢的那半好了再补上,以及为什么 fallback 之后的内容不用跟着一起等。

进入 keel 阅读