KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
渲染与部署 — keel 龙骨
渲染与部署 的参考信息:渲染与部署
课程导读 · 每一条关于"谁渲染的"结论,都要能用一个可观察的量验一遍
这门课解决的是同一类问题:数据是旧的但没人改过缓存、加了 <Suspense> 还是白屏、force-dynamic 了数据还是不变、本地好好的上线就 500、用户说"内容闪了一下"而日志里什么都没有。它们的共同点是——这些决定都是框架在你看不见的地方做的:构建期替你判定了哪些页可以定稿,运行时替你决定了一份数据存不存,发送时替你决定了先发哪一批,出错时替你决定了错误能不能被看见。
你现在的起点
- 会用
next dev起一个 App Router 项目,也见过next build输出里那串○和ƒ,但说不清它们是怎么判出来的; - 知道"服务端渲染"这个词,但把它当成一个开关 —— 以为"用了 SSR 就是动态、用了静态就是快";
- 改不动缓存时,处理方式是在
fetch前后乱试cache和revalidate的取值,试到看起来对为止; - 给页面加过
export const dynamic = "force-dynamic",但没验证过它的作用面到底有多大; - 听过"水合"这个词,但线上从没见过水合相关的报错,所以默认它不会出问题。
如果你符合上面几条,这门课就是为你写的。前提是你会写 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 交付面与整条链 | 静态页存两份,一份在内容哈希目录下 | 产物清单与体积 + 上线前四件事 |
每章结构统一:现场 → 形态 → 原因 → 本章脉络 → 生产边界 → 动手:可观察结果 → 故障注入 → 自测题 → 现在能解释什么。
三个必须先纠偏的认知
"用了 SSR"和"这页是动态的"是两件事。 实测:一个渲染函数里写着
new Date().toISOString()的页面,next build把它判成○(Static),页面上的时间字段是构建时刻2026-10-06T12:22:12.641Z,刷新多少次都不变。时间会变,但时间不来自请求 —— 判定只认"这一次渲染有没有依赖请求"。这也意味着这类错误永远不会报错。revalidate: N不是"到期重新取一次"。 实测时间线:过了窗口后的第一次请求返回的仍是旧值,同时后台去打源;下一次请求才拿到新值。用户看到的既不是报错也不是等待,是旧数据。而revalidateTag的行为不一样 —— 失效后的第一次请求就直接给新值,没有"先给旧值"这一步。两种失效路径的观感完全不同。流式不会让 Suspense 之后的内容一起等。 实测那个 3 秒慢数据的页面上,
shell、fallback、以及tail(Suspense 之后的内容)全部落在同一个 chunk 里,12 毫秒就到;慢数据 3025ms 才单独来一批。所以"加了 Suspense 还是白屏"几乎总是边界画错了 ——await写在边界之外,占位根本没有机会发出去。
学完这门课你能做什么
- 拿到"数据是旧的",能先问三个问题:这页是
○还是ƒ?问题在页面级缓存还是 fetch 级缓存?是过了时间窗口还是压根没设窗口? —— 而不是去乱改cache的取值; - 在部署之前用三样东西核对一页的行为:构建时的路由表(判定)、
curl -I的x-nextjs-cache与cache-control(运行时形态)、server/app/*.html的清单(产物证据); - 想验一个"这页到底取了几次数据"的问题时,知道必须用一个独立于框架进程的计数源,并且知道为什么自建 API route 测不了这件事(构建期服务器还没起,自取会直接构建失败);
- 遇到"加了 Suspense 没效果",能用 chunk 到达时刻把"壳/占位/尾巴/慢数据"各自的落点量出来,判断是边界位置问题还是没画边界;
- 收到"内容闪了一下"这类反馈,知道生产构建里没有水合警告代码,所以第一条路是复现而不是翻日志;
- 知道"静态页"省的是服务端每次渲染的钱,客户端的 JS 一分没省(实测一个静态页仍带 7 个 script chunk)。
前置要求
- 会写 React 与 JSX,能跑
npm install/next build(这门课全程真跑真量,不靠推断); - 建议先读同板块的 组件与样式 —— 那门课最后量到"服务端渲染出来的 HTML 里一个
<style>都没有",这门课接手它留下的入口问题:那份 HTML 是谁在什么时候拼出来的; - HTTP 的基本概念要有:状态码、响应头、
cache-control、transfer-encoding。这门课会把它们当成主要判据用。
替身边界
实验以 Next.js 16.3.8(Turbopack 为默认打包器) + React 19.3.0 + Node 22.22.2 为准,浏览器用 Playwright 自带的 HeadlessChrome 151。
这个组合里有三件事是"刚刚才变成这样"的,一定要自己复核:
- 16.x 的 Turbopack 是默认打包器。 实测"客户端组件里引 Node 内置模块"报的是 Turbopack 的内部错误(
the chunking context (unknown) does not support external modules (request: node:fs)),不带任何"你放错位置了"的指引 —— 拿 webpack 时代的报错经验来搜是搜不到的; - 路由缓存目录的结构。 实测静态页的 HTML 在构建时写进
server/app/x.html,首次被服务时又写一份到server/route-cache/APP_PAGE/<内容哈希>/$/x.html(构建刚结束该目录不存在),目录名是内容哈希不是路由名。这个形状在新版本里可能变; cache-control与x-nextjs-cache的具体取值(s-maxage=31536000等)是自托管next start直连下的观测,中间隔 CDN 或反向代理之后会不会被改写,本课没测。
结论的形状("判定看请求不看时间"、"页面级与 fetch 级是两层"、revalidate 是先给旧值、"Suspense 不阻塞它之后的内容"、"生产里水合是静默的")跨版本成立;具体时间戳、字节数、报错文案请在你自己的版本上复核。
实验全部是本机小应用、无外网依赖(计数源是本机一个十行的 http 服务)。凡涉及时间戳与字节数的地方,请只看比值和形状,不要抄绝对值。