KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

01 · 首屏的 HTML 是谁给的 — keel 龙骨

这一章回答:同一份 React 组件,为什么有的页面的 HTML 在构建那天就写好了,有的每次请求现拼?

这一章回答:同一份 React 组件,为什么有的页面的 HTML 在构建那天就写好了,有的每次请求现拼?

这门课接在前三门后面。数据怎么取回来(Prisma)、一行 import 被翻译成哪个文件(模块与构建)、两条 CSS 谁赢(组件与样式)都讲过了。剩下最后一段路:这些代码跑出来的东西,是怎么变成用户屏幕上那屏画面的。

如果只能从这一章带走一句话,是这句:

"服务端渲染"不是一个开关。它是四个问题分别作答的结果:谁渲染、什么时候渲染、结果存在哪、什么时候失效。

现场

一个内部工具站,首页要显示"数据截至 <时间>"。组件里写得很朴素:

export default function Page() {
  const now = new Date().toISOString();
  return <p>数据截至 {now}</p>;
}

本地 npm run dev 一切正常,时间每次刷新都在变。上线之后:

两个页面都是同一个框架、同一套写法、同一个部署。为什么一个冻住了、一个活的?

先别往下看。你的直觉答案大概是"因为第一个页面是静态的、第二个是动态的"。 这个答案不算错,但它没回答真正的问题:框架凭什么知道该把哪一个当静态?"用了 new Date()"难道不算"它会变"吗?

一、构建时那一张表

跑一次 next build,输出的最后一张表就是框架的判定结果:

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/revalidate
├ ○ /client-boundary
├ ƒ /cookies-read
├ ○ /dynamic-time
├ ○ /fetch-default
├ ƒ /fetch-force
├ ƒ /fetch-nostore
├ ƒ /fetch-revalidate
├ ○ /hydration-mismatch
├ ƒ /search
├ ○ /static-time
└ ƒ /stream

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

这张表是本课的地基。○ 的意思是"这一页在构建期就被渲染成一份 HTML 文件,之后所有请求都回放它";ƒ 的意思是"每次请求都重新渲染"。

现在回头看那个冻住的页面。它对应表里的 ○ /dynamic-time —— 判定是 ○,也就是静态。 一个渲染函数里读了当前时间的页面,被判成了静态页。

这就是现场那个现象的全部原因:构建期渲染了一次,那一瞬间的 new Date() 被写死在产物里,之后再也没有第二次。 你加的 console.log 当然不会出现在线上日志里——那段代码在构建机上跑过一次,之后再没跑过。

而 ƒ /cookies-read 之所以是活的:它读 cookie,而 cookie 只存在于某一个具体的请求上,构建机上根本没有"某个用户的 cookie"这种东西,所以它不可能在构建期定稿。

二、分水岭不是"用了什么 API",是"碰没碰请求"

把上表按"页面里做了什么"重排,规律就露出来了:

页面里做了什么 判定 说明
只打印一个模块级常量 ○ 预期内
渲染函数里 new Date() ○ 时间不是请求
fetch(URL) 不带任何选项 ○ 整页在构建期定稿(fetch 也在构建期跑了一次)
内含 "use client" 子组件 ○ 有客户端组件不等于页面动态
只读 searchParams ƒ 查询串是请求的一部分
读 cookies() ƒ cookie 是请求的一部分
fetch(URL, {cache:"no-store"}) ƒ fetch 级的"别缓存"会把整页拖成动态

四行 ○ 里有三行违反直觉,值得逐个交代。

第一,new Date() 不是动态 API。 这一点最容易翻车:Date.now()、Math.random()、process.env 在框架眼里都只是普通表达式,它不会因为你要读时间就判定这页必须每次重算。实测 /dynamic-time 的页面上,那个时间字段是 2026-10-06T12:22:12.641Z —— 构建时刻,刷新多少次都不变。如果这页是想显示"实时数据",那它从上线第一秒起就是错的,而且不报任何错。

