KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

06 · 水合:线上为什么一声不响 — keel 龙骨

这一章回答:水合失败长什么样、为什么生产环境里你看不到它——以及如何用一条比"盯 console"更硬的判据,去证明线上根本没有水合的告警代码。

这一章回答:水合失败长什么样、为什么生产环境里你看不到它——以及如何用一条比"盯 console"更硬的判据,去证明线上根本没有水合的告警代码。

现场:首屏写着 server,一会儿变成 browser,而 console 是空的

E7 在 /hydration-mismatch 这一页上动了点手脚。这个 "use client" 组件在渲染时读同一个表达式:

typeof window === "undefined" ? "server" : "browser"

服务端渲染时没有 window,所以它算出 server;浏览器水合时 window 有了,它算出 browser。于是同一块内容,两个环境算出两个不同的值。

生产构建(next build + next start)下用 Playwright 读,结果是:

水合后 DOM 里的值: browser
服务端 HTML 里的值: server → 不一致(已失配并被浏览器改写)
console 里与水合相关的消息: (无)
全部 console 消息类型统计: {}

失配真的发生了——服务端发的是 server,水合后 DOM 被改写成 browser——可 console 里一条消息都没有。 全部 console 消息类型统计: {} 那个空对象,说的就是"连一条都没有"。

先给两个预测:

  1. 这是不是说明"根本没出问题"?毕竟一个正经的水合失败,不应该报点什么吗?
  2. 如果你要在线上确认"到底有没有水合失配",你会用什么办法?盯着用户浏览器的 console 显然不现实。

第二个预测是这一章的重头戏。第一个预测的答案很冷:没报错不等于没出事——DOM 已经被改了,只是没人告诉你。

一、水合是什么:服务端已经发了一份 HTML,浏览器要把它"接上"

先把"水合"这个词落成一个具体的动作。上一章 /client-boundary 的实测是最好的例子:

按钮初始文字: 点了加一: 0
点两次后文字: 点了加一: 2

服务端给浏览器的那份 HTML 里,按钮的文字已经是 点了加一: 0——它不是你打开页面之后由 JS 生成的,是构建期就写死在 HTML 里的(E5 里那份 server/app/client-boundary.html,8648 B)。也就是说,浏览器在 JS 到位之前就已经能把这行字画出来了。

那 JS 到位之后还干什么?它把这块 HTML 接上:给按钮绑事件、把组件的状态接管过来。这就是水合(hydration)。 "接上"之后,你点一下才真的会变成 点了加一: 1,再点变 2。

所以水合有个前提:服务端已经发出去的那份 DOM,得和浏览器打算"接上"的那份结构对得上。 对得上,React 就静静地把事件和状态挂上去;对不上,就是"水合失配"(hydration mismatch)。

对不上的原因,/hydration-mismatch 是最干净的一种:同一段表达式,在两个环境里算出两个值。服务端算出 server 并写进 HTML,浏览器算出 browser 并写进它那份结构——两边一比,对不上。

顺带把量级记住:你要水合的那份 JS 不是免费的。E5 量到客户端 JS 共 10 个 chunk / 590069 B,gzip 后 180991 B——一个只有 1 个按钮的页面,首屏也要下这么大一坨(E8 第 12 条)。水合是拿这份 JS 去"认领"已经画好的 HTML;HTML 负责好看得快,JS 负责接得上。

还有一点容易混,得先掰开:水合不是"重新渲染一遍"。 HTML 里那些节点不会被打碎重建,React 是拿自己算出来的那份结构,跟这份现成的 DOM 做一次对齐;对上了,就把事件监听挂上去、把组件状态接管过来。正因为它是"对齐"而不是"重建",服务端和浏览器两边算出来的结构必须一致——这就是后面所有失配的入口。而"一致"这件事,恰好在生产构建里没有任何东西替你兜底(第三节会量给你看)。

二、实测的失配:server 被改写成 browser,而 console 是空的

现在回收开头那两个预测。

第一个预测"是不是根本没出问题"——答案是出了问题。判据就在那两行:

水合后 DOM 里的值: browser
服务端 HTML 里的值: server → 不一致(已失配并被浏览器改写)

