KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

07 · 交付面:一套产物里有什么,以及这条链怎么收口 — keel 龙骨

这一章回答:构建完之后磁盘上多了什么?上线时该盯哪几个数?前面六章的结论怎么合成一条链?

这一章回答:构建完之后磁盘上多了什么?上线时该盯哪几个数?前面六章的结论怎么合成一条链?

前面六章都在回答"为什么"。这一章换一个姿势:只看产物和响应头,不看代码。 因为线上出问题时,你手里往往只有这两样。

现场

一次上线复盘,几个现象摆在一起:

这三个问题都是同一个问题的三种问法:构建产出的那堆文件,到底代表什么?

先猜一下:.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 的产物和生产的产物住在同一个目录里,混在一起数出来的数没有任何意义。

两个数值得停一下:

二、静态页存了两份,第二份是服务之后才出现的

只有判定为 ○ 的路由才有 .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),差别只在位置和名字:

哈希这个细节说明缓存键不是"这个 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。这两件事经常被混在一起说成"静态就快",实际上:

四、上线前该盯的四件事

把前面几章的判据收成一张清单。这四样都可以在部署之前看到:

盯什么 从哪看 判据
判定 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   ← 多出来的这一份

两个方向都有信息,而且"多出来的那份"比"对上的那些"更值得注意:

把这个交叉写成一次发布前的动作,就是三行:

# 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 语义在代码里,流式边界在页面上,水合问题在浏览器里,体积在产物里。这门课真正想让你带走的能力不是记住这些结论,而是在部署之前,用一个能观察的量把每一个结论验一遍。

本章脉络

生产边界

动手:可观察结果

  1. 数静态页:ls .next/server/app/*.html,逐个对第 02 章的判定表。凡是 ○ 的都应该有(_not-found、_global-error 也算),凡是 ƒ 的都不该有。
  2. 找那两份,并分清它们的来历:构建完立刻 ls .next/server/app/*.html(只有一份,按路由名摆着);起服务、把其中一页请求一次,再 ls .next/server/route-cache/APP_PAGE/(这时候才多出一个哈希目录)。两个时刻分别记一次,就再也分不混了。
  3. 看一个静态页的头部:从 HTML 里数 <script src> 的个数,再数 <link rel=stylesheet>。记住"静态"不代表"没有 JS"。
  4. 对线上打两枪: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 后的数字跟人沟通,用原始大小的变化趋势判断"这次改动是不是引入了新的大块"。

自测题

  1. 一个页面被判成 ○,浏览器首屏还要不要下 JS?为什么?
  2. .next/server/route-cache/APP_PAGE/<哈希>/$/x.html 里的 <哈希> 为什么不是路由名?这份 HTML 是构建时写的还是运行时写的 —— 你用什么动作能把两者区分开?
  3. 你要在部署前确认"这次改动没把某个页面从动态变成静态",手里不看代码,有哪两条判据?
  4. 同一条"传函数给客户端组件"的错误,为什么在静态页和动态页上的表现完全不同?
  5. 一个页面的 cache-control 是 s-maxage=31536000,能不能据此断定它是构建期定稿的?为什么(结合第 03 章的两层缓存)?
  6. 用户反馈"页面数据是旧的"。你会先看哪两个东西来区分"页面级缓存"和"fetch 级缓存"?

现在能解释什么

回到现场那四个现象:

整门课收在一句话上:"服务端渲染"不是开关,是四个问题分别作答 —— 谁渲染、什么时候渲染、结果存在哪、什么时候失效。 这四个问题都能在部署前用一个可观察的量验一遍:构建表、响应头、产物体积、以及一个独立于框架进程的计数源。能自己取证的能力,比记住任何一个默认值都耐用。

进入 keel 阅读