第二,不带选项的 fetch 会把整个页面一起冻住。 注意这里有两件事同时发生,容易混:

第三,有客户端组件不等于页面是动态的。 /client-boundary 里有一个 "use client" 的按钮组件,页面照样是 ○。原因是客户端组件的首屏输出也只是一段 HTML —— 它在服务端被渲染出来、拼进静态页,代码另走一个 chunk,到浏览器里才"接上"(水合)。静态与"有没有交互"是两件不相干的事。

顺带记住前两章的入口问题在这里的落点:判定的是"这一次渲染有没有依赖请求",不是"这段代码会不会产出不同的值"。 时间会变,但时间不来自请求,所以框架认为它可以定稿。

三、两个缓存层,别当一层看

○ 和 ƒ 只说了"页面要不要每次重渲染",它没说 fetch 的结果会被怎么处理。这两件事是两层:

/fetch-force 是拆开这两层的最好标本:它的页面声明了 force-dynamic(页面每次都重渲染,判定是 ƒ),但页面里的 fetch(URL, {cache:"force-cache"}) 把数据存住了。运行时实测:第一次请求打到计数源(4→5),第二次请求页面上还是同一个值、计数源命中 +0。

同一个页面上,页面可以不缓存而数据被缓存。 反过来也成立:/fetch-default 是页面被缓存(整页回放),数据自然也就不会再取。看清一层不等于看清另一层,这是本课后面反复用到的一把刀。

两层各自的判据也完全不同,这一点在排查时最有用 —— 你能只看现象就分出是哪一层出的问题:

层 决定什么 认它的凭据 实测对照
页面层 整份 HTML 回放不回放 路由表的 ○/ƒ、响应头的 x-nextjs-cache 与 cache-control /fetch-default 是 ○ + HIT;/fetch-nostore 是 ƒ + 无 x-nextjs-cache
fetch 层 这份数据存不存、存多久 在两次请求区间里,源被命中了几次 /fetch-force 区间 +1(第一次打源、第二次没有);/fetch-nostore 区间 +2(两次都打)

注意第二层的判据必须用独立计数源才拿得到 —— 这是本课为什么一上来就要搭那个 mock 源。页面 HTML 里的 hits 只能告诉你想看的东西(渲染时用的值),只有计数源知道"源到底被打了几次"。

还有一个容易混淆的推论:页面是 ƒ 不代表数据实时。 /fetch-force 就是 ƒ,页面每次都在重新渲染,但它取来的数据被 fetch 层存住了 —— 你会看到"页面上渲染时刻每次都变,而数据一直是同一份"。这是"为什么我明明 force-dynamic 了数据还是旧的"这个问题的完整答案。

四、还有第四层:谁把内容切成两批

上面三条都假设"一次渲染产出一份完整的 HTML"。但真实的慢接口会让这个假设失效 —— 服务端如果非要等那个 3 秒的查询回来才能发第一个字节,用户就盯着白屏 3 秒。

所以还有第四个问题:一次渲染的结果,能不能分几次送出去? 实测 /stream 这条路由,一个响应被切成了两批:

=== chunk 到达时刻(ms)===
  #0  t=    12ms  len=  1849
  #1  t=    12ms  len=  5777
  #2  t=  3025ms  len=   192
  #3  t=  3025ms  len=   963
  #4  t=  3025ms  len=    14

=== 标记出现的时机 ===
  shell     → chunk#0  t=12ms
  fallback  → chunk#0  t=12ms
  slow      → chunk#3  t=3025ms
  tail      → chunk#0  t=12ms

12 毫秒时第一批就到了,里面同时有壳、fallback 占位、以及 Suspense 之后的尾巴;3 秒后第二批才到,带来慢数据。这一章只需要记住一个结论:流式不是"分几次请求",是一个响应里的几批内容,而且它不会让 fallback 后面的内容一起陪着等。为什么能做到、边界在哪,是第 04 章。

五、把四种到达方式排在一起看