服务端 HTML 里明明写着 server,水合之后 DOM 里变成了 browser。DOM 被改了。 这不是"没出问题",这是"服务端和浏览器没对上,React 按浏览器那边重写了 DOM"。用户看到的效果,就是"那行字闪了一下、变了个样"。

而且这一页在构建期还能被判成静态——E1 的路由表里 /hydration-mismatch 带的是 ○:

├ ○ /hydration-mismatch

一个静态页面里,照样水合、照样失配、照样静默。 这跟上一章 /client-boundary 是同一类形状:页面的第一份 HTML 在构建期就定稿,客户端组件的代码另走 chunk,到浏览器才接上。区别只在于——上一个接上之后好好地能点,这一个接上之后对不上、把 DOM 改了。

第二个预测——你会怎么在线上确认——正是这一章要给你的那条硬判据。先别急着看答案,因为"console 没消息"本身是个很弱的证据:"我没看到"也可能是"我没盯对地方"。

先把"console 是空的"这句话的分量说清楚。它字面上的意思只是"没有消息被打印出来",而这背后有三种可能:(1) 真的没失配;(2) 失配了,但没有告警代码;(3) 有告警代码,但你没把它收全。本章要做的事,恰恰是把 (2) 和 (1) 分开——办法不是盯得更久,而是换一种不依赖"看"的证据。

三、比"没看到日志"更硬的判据:去产物里 grep 警告代码

思路很直接:如果生产构建里根本不存在"水合失配"的告警代码,那它当然不会在线上打印任何东西。 所以不盯 console,改去搜产物。

E7 拿 5 个关键词去 grep 生产产物:

--- 生产 build · 浏览器 chunk | 10 个文件, 0.6 MB
    "Hydration failed" 0 次
    "did not match" 0 次
    "Text content does not match" 0 次
    "hydration-error" 0 次
    "An error occurred during hydration" 0 次
--- 生产 build · server chunk | 83 个文件, 0.9 MB
    "Hydration failed" 0 次
    "did not match" 命中 1 次 → 上下文是
      `The Server Reference ID did not match the expected format.`(与水合无关)
    "Text content does not match" 0 次
    "hydration-error" 0 次
    "An error occurred during hydration" 0 次

一行一行读:

为什么要分两包 grep?因为浏览器侧和 server 侧是两份独立的产物,判据只有落在会被送到浏览器的那一包里,才有机会在用户面前响。所以"浏览器侧 0 命中"是决定性的那一半:告警代码压根没进浏览器,就没机会被任何人看到。

结论只有一句:生产产物里没有水合失配的告警代码,所以线上是静默的——DOM 被悄悄改写,没有任何日志。

这条判据比"console 里没看到消息"硬得多。原因是它不依赖你盯得对不对:你不是在"观察有没有输出",而是在"检查有没有可能输出"。前者的反例是"我可能看漏了",后者的结论是"这套产物里压根没有那句话,谁也打不出来"。

再换个角度看同一条事实:浏览器那一侧是一份完整、独立的代码包(10 个文件、0.6 MB),它里面没有告警字符串,就意味着"报不报"这件事在你打开页面的很久之前就已经定了。你在页面里盯多久、加多少监听,都改不了这一点——没有的东西,运行时也变不出来。

E7 的原文也把这条落成了工程后果:

→ 生产产物里根本没有水合失配的警告代码,所以线上是静默的:DOM 被悄悄改写,没有任何日志。这类问题只能从"用户看到内容闪了一下 / 数字跳变"这一侧发现。

四、由此推出的工程后果:你得能自己把失配注出来

既然线上是静默的,那"等用户报"就是唯一被动的发现途径,而这条途径几乎不可用——用户会把"闪了一下"当成很正常的事,根本不会报。

