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 消息类型统计: {} 那个空对象,说的就是"连一条都没有"。
先给两个预测:
- 这是不是说明"根本没出问题"?毕竟一个正经的水合失败,不应该报点什么吗?
- 如果你要在线上确认"到底有没有水合失配",你会用什么办法?盯着用户浏览器的 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 次
一行一行读:
- 浏览器那一侧(10 个文件,0.6 MB):5 个关键词全部 0 命中。 也就是说,送到用户浏览器的那 0.6 MB 代码里,根本没有那几条水合告警的字符串。浏览器连"该说什么"都没有。
- 服务端那一侧(83 个文件,0.9 MB):唯一命中的是
did not match这条关键词,1 次,但它的上下文是The Server Reference ID did not match the expected format.——这是"服务端引用 ID 格式不对",跟水合失配没有半点关系。其余 4 个关键词也是 0。
为什么要分两包 grep?因为浏览器侧和 server 侧是两份独立的产物,判据只有落在会被送到浏览器的那一包里,才有机会在用户面前响。所以"浏览器侧 0 命中"是决定性的那一半:告警代码压根没进浏览器,就没机会被任何人看到。
结论只有一句:生产产物里没有水合失配的告警代码,所以线上是静默的——DOM 被悄悄改写,没有任何日志。
这条判据比"console 里没看到消息"硬得多。原因是它不依赖你盯得对不对:你不是在"观察有没有输出",而是在"检查有没有可能输出"。前者的反例是"我可能看漏了",后者的结论是"这套产物里压根没有那句话,谁也打不出来"。
再换个角度看同一条事实:浏览器那一侧是一份完整、独立的代码包(10 个文件、0.6 MB),它里面没有告警字符串,就意味着"报不报"这件事在你打开页面的很久之前就已经定了。你在页面里盯多久、加多少监听,都改不了这一点——没有的东西,运行时也变不出来。
E7 的原文也把这条落成了工程后果:
→ 生产产物里根本没有水合失配的警告代码,所以线上是静默的:DOM 被悄悄改写,没有任何日志。这类问题只能从"用户看到内容闪了一下 / 数字跳变"这一侧发现。
四、由此推出的工程后果:你得能自己把失配注出来
既然线上是静默的,那"等用户报"就是唯一被动的发现途径,而这条途径几乎不可用——用户会把"闪了一下"当成很正常的事,根本不会报。
所以这一类问题的排查,只能反过来做:你要有一套可复现的、自己能随时触发的注入手段。 因为你知道判据在哪——
- 选一块"服务端和浏览器会算出不同值"的内容。
typeof window === "undefined" ? "server" : "browser"就是最省事的一种:一句话,两个环境两个值,不需要任何外部依赖。 - 把它放进一个
"use client"组件。 只有会被水合的组件才谈得上水合失配;服务端组件根本不会在浏览器里重跑。 - 跑生产构建(
next build+next start),别跑next dev。 你要量的是"线上什么样",那就得在生产模式下看——这一点在本章## 生产边界里还会再强调一次。 - 读两样东西:
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 次,还是跟水合无关的那条服务端引用报错。两支汇到同一个结论:产物里没有告警代码,所以线上静默。最后落到工程后果上:既然没人会替你报,你就得有一套自己能触发的注入手段。
生产边界
- 本课的坐标是 Next.js 16.3.8 + react 19.3.0 + Node 22.22.2,浏览器是 Playwright 自带的 HeadlessChrome 151.0.7922.34。 "5 个关键词 0 命中"是这套组合上的生产产物的事实;换版本请自己重新 grep 一次产物。
- ⚠️ 未验证:开发模式(
next dev)下会不会报水合警告,本章没有数据。 我本想量这个对照,但本环境下next dev的客户端脚本没能水合——console 里是WebSocket connection to 'ws://127.0.0.1:3003/_next/hmr' failed,按钮点了不响应、DOM 里仍然是server。这条对照数据缺失,不得当作已取证的事实来引用。 你要用"开发模式会报、生产模式不报"这个说法,请在自己环境里先量一次。 - "10 个文件 / 0.6 MB"与"83 个文件 / 0.9 MB"是产物体积形状,不是阈值。 换项目会变;要记的是"浏览器侧和 server 侧是两包代码、要分别 grep"这个动作。
- "生产产物里没有告警代码"这条结论,只对本章所用的 5 个关键词成立。 如果某个版本换了告警措辞,你不该只搜这 5 个词——正确做法是先去产物里找到那句真实存在的告警文本,再拿它来搜。
- 本章没有测"失配之后 React 会怎么修复"的细节(比如它是整体重渲这块、还是只改文本)。我们只量到"DOM 里的值从
server变成了browser"这个可观察结果。修复策略的差异未验证。 - "静态页照样会水合失配"这条,是在
/hydration-mismatch被判○ Static这个事实上量的。 别把它推广成"所有静态页都会失配"——静态只说明第一份 HTML 在构建期定稿,和会不会失配是两件事。 new Date()/ 随机数这类"两边会算出不同值"的写法,本章只实测了typeof window一种。 时间戳、随机数、localStorage、matchMedia、语言/时区是否同样失配并静默,未逐个验证;第五节那份候选清单是从实测那一例外推的。
动手:可观察结果
每一步的结论都必须来自你机器上的输出,不能来自"我记得会报"。
| 产出 | 判断标准 |
|---|---|
| 一次真实的失配 | 你自己造一页,让服务端 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、语言/时区),就能预判出多少种线上会静默闪一下的地方。
自测题
- 服务端 HTML 里写的是
server,水合后 DOM 是browser,而 console 里 0 条消息。请说明"失配发生了"和"没有任何日志"这两件事为什么能同时成立。 - 为什么"我盯了 console,没看到警告"是一条弱判据,而"产物里 grep 不到告警代码"是一条强判据?两者的反例分别是什么?
- 你 grep 到 server chunk 里
did not match命中 1 次。在宣布"抓到水合的锅"之前,你必须先做哪件事?本章里那条命中的真实上下文是什么? /hydration-mismatch被判○ Static。一个"静态"页怎么会有水合失配?请把"静态"和"会被水合"这两件事的关系讲清楚。- 既然线上是静默的,为什么"等用户来报"几乎不可行?一个团队应该准备什么样的替代手段?
next dev下的对照数据本章缺失。请说明缺失的原因,并说明为什么不能据此推断"开发模式也不报"。- 你有一个组件,渲染时读了
new Date().toLocaleTimeString()。按本章的判据,你怎么判断它会不会在生产里静默出问题?
现在能解释什么
- 为什么线上"什么都没报"却不代表没问题——失配真的发生了,DOM 被改写了,只是产物里根本没有告警代码,谁也打不出那条日志;
- 为什么"盯 console 没看到"不足以证明清白,而"去产物里 grep 告警关键词"能——前者依赖你的观察,后者检查的是有没有可能输出;
- 为什么一个
○ Static页面也会水合——静态只约束发给浏览器的第一份 HTML,客户端组件的代码仍然会被送到浏览器并接上; - 为什么那一行字会"闪一下"——服务端发的是
server,水合时浏览器算出browser,React 按浏览器这版改写了 DOM; - 为什么这类问题只能靠"可复现的注入"发现——等你从用户那一侧收到"好像闪了一下",通常已经太晚了。
上一章:05 章 · 服务端/客户端边界 —— 我们在那里看着客户端组件被水合之后正常点了两下;这一章把镜头转向它水合失败时会怎样:答案是一声不响。