前面四节的东西散在四处,合起来是一张表。"用户看到的第一屏"有四种到达方式,每种都能用一个可观察的量认出来:

到达方式 这一页的 HTML 什么时候产生 认它的凭据 本课实测
构建期回放 构建那一次,之后不再变 路由表 ○;响应头 x-nextjs-cache: HIT + s-maxage=31536000;产物里有 <路由>.html /static-time、/dynamic-time、/fetch-default
按需渲染 每次请求现拼 路由表 ƒ;响应头 private, no-cache, no-store, max-age=0, must-revalidate;产物里没有 <路由>.html /cookies-read、/search、/fetch-nostore
分批到达 请求时先拼一批、最慢的那段后补 transfer-encoding: chunked 且没有 content-length;同一个响应里两批 chunk,间隔就是慢数据的时间 /stream:12ms 一批、3025ms 一批
客户端接上 HTML 到了之后,由浏览器里的 JS 接管 页面里带 <script src>;DOM 在 JS 执行后可能被改写(水合) /client-boundary(点了从 0 到 2)、/hydration-mismatch(server 被改写成 browser)

这张表有三个用处:

第一,四种可以叠加,不是四选一。 /stream 同时是"按需渲染"+"分批到达";/client-boundary 同时是"构建期回放"+"客户端接上"。所以"这页是静态的还是动态的"这个问题,问的只是第一列;第三、四列是另外两个独立的问题(第 04、05、06 章)。

第二,认它的凭据全部不依赖读代码。 构建表在构建日志里,响应头 curl -I 就有,产物清单 ls 就有,<script src> 数 HTML 里就有。线上排查时你通常没有代码在手(或者代码已经不是那一版),这四个判据是唯一靠得住的东西。

第三,第 1 行和第 2 行的差别,比它的字面看起来大得多。 "构建期回放"意味着这份 HTML 是所有用户共享的;"按需渲染"意味着它是每个请求各自一份的。一个读 cookies() 的页面被误改成静态,不只是"数据不更新"——它可能把 A 用户的内容发给 B 用户。而这不会有任何告警,因为从框架的角度看,一切都按你的判定正常工作了。

顺带说清一件常被混在一起的事:四种到达方式里,"快"指的是不同东西。
构建期回放快在服务端不用再渲染;分批到达快在第一眼来得早(总耗时一秒没省);客户端接上则是纯成本——它只负责让页面"活起来",你为它付 JS 的体积和主线程的时间(第 07 章量到:一个静态页仍带 7 个 script chunk、底盘 176.4 KB gzip)。

六、框架替你在哪一层做了决定

把四个问题合起来,这门课的地图就清楚了:

章 回答的问题 对应上表里的哪一类
02 ○/ƒ 是怎么判出来的、哪些 API 算"碰了请求" 判定规则本身
03 fetch 的四种写法把结果存在哪、什么时候失效 fetch 层缓存
04 一个响应为什么能分两批到 流式渲染
05 "use client" 的边界在哪、什么能跨过去 服务端/客户端边界
06 水合失败长什么样、为什么生产里看不见 浏览器侧
07 一套产物里到底有什么、怎么部署 交付面

再给一个提醒:这张表和这套判定都是"框架替你在构建期做的决定"。它读得懂静态代码,读不懂你的意图。你把一页写成了 ○,它不会警告你"这页本来应该实时";你把它误标成 ƒ,它也不会告诉你"这页其实可以省下每次渲染"。这门课的用处,是让你能在部署之前把这两件事都看出来。

