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 一切正常,时间每次刷新都在变。上线之后:
- 无论谁访问、什么时候访问,这行字永远是构建那一天的某个时刻;
- 加了一行
console.log想看它执行几次,日志里一次都没出现; - 但同一台服务器上另一个页面读 cookie 显示用户名,那个页面每次都在变。
两个页面都是同一个框架、同一套写法、同一个部署。为什么一个冻住了、一个活的?
先别往下看。你的直觉答案大概是"因为第一个页面是静态的、第二个是动态的"。 这个答案不算错,但它没回答真正的问题:框架凭什么知道该把哪一个当静态?"用了 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 会把整个页面一起冻住。 注意这里有两件事同时发生,容易混:
- fetch 在构建期真的执行了一次。这是能验的:本课用一个独立计数源(每次被请求就自增),构建前它的计数器是 1,构建跑完后变成 2,第二条记录的时间戳落在构建窗口里(
2026-10-06T12:22:12.676Z,正好在Generating static pages ... (11/11)期间)。 - 因为整页在构建期定稿,之后所有请求都直接回放那份 HTML,fetch 一次都不再发生。运行时把同一页请求两次,页面上的计数都是
2,计数源在那两段区间里命中 +0。
第三,有客户端组件不等于页面是动态的。 /client-boundary 里有一个 "use client" 的按钮组件,页面照样是 ○。原因是客户端组件的首屏输出也只是一段 HTML —— 它在服务端被渲染出来、拼进静态页,代码另走一个 chunk,到浏览器里才"接上"(水合)。静态与"有没有交互"是两件不相干的事。
顺带记住前两章的入口问题在这里的落点:判定的是"这一次渲染有没有依赖请求",不是"这段代码会不会产出不同的值"。 时间会变,但时间不来自请求,所以框架认为它可以定稿。
三、两个缓存层,别当一层看
○ 和 ƒ 只说了"页面要不要每次重渲染",它没说 fetch 的结果会被怎么处理。这两件事是两层:
- 页面层:这份渲染结果整体存不存、回放不回放;
- fetch 层:被
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["整页等到最慢的一段"]
本章脉络
- 判定:
○/ƒ不看"值会不会变",看"渲染有没有依赖请求"。new Date()和Math.random()都不算依赖请求;cookies()、searchParams、no-store算。 - 证据形态:构建期的路由表 + 运行时的
x-nextjs-cache与cache-control响应头 + 一个独立计数源的命中区间,三样互相印证。 - 两个缓存层:页面层决定"整份 HTML 回放不回放",fetch 层决定"这份数据存不存"。
force-dynamic+force-cache可以让它们分道扬镳。 - 流式是第四层:一个响应可以分多批,先到的那批里既有壳也有 fallback 之后的内容。
- 本课的地图:02 判定 → 03 fetch 缓存 → 04 流式 → 05 边界 → 06 水合 → 07 交付。
生产边界
这些是本课没有量到的东西,别把上面的结论外推过去:
- 只量了自托管的
next start。 CDN、反向代理、共享缓存介入之后,cache-control会被怎么改写、s-maxage会落到谁身上,本课没测。 - 只跑了单实例。 多副本部署时各实例的缓存是不是各存一份、tag 失效会不会只命中一个实例,本课没测 —— 这个问题在生产里是要命的,但需要真实的多实例环境才能验。
use cache指令没验证过。 它在框架里存在,但本实验台一条都没跑,所以本课一个字都不猜。拿它当"新一代缓存写法"的教程不要用在本文上。revalidatePath没量。 实验台留了入口,只跑了revalidateTag。- fetch 缓存落盘在哪、失效的具体路径没查。本课只报了"区间内源命中 +0 或 +1"这种可观察结果。
- 表里 7 个
○/ 7 个ƒ是本实验台的形状,不是经验值。别拿这个比例去估真实项目。
动手:可观察结果
想自己复现这一章,最小代价是这样:
- 建一个模块级常量页面:
const T = new Date().toISOString(),渲染出来。构建、启动、刷新五次 —— 值不变,○。 - 建一个渲染函数里取时间的页面:
const now = new Date().toISOString()。别加任何动态 API。构建 —— 你会看到它也被判成○,这就是本章要你亲眼看的那一眼。 - 起一个独立于框架进程的计数源(本课用的是
127.0.0.1:3999上一个十行的http服务,/tick每次自增、/log吐出全部记录),让页面去 fetch 它。
为什么不能用框架自己的 API route:构建期服务器还没起,自取会直接让构建失败。 - 请求两次,比对三个数:页面 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 里验判定。
自测题
- 一个页面里只有
Math.random(),没有任何其它调用,next build会把它判成○还是ƒ?为什么? - 页面声明
force-dynamic,页面里 fetch 用force-cache。连续请求三次,fetch 的源会被打到几次?为什么不是三次? cache-control出现s-maxage=31536000与出现private, no-cache, no-store, max-age=0, must-revalidate,分别对应构建表里的哪个符号?能不能只看响应头就判断一个页面的渲染方式?- 一个页面里含
"use client"组件,它为什么不因此变成ƒ? - 为什么"构建期打了一次源"这件事必须用一个独立进程来测,而不能用一个框架自己的 API route?
现在能解释什么
你现在应该能把现场那三个现象一次说清:
- 那行"数据截至"为什么冻在构建那天 —— 页面被判成
○,构建期渲染一次,new Date()的结果被写死进产物,之后没有第二次渲染,所以连console.log都不会出现在线上日志里。 - 为什么它不报错 —— 判定只看"渲染有没有依赖请求",时间不来自请求,框架没有理由拦你。
- 为什么另一个页面是活的 —— 它读 cookie,cookie 只属于具体请求,构建期不可能定稿,所以它必然是
ƒ。
再往下推一层:"服务端渲染"不是一个开关,是四个问题分别作答 —— 谁渲染、什么时候渲染、结果存在哪、什么时候失效。02 到 06 章就是在把这四个问题逐个拆开量。