所以这一类问题的排查,只能反过来做:你要有一套可复现的、自己能随时触发的注入手段。 因为你知道判据在哪——

  1. 选一块"服务端和浏览器会算出不同值"的内容。 typeof window === "undefined" ? "server" : "browser" 就是最省事的一种:一句话,两个环境两个值,不需要任何外部依赖。
  2. 把它放进一个 "use client" 组件。 只有会被水合的组件才谈得上水合失配;服务端组件根本不会在浏览器里重跑。
  3. 跑生产构建(next build + next start),别跑 next dev。 你要量的是"线上什么样",那就得在生产模式下看——这一点在本章 ## 生产边界 里还会再强调一次。
  4. 读两样东西:server 与 browser 谁先谁后(服务端 HTML 里的值 vs 水合后 DOM 里的值),以及 console 里到底有没有消息。

跑完你会发现这一页仍然被判 ○ Static(E1 的路由表里 /hydration-mismatch 带 ○)——这又是一个"静态页里活着一块会失配的客户端组件"的例子,和上一章的 /client-boundary 是同一类形状。

而最值得记住的一课是:你把这个失配注进去了、它真的发生了、DOM 真的被改了——但你的 console 依然干净。 这就是"为什么线上看不见"的完整证据链。

五、把"静默"反过来当成一条纪律

前面几节量的是"它为什么不响"。把它翻过来,就是一条能用的纪律:凡是依赖"日志会出现"的监控,对这类问题都是盲的。

理由第三节已经给足了——告警字符串压根不在产物里。于是你的监控面板上,这一页永远是绿的,哪怕它每一屏都在闪。这不是监控坏了,是它盯的是一样不存在的东西。

那还能盯什么?只剩两条路。

第一条,盯用户能看见的症状。E7 把这句话说得很直白:这类问题只能从"用户看到内容闪了一下 / 数字跳变"这一侧发现。也就是说,"首屏文字有没有跳变""数字有没有从 A 闪到 B"这类肉眼判据,在这个问题上是一线证据,不是可有可无的锦上添花。

第二条,也是更可靠的一条,把"服务端和浏览器会算出不同值"当成一类写法去主动搜。本章实测的候选是 typeof window(/hydration-mismatch);把同一套逻辑外推,凡是"同一段渲染代码在两个环境里结果可能不同"的写法,都在这个模式里——时间、随机数、localStorage、媒体查询、语言/时区。(这份清单是从实测那一例外推出来的候选,本实验台没有逐个取证,别当已验事实引用。)

而要把这两条落到地上,靠的还是"可复现"。EVIDENCE 的探针表里有一个 probe-browser.mjs,它的活儿就是"用 Playwright 读水合与交互",产出 out/E6c-hydration.txt。这一章所有关于 server / browser 与 console 条数的数字,都出自这条自动化路径,不是靠人肉盯屏幕。 这就是正解的形状:把"服务端发的值"和"水合后的值"一起读出来做比对,写成一个能反复跑的探针,而不是每来一个问题就临时开一次浏览器。既然线上不会替你把枪响记录在案,那就只能自己把靶场搭起来。

本章脉络

flowchart TD
    A["/hydration-mismatch<br/>use client 组件里读 typeof window"] --> B["构建期判定 ○ Static"]
    B --> C["服务端发 HTML:值是 server"]
    C --> D["浏览器水合:DOM 被改写成 browser"]
    D --> E["console 与水合相关的消息:0 条"]
    E --> F["不盯 console 改去 grep 产物"]
    F -->|"10 个浏览器 chunk 0.6 MB"| G["5 个关键词全部 0 命中"]
    F -->|"83 个 server chunk 0.9 MB"| H["did not match 命中 1 次<br/>The Server Reference ID did not match the expected format."]
    G --> I["生产产物里没有水合失配的告警代码"]
    H --> I
    I --> J["线上失配静默<br/>DOM 被悄悄改写"]
    J --> K["只能从『用户看到闪一下』这一侧发现<br/>所以要能自己注入"]

图里在说什么。 顶上是失配的成因:一个 "use client" 组件在两个环境里算出两个不同的值,服务端发 server,浏览器水合算出 browser,DOM 被改写。中间是本章的转折——别在 console 里找答案,而是去产物里 grep 那 5 个告警关键词。结果分两支:10 个浏览器 chunk(0.6 MB)全部 0 命中;83 个 server chunk(0.9 MB)里 did not match 只命中 1 次,还是跟水合无关的那条服务端引用报错。两支汇到同一个结论:产物里没有告警代码,所以线上静默。最后落到工程后果上:既然没人会替你报,你就得有一套自己能触发的注入手段。

