KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

渲染与部署 — keel 龙骨

渲染与部署 的参考信息:渲染与部署

课程导读 · 每一条关于"谁渲染的"结论,都要能用一个可观察的量验一遍

这门课解决的是同一类问题:数据是旧的但没人改过缓存、加了 <Suspense> 还是白屏、force-dynamic 了数据还是不变、本地好好的上线就 500、用户说"内容闪了一下"而日志里什么都没有。它们的共同点是——这些决定都是框架在你看不见的地方做的:构建期替你判定了哪些页可以定稿,运行时替你决定了一份数据存不存,发送时替你决定了先发哪一批,出错时替你决定了错误能不能被看见。

你现在的起点

如果你符合上面几条,这门课就是为你写的。前提是你会写 React 并且跑得起来 npm —— 这门课不教页面怎么写,它教的是"你写下的这个组件,最后是被谁、在哪一步、按什么规则变成了用户屏幕上那屏画面"。

一条能走通的学习路径

先看清四问(01 首屏的 HTML 是谁给的)
  → 同一个组件,四种到达方式:静态回放 / 按需渲染 / 客户端拼 / 分批到达
  → 现场:我在组件里写了 new Date(),线上永远是构建那天
  → 判定的是"渲染碰没碰请求",不是"值会不会变"

再拆判定(02 build 那张表到底在说什么)
  → ○ / ƒ 从哪来;Date.now() 和 Math.random() 为什么不算动态 API
  → 只读 searchParams 就够让整页变动态
  → 有客户端组件的页面照样可以是静态的

再拆数据那一层(03 fetch 的四种写法)
  → 页面级缓存与 fetch 级缓存是两个独立的层
  → revalidate 到期后先给旧值、后台去取新的(stale-while-revalidate)
  → revalidateTag 的语义不同:下一次请求直接给新值

分批到达(04 一个响应,两批内容)
  → 12ms 的第一批里有壳、有占位、也有 Suspense 之后的尾巴
  → chunked 与"没有 content-length"是必要条件,代价是哪两件事不能再改
  → 边界的位置比数量重要:await 写在边界外 = 白屏

边界(05 服务端/客户端的那条线)
  → 传函数过去:静态页在构建期被拦下,动态页拖到请求时 500
  → 浏览器只拿到 digest,消息只在服务端日志里
  → 客户端里引 node:fs → Turbopack 直接 panic

浏览器侧(06 水合:生产里它是静默的)
  → 失配真的发生了,DOM 被改写,而 console 一条都没有
  → 更硬的判据:产物里根本没有水合警告的代码

交付侧(07 产物、头部与收口)
  → 静态页存两份,其中一份在 <内容哈希> 目录下
  → 一个静态页仍然带 7 个 script chunk
  → 上线前要盯的四件事,全都能在部署之前看到

你会拿到什么

章节 一句话 关键动作
01 首屏的 HTML 是谁给的 判定的是"碰没碰请求",不是"值会不会变" 构建表 × 运行时响应头 × 独立计数源三方对账
02 ○/ƒ 是怎么判出来的 new Date() 不是动态 API 14 条路由逐条判定,时间字段与构建时刻逐字比对
03 fetch 的四种语义 revalidate 是"先给旧值" 每 4 秒采样一次的时间线,直到看到新值追上
04 一个响应,两批内容 Suspense 之后的尾巴也在第一批里 按 chunk 记到达时刻:12ms 一批、3025ms 一批
05 服务端/客户端的那条线 同一条错误,静态页构建期炸、动态页请求时 500 两次故障注入的完整报错原文 + digest 对照
06 水合:生产里它是静默的 失配发生了,而 console 一条都没有 直接 grep 生产产物里有没有水合警告代码
07 交付面与整条链 静态页存两份,一份在内容哈希目录下 产物清单与体积 + 上线前四件事

每章结构统一:现场 → 形态 → 原因 → 本章脉络 → 生产边界 → 动手:可观察结果 → 故障注入 → 自测题 → 现在能解释什么。

三个必须先纠偏的认知

  1. "用了 SSR"和"这页是动态的"是两件事。 实测:一个渲染函数里写着 new Date().toISOString() 的页面,next build 把它判成 ○(Static),页面上的时间字段是构建时刻 2026-10-06T12:22:12.641Z,刷新多少次都不变。时间会变,但时间不来自请求 —— 判定只认"这一次渲染有没有依赖请求"。这也意味着这类错误永远不会报错。

  2. revalidate: N 不是"到期重新取一次"。 实测时间线:过了窗口后的第一次请求返回的仍是旧值,同时后台去打源;下一次请求才拿到新值。用户看到的既不是报错也不是等待,是旧数据。而 revalidateTag 的行为不一样 —— 失效后的第一次请求就直接给新值,没有"先给旧值"这一步。两种失效路径的观感完全不同。

  3. 流式不会让 Suspense 之后的内容一起等。 实测那个 3 秒慢数据的页面上,shell、fallback、以及 tail(Suspense 之后的内容)全部落在同一个 chunk 里,12 毫秒就到;慢数据 3025ms 才单独来一批。所以"加了 Suspense 还是白屏"几乎总是边界画错了 —— await 写在边界之外,占位根本没有机会发出去。

学完这门课你能做什么

前置要求

替身边界

实验以 Next.js 16.3.8(Turbopack 为默认打包器) + React 19.3.0 + Node 22.22.2 为准,浏览器用 Playwright 自带的 HeadlessChrome 151。

这个组合里有三件事是"刚刚才变成这样"的,一定要自己复核:

结论的形状("判定看请求不看时间"、"页面级与 fetch 级是两层"、revalidate 是先给旧值、"Suspense 不阻塞它之后的内容"、"生产里水合是静默的")跨版本成立;具体时间戳、字节数、报错文案请在你自己的版本上复核。

实验全部是本机小应用、无外网依赖(计数源是本机一个十行的 http 服务)。凡涉及时间戳与字节数的地方,请只看比值和形状,不要抄绝对值。

进入 keel 阅读