flowchart TD
  REQ["一次请求"] --> Q1{"这次渲染<br/>碰了请求吗?"}
  Q1 -->|"没碰:只有常量/时间/fetch无选项"| ST["○ 静态<br/>回放构建期产物"]
  Q1 -->|"碰了:cookie / searchParams / no-store"| DY["ƒ 动态<br/>每次重新渲染"]
  ST --> Q2{"页面里的 fetch<br/>有自己的缓存吗?"}
  DY --> Q2
  Q2 -->|"force-cache / revalidate"| F1["数据被存住<br/>区间内不回源"]
  Q2 -->|"no-store 或无选项"| F2["每次都回源"]
  DY --> Q3{"有没有 Suspense<br/>包住慢的那段?"}
  Q3 -->|"有"| S1["一个响应两批内容<br/>壳先到,慢数据后到"]
  Q3 -->|"没有"| S2["整页等到最慢的一段"]

本章脉络

生产边界

这些是本课没有量到的东西,别把上面的结论外推过去:

动手:可观察结果

想自己复现这一章,最小代价是这样:

  1. 建一个模块级常量页面:const T = new Date().toISOString(),渲染出来。构建、启动、刷新五次 —— 值不变,○。
  2. 建一个渲染函数里取时间的页面:const now = new Date().toISOString()。别加任何动态 API。构建 —— 你会看到它也被判成 ○,这就是本章要你亲眼看的那一眼。
  3. 起一个独立于框架进程的计数源(本课用的是 127.0.0.1:3999 上一个十行的 http 服务,/tick 每次自增、/log 吐出全部记录),让页面去 fetch 它。
    为什么不能用框架自己的 API route:构建期服务器还没起,自取会直接让构建失败。
  4. 请求两次,比对三个数:页面 HTML 里的计数值、响应头的 x-nextjs-cache、计数源在这两次请求区间里的命中数。三个数对上,○/ƒ 和两层缓存的区别就都能亲手看到。

可观察结果:静态页两次请求 x-nextjs-cache: HIT、cache-control: s-maxage=31536000、计数源区间 +0;动态页无 x-nextjs-cache、cache-control 是 private, no-cache, no-store, max-age=0, must-revalidate、回源行为随 fetch 写法而变。

故障注入

注入一:把静态页当成实时页用。

在你那个 ○ 页面上加一行显示"数据截至 <时间>",然后改一个不相关的文案、重新构建、部署。

预期:文案变了,时间也变了 —— 变成这次构建的时刻。用户看到的仍然是"冻住的旧时间",但它跟着你的部署在跳。这个现象比"永远不动"更难发现,因为它看起来像"偶尔会更新"。

注入二:以为"页面动态"就等于"数据实时"。

给页面加 export const dynamic = "force-dynamic",但页面里的 fetch 写 cache:"force-cache"。

预期:页面上其它每次都在变的东西(比如渲染时刻)确实在变,而 fetch 来的数据不动。这就是一层缓存和两层缓存的差别,也是"为什么我明明 force-dynamic 了数据还是旧的"这个提问的来源。

注入三:把本地 dev 的行为当线上行为。

在 dev 里刷新那个时间页面,你会看到时间每次都在变 —— 因为 dev 模式下每页都是按需渲染的。

预期:线上完全不是这样。这个注入不需要写代码,只需要记住别在 dev 里验判定。

自测题

  1. 一个页面里只有 Math.random(),没有任何其它调用,next build 会把它判成 ○ 还是 ƒ?为什么?
  2. 页面声明 force-dynamic,页面里 fetch 用 force-cache。连续请求三次,fetch 的源会被打到几次?为什么不是三次?
  3. cache-control 出现 s-maxage=31536000 与出现 private, no-cache, no-store, max-age=0, must-revalidate,分别对应构建表里的哪个符号?能不能只看响应头就判断一个页面的渲染方式?
  4. 一个页面里含 "use client" 组件,它为什么不因此变成 ƒ?
  5. 为什么"构建期打了一次源"这件事必须用一个独立进程来测,而不能用一个框架自己的 API route?

现在能解释什么

你现在应该能把现场那三个现象一次说清:

再往下推一层:"服务端渲染"不是一个开关,是四个问题分别作答 —— 谁渲染、什么时候渲染、结果存在哪、什么时候失效。02 到 06 章就是在把这四个问题逐个拆开量。

进入 keel 阅读