生产边界

动手:可观察结果

每一步的结论都必须来自你机器上的输出,不能来自"我记得会报"。

产出 判断标准
一次真实的失配 你自己造一页,让服务端 HTML 里的值和水合后 DOM 里的值不一样,两个值都记下来
一条 console 证据 失配真的发生了以后,你的 console 里与水合相关的消息数量是多少——不是"应该有",是"实际是"
一份产物 grep 结果 拿 5 个关键词分别搜浏览器 chunk与 server chunk,记录各自命中次数与上下文
一次"为什么静默"的结论 你能用上面那次 grep 的结果,解释 console 为什么是空的(不是"没看到",是"没有可打印的东西")

完成标志:你能说出一句"失配发生了、DOM 被改了、console 干净,三者同时为真并不矛盾",并且能顺着产物 grep 把这句话证明到底。做到这一步,你就再也不会把"控制台没报错"当成"线上没问题"。

故障注入

自己跑,先预测再观察。

注入 怎么做 观察什么 期望现象
造一次水合失配 建一个 "use client" 组件,渲染 typeof window === "undefined" ? "server" : "browser",跑 next build + next start 服务端 HTML 的值 vs 水合后 DOM 的值 HTML 里是 server,水合后 DOM 变成 browser——失配确实发生(实测原文如此)
盯 console 上一步的同时,把 Playwright 的 console 全量收下来 与水合相关的消息条数 0 条——全部 console 消息类型统计: {}
去产物里找告警 对浏览器 chunk 与 server chunk 分别 grep 那 5 个关键词 每个关键词的命中次数 浏览器侧 5 个全 0;server 侧只有 did not match 命中 1 次,且上下文是 The Server Reference ID did not match the expected format.(与水合无关)
换个"值"再注一次 把 typeof window 换成一个时间戳或随机数,仍放在 "use client" 组件里 值有没有在服务端和浏览器之间分叉 同样会分叉、同样会静默改写——成因不止 typeof window 一种,凡是"两边算出不同值"的写法都在这个模式里
切到开发模式看对照 同一页改成 next dev 跑 有没有水合警告 ⚠️ 本章未取到这条数据:本环境 next dev 的客户端脚本没能水合(ws://127.0.0.1:3003/_next/hmr 连接失败、按钮不响应、DOM 仍是 server)。请在你自己的环境里量,别引用本章结论

第四条注入值得真做一遍,因为它把"成因"从"typeof window 这一个特例"抬到了一类模式:只要一段渲染逻辑在服务端和浏览器会算出不同结果,它就是一个水合失配的候选。 你能想到多少种这样的写法(时间、随机、localStorage、matchMedia、语言/时区),就能预判出多少种线上会静默闪一下的地方。

自测题

  1. 服务端 HTML 里写的是 server,水合后 DOM 是 browser,而 console 里 0 条消息。请说明"失配发生了"和"没有任何日志"这两件事为什么能同时成立。
  2. 为什么"我盯了 console,没看到警告"是一条弱判据,而"产物里 grep 不到告警代码"是一条强判据?两者的反例分别是什么?
  3. 你 grep 到 server chunk 里 did not match 命中 1 次。在宣布"抓到水合的锅"之前,你必须先做哪件事?本章里那条命中的真实上下文是什么?
  4. /hydration-mismatch 被判 ○ Static。一个"静态"页怎么会有水合失配?请把"静态"和"会被水合"这两件事的关系讲清楚。
  5. 既然线上是静默的,为什么"等用户来报"几乎不可行?一个团队应该准备什么样的替代手段?
  6. next dev 下的对照数据本章缺失。请说明缺失的原因,并说明为什么不能据此推断"开发模式也不报"。
  7. 你有一个组件,渲染时读了 new Date().toLocaleTimeString()。按本章的判据,你怎么判断它会不会在生产里静默出问题?

现在能解释什么

上一章:05 章 · 服务端/客户端边界 —— 我们在那里看着客户端组件被水合之后正常点了两下;这一章把镜头转向它水合失败时会怎样:答案是一声不响。

进入 keel 阅读