KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
07 · 交付面:一套产物里有什么,以及这条链怎么收口 — keel 龙骨
这一章回答:构建完之后磁盘上多了什么?上线时该盯哪几个数?前面六章的结论怎么合成一条链?
这一章回答:构建完之后磁盘上多了什么?上线时该盯哪几个数?前面六章的结论怎么合成一条链?
前面六章都在回答"为什么"。这一章换一个姿势:只看产物和响应头,不看代码。 因为线上出问题时,你手里往往只有这两样。
现场
一次上线复盘,几个现象摆在一起:
- 构建日志是全绿的(
✓ Compiled successfully、Generating static pages (11/11)),发布也没报错; - 上线后一部分页面的数据是构建那天的,另一部分页面的数据是请求时的;
- 同事想核对"到底哪些页面是静态的",但发布产物里没有那张
○/ƒ表 —— 那张表只在构建日志里闪了一下就过去了; - 有人问"我们就这几个页面,用户要下多少 JS",没人答得出来。
这三个问题都是同一个问题的三种问法:构建产出的那堆文件,到底代表什么?
先猜一下:.next 目录下有多少个文件、占多大?一个只有 1 个按钮的页面,浏览器要下多少 JS?
一、产物里到底有什么
实测这个实验台(12 条路由、1 个客户端组件、0 个样式表):
=== 总量 ===
.next 全部文件: 341 个, 41.9 MB
=== 客户端 JS ===
static/chunks/*.js(不含 map): 10 个文件, 590069 B (576.2 KB)
229156 B static/chunks/1rj7ns8rte9vc.js
165979 B static/chunks/2z-e6jheq0ftu.js
112594 B static/chunks/0cz1d0mv5g_q7.js
28907 B static/chunks/19mx3mg6lkumu.js
23131 B static/chunks/1mpnoxox0-4cg.js
14377 B static/chunks/3fntmmi971322.js
9721 B static/chunks/turbopack-1vijxpa2bdysr.js
5364 B static/chunks/310vm2bl3xxpt.js
其中 gzip 后合计: 180991 B (176.7 KB)
=== 服务端输出 ===
server/**/*.js: 83 个文件, 967049 B (944.4 KB)
246955 B server/chunks/[root-of-the-server]__0ztbbuw._.js
215192 B server/chunks/ssr/[root-of-the-server]__1uan58j._.js
...
=== 静态资源(css 等) ===
*.css: 0 个文件, 0 B
报这组数字之前有一个前提要先做:清空 .next 再构建。实测跑过一次 next dev 之后,同一个目录会从 341 个文件 / 41.9 MB 涨到 540 个文件 / 112.8 MB —— dev 的产物和生产的产物住在同一个目录里,混在一起数出来的数没有任何意义。
两个数值得停一下:
- 一个只有 1 个按钮的页面,浏览器首屏要下 176.7 KB(gzip)的 JS。 这是 App Router 运行时的底盘成本,跟你的业务代码无关。你的业务量是往上加的,不是从零开始的 —— 评估"加点东西会不会太肥"时,起跑线在这里。
.next有 341 个文件、41.9 MB,而你写的源码可能只有十几 KB。产物不是"你的代码翻译版",是一整套运行时 + 路由清单 + 缓存骨架。所以"部署时把哪些文件拷到哪个目录"从来不是靠猜的 —— 这也是为什么真实项目会用框架提供的产出模式,而不是自己挑文件(本课没验证具体产出模式,见「生产边界」)。
二、静态页存了两份,第二份是服务之后才出现的
只有判定为 ○ 的路由才有 .html。构建刚结束时,清单只有 server/app/ 与 server/pages/ 下这些:
8745 B server/app/_global-error.html
8978 B server/app/_not-found.html
8648 B server/app/client-boundary.html
7573 B server/app/dynamic-time.html
7639 B server/app/fetch-default.html
7601 B server/app/hydration-mismatch.html
6679 B server/app/index.html
7461 B server/app/static-time.html
8978 B server/pages/404.html
8745 B server/pages/500.html
然后启动服务、把五个静态页各请求一次,再看同一个目录:
=== 起服务前(刚构建完)===
route-cache 不存在
=== 依次请求 5 个静态页 ===
/static-time -> 200
/dynamic-time -> 200
/fetch-default -> 200
/client-boundary -> 200
/hydration-mismatch -> 200
=== 请求后再看 ===
.next/server/route-cache/APP_PAGE/
266e2e8b.../ → $/static-time.html + .meta + .rsc + .segments/
315002b5.../ → $/client-boundary.html + .meta + .rsc + .segments/
49e24395.../ → $/fetch-default.html + .meta + .rsc + .segments/
561345cc.../ → $/hydration-mismatch.html + .meta + .rsc + .segments/
94f7e757.../ → $/dynamic-time.html + .meta + .rsc + .segments/
🔴 这是这一节最该带走的一条:那份"多出来的" HTML 不是构建产物,是"这个页面被服务过一次"留下的痕迹。 构建刚结束时 server/route-cache/ 目录根本不存在;请求过哪几个静态页,才会长出哪几个目录。
两处的字节数完全一样(7461 / 8648 / 7639 / 7601 / 7573),差别只在位置和名字:
server/app/<路由>.html—— 构建产出,按路由名摆好;server/route-cache/APP_PAGE/<一长串内容哈希>/$/<路由>.html—— 运行期写入,目录名是内容算出来的哈希,旁边还有.meta/.rsc/.segments/三样。
哈希这个细节说明缓存键不是"这个 URL 对应的那页",而是"这一份内容"。它带来一个直接的排查推论:"产物里有几个静态页"和"服务过之后磁盘上有几份缓存"是两个不同的问题。 如果你拿"缓存目录里有没有这个页面"来判断它是不是静态的,你会把"还没被访问过"误判成"不是静态的" —— 该看的是 server/app/。
反过来的判据同样好使。把六条动态路由逐个核对有没有 .html:
cookies-read → 无 .html ✓
fetch-nostore → 无 .html ✓
fetch-force → 无 .html ✓
fetch-revalidate → 无 .html ✓
search → 无 .html ✓
stream → 无 .html ✓
一个页面的渲染方式,可以是"构建产物里有没有这个文件"来验证的 —— 不需要看代码,也不需要那张一闪而过的表。
三、静态页也要下 JS,而且一下就是 7 份
挑一个被判定为 ○ 的页面,看它的 HTML:
内联 <style> 数量: 0
<link rel=stylesheet> 数量: 0
<script src> 数量: 7
preload link 数量: 1
/_next/static/chunks/19mx3mg6lkumu.js
/_next/static/chunks/1rj7ns8rte9vc.js
/_next/static/chunks/2z-e6jheq0ftu.js
/_next/static/chunks/turbopack-1vijxpa2bdysr.js
/_next/static/chunks/1mpnoxox0-4cg.js
/_next/static/chunks/0cz1d0mv5g_q7.js
/_next/static/chunks/310vm2bl3xxpt.js
这张页面的正文在构建期就定稿了,但它仍然带着 7 个 script 标签。原因在第 05、06 章:静态 HTML 只是"首屏长这样",页面要"活起来"还得靠这些 chunk 在浏览器里接上(水合)。
所以"静态渲染"省的是服务端的每次渲染,不是客户端的 JS。这两件事经常被混在一起说成"静态就快",实际上:
- 服务端:静态页省掉了每次渲染的开销,且响应头可以直接给长缓存(实测
s-maxage=31536000); - 客户端:一分钱都没省,只要页面里有客户端组件,该下载的 chunk 一个不少。
四、上线前该盯的四件事
把前面几章的判据收成一张清单。这四样都可以在部署之前看到:
| 盯什么 | 从哪看 | 判据 |
|---|---|---|
| 判定 | next build 的 Route (app) 表 |
该 ○ 的别是 ƒ(白花每次渲染的钱);该 ƒ 的别是 ○(数据会冻在构建那天,而且不报错) |
| 构建期拦截 | 构建是否 exit 0 |
边界写错这类问题,静态页会被拦在构建期(Error occurred prerendering page ...);一旦被标成动态,就会溜到线上变成请求时的 500 |
| 响应头 | 对线上真实请求 curl -I |
静态页应有 x-nextjs-cache: HIT 与 s-maxage=31536000;动态页是 private, no-cache, no-store, max-age=0, must-revalidate |
| 体积 | static/chunks 合计 |
底盘 176.4 KB(gzip)。加了东西之后涨了多少,要能答出来 |
第二行那个对照是本课里最值得记住的一条工程纪律:同一条错误,静态页在 CI 里就被拦下,动态页一路走到线上、请求时才 500。 而线上的那次 500,浏览器只拿到一个 digest,消息只在服务端日志里 —— 也就是说排查入口在服务器,不在浏览器。
五、两条判据互相印证:一个能写进发布检查的做法
前面几节都只说"看哪一样"。但真正上线时你需要的不是"看得到",而是**"两样东西对不上就拦下来"**。本节把三个判据摆成可以互相印证的形式:
| 判据 | 从哪拿 | 它能单独证明什么 | 它单独会骗你的地方 |
|---|---|---|---|
| A. 构建路由表 | next build 输出 |
每个路由被判成 ○ 还是 ƒ |
只在构建日志里闪一次,日志一滚就没了 |
| B. 响应头 | curl -I <线上地址> |
这个请求实际走的是"回放"还是"现渲染" | 中间有 CDN 或代理时,头可能被改写(本课没测) |
| C. 构建产物 | ls .next/server/app/*.html |
构建期到底写出了哪几份 HTML | route-cache/ 那份是运行期才有的,拿它判会漏 |
单看任何一个都能出错,而A 与 C 交叉就很硬:两者都是构建期的事实,且互相独立(一个是日志,一个是磁盘)。
实测这个实验台的交叉结果(7 个 ○ 路由对 8 个 server/app/*.html):
○ / → server/app/index.html
○ /_not-found → server/app/_not-found.html
○ /client-boundary → server/app/client-boundary.html
○ /dynamic-time → server/app/dynamic-time.html
○ /fetch-default → server/app/fetch-default.html
○ /hydration-mismatch → server/app/hydration-mismatch.html
○ /static-time → server/app/static-time.html
(路由表里没有) → server/app/_global-error.html ← 多出来的这一份
两个方向都有信息,而且"多出来的那份"比"对上的那些"更值得注意:
- 7 个
○路由全部对上了.html,且没有一个是ƒ却有 HTML 的(cookies-read/search/stream/fetch-nostore/fetch-force/fetch-revalidate/api/revalidate逐条核对过,全部没有)—— 这一条对了,说明"该静态的确实是静态的、该动态的确实是动态的"。 - 反过来多出一个
_global-error.html,而路由表里没有_global-error这一行。 它是框架自己的兜底页,不计入你的路由判定。也就是说:"两边数量相等"不是判据,C ⊇ A才是 —— 产物里可以多出框架自己的东西,但不能少你判定为静态的那些。 - 另外
server/pages/404.html与server/pages/500.html也在清单里,它们是另一套目录结构(pages/)留下的产物,同样不属于你的app/路由。
把这个交叉写成一次发布前的动作,就是三行:
# 1) 判定:把 ○ 的路由抄下来(构建日志里)
# 2) 产物:数构建产出的静态页
ls .next/server/app/*.html | wc -l
# 3) 交叉:每一个 ○ 路由都应该能在上面的清单里找到对应的 .html;
# 清单里多出来的(_global-error / pages/ 那两份)允许存在
注意第 2 步必须在"构建完、还没起服务"的窗口里做 —— 起过服务之后 route-cache/ 就长出来了,你会把运行期缓存当成构建产物数进去。这也是本课为什么把这条判据拆成"构建产物"和"运行期缓存"两件事讲。
六、把七章串成一条链
走到这里,一个请求从进来到用户看见画面,路上经过这几道决定:
flowchart TD
A["写一个页面"] --> B{"02 判定<br/>渲染碰了请求吗?"}
B -->|"没碰"| S["○ 静态<br/>构建期定稿"]
B -->|"碰了"| D["ƒ 动态<br/>每请求重渲染"]
S --> C{"03 fetch 层<br/>数据又存了吗?"}
D --> C
C -->|"存了"| C1["区间内不回源<br/>到期先给旧值"]
C -->|"没存"| C2["每次回源"]
D --> E{"04 有 Suspense 边界吗?"}
E -->|"有且画对了"| E1["第一批 12ms 出去<br/>尾巴也在里面"]
E -->|"没有 / 画错"| E2["等最慢那段"]
S --> F["05 边界<br/>客户端组件另走 chunk"]
E1 --> F
E2 --> F
F --> G["06 水合<br/>生产里静默,失配不报"]
G --> H["07 交付<br/>产物 / 头部 / 体积"]
每一道决定的共同点是:它们都不需要等线上出事才知道。 判定在构建日志里,fetch 语义在代码里,流式边界在页面上,水合问题在浏览器里,体积在产物里。这门课真正想让你带走的能力不是记住这些结论,而是在部署之前,用一个能观察的量把每一个结论验一遍。
本章脉络
- 产物规模:
.next341 个文件 / 41.9 MB(跑过next dev会被污染到 540 个 / 112.8 MB);客户端 JS 10 个 chunk / 590069 B(gzip 180991 B);服务端 JS 83 个文件 / 967049 B。 - 静态页存两份,但出现时机不同:
server/app/x.html是构建产出;server/route-cache/APP_PAGE/<内容哈希>/$/x.html是首次服务该页时才写的(构建刚结束该目录不存在),字节数相同。判"是不是静态页"要看server/app/,不是缓存目录。 - 静态页也要下 JS:一个
○页面仍然带 7 个 script chunk。静态省的是服务端每次渲染,不是客户端下载。 - 上线前四件事:判定表、构建是否
exit 0、响应头、体积 —— 全都能在部署前看到。 - 同一错误两种命运:静态页构建期拦下(digest
4218742025),动态页请求时 500(digest1496312302),且浏览器只拿到 digest。
生产边界
- 没验证任何"产出模式":standalone 之类把运行时打包进产物的做法,本课一条都没跑。所以"部署时拷哪些文件"这个问题,本课只给了"产物有 341 个文件、而且还会随访问增长、不要靠猜",没给答案。
- 没量多实例与共享缓存:
s-maxage落到 CDN 上之后的行为、多个副本各自的缓存是否独立、tag 失效会不会只命中一个副本 —— 全部未测。 - 没量冷启动:
next start之后第一个请求的耗时、以及缓存从空到热的过程,本课没测。 - 没量并发:多个流式请求同时进行时的内存与连接增长没测。
- 没量真实中间层:反向代理 / CDN 对流式与
cache-control的改写没测。 - 体积数字是本实验台的(12 条路由、1 个客户端组件、0 样式表、没引 UI 库)。接了真实 UI 库之后底盘会长得完全不同,别拿 176.4 KB 当预算上限。
动手:可观察结果
- 数静态页:
ls .next/server/app/*.html,逐个对第 02 章的判定表。凡是○的都应该有(_not-found、_global-error也算),凡是ƒ的都不该有。 - 找那两份,并分清它们的来历:构建完立刻
ls .next/server/app/*.html(只有一份,按路由名摆着);起服务、把其中一页请求一次,再ls .next/server/route-cache/APP_PAGE/(这时候才多出一个哈希目录)。两个时刻分别记一次,就再也分不混了。 - 看一个静态页的头部:从 HTML 里数
<script src>的个数,再数<link rel=stylesheet>。记住"静态"不代表"没有 JS"。 - 对线上打两枪:
curl -I一个静态页和一个动态页,把x-nextjs-cache与cache-control抄下来对照。
可观察结果:静态页两次都是 x-nextjs-cache: HIT + s-maxage=31536000;动态页没有 x-nextjs-cache,cache-control 是 private, no-cache, no-store, max-age=0, must-revalidate。一个 ○ 页面仍然带 7 个 <script src>。
故障注入
注入一:把该动态的页面做成静态的。
挑一个读 cookies() 的页面,把读 cookie 换成读一个 process.env 常量(构建期就注入好)。构建。
预期:路由表从 ƒ 变成 ○,而且构建和部署全绿。上线后这个页面对所有用户返回同一份内容 —— 如果那「常量」原本是按用户不同的,这就是一个静默的数据泄漏形态。没有任何告警会响。
注入二:用"构建绿了"当验收。
故意在服务端组件里给客户端组件传一个函数,让构建失败;记下错误签名(Error occurred prerendering page ... + Event handlers cannot be passed to Client Component props)。然后给同一页加 export const dynamic = "force-dynamic",再构建。
预期:构建绿了。 同一份代码、同一条错误,从"CI 拦下"变成"上线后请求时 500"。把这两次的 digest 都记下来 —— 它们不一样,而这正是"线上报障时你手里唯一有的东西"。
注入三:把 gzip 之后的体积算清楚再上线。
只看 static/chunks 的原始大小会高估三倍多(590069 B vs gzip 180991 B)。
预期:你加一个 UI 库之后会看到两者比例变化。用 gzip 后的数字跟人沟通,用原始大小的变化趋势判断"这次改动是不是引入了新的大块"。
自测题
- 一个页面被判成
○,浏览器首屏还要不要下 JS?为什么? .next/server/route-cache/APP_PAGE/<哈希>/$/x.html里的<哈希>为什么不是路由名?这份 HTML 是构建时写的还是运行时写的 —— 你用什么动作能把两者区分开?- 你要在部署前确认"这次改动没把某个页面从动态变成静态",手里不看代码,有哪两条判据?
- 同一条"传函数给客户端组件"的错误,为什么在静态页和动态页上的表现完全不同?
- 一个页面的
cache-control是s-maxage=31536000,能不能据此断定它是构建期定稿的?为什么(结合第 03 章的两层缓存)? - 用户反馈"页面数据是旧的"。你会先看哪两个东西来区分"页面级缓存"和"fetch 级缓存"?
现在能解释什么
回到现场那四个现象:
- 构建全绿但行为分两派 —— 因为判定发生在构建期,且两派都会被正常渲染出来;绿不绿只说明"渲染没抛错",不说明"这一页该不该每次重渲染"。
- 哪些页面是构建期定稿的,产物里查得到 ——
server/app/*.html的清单与判定表的○集合互相印证(7 个○全部对上;产物里多出_global-error.html与pages/404.html、pages/500.html,都是框架自己的东西)。而route-cache/APP_PAGE/<内容哈希>/$/下那份是服务过之后才出现的,不能拿它当构建产物。 - 一个按钮的页面要下 176.4 KB(gzip)的 JS、7 个 chunk —— 底盘成本,与业务代码无关。
- 上线前该看什么 —— 判定表、构建退出码、响应头、体积。四样都在部署之前,谁都不需要等线上出事。
整门课收在一句话上:"服务端渲染"不是开关,是四个问题分别作答 —— 谁渲染、什么时候渲染、结果存在哪、什么时候失效。 这四个问题都能在部署前用一个可观察的量验一遍:构建表、响应头、产物体积、以及一个独立于框架进程的计数源。能自己取证的能力,比记住任何一个默认值都耐用。