水合 · 把死的 HTML 救活
服务端发来的 HTML 看得见却点不动——它是「死」的。水合(hydration)让客户端 JS 接管这份 HTML,装上事件、恢复状态,把它唤醒成可交互应用。这一章讲透水合的原理、它的「恐怖谷」、以及最坑的水合失配。
水合 · 把死的 HTML 救活
上一章说,SSR 让服务器发来一份带内容的 HTML,浏览器立刻能显示。但有个残酷的真相:这份 HTML 此刻是「死」的——你能看到按钮,但点了没反应;能看到输入框,但打不了字。因为服务端产出的只是一段静态文本,上面没有任何事件监听、没有任何 JavaScript 状态。
把这份「死」HTML 唤醒成一个「活」的、能交互的应用,靠的就是水合(hydration)。
水合到底在做什么
浏览器显示完首屏后,后台会下载那包 JS。JS 跑起来后,框架(React / Vue / Svelte)做一件事:不是把页面重画一遍,而是「接管」已经存在的 HTML。
具体说,React 会在客户端再渲染一遍相同的组件树,然后拿这次的结果去匹配服务端已经放在页面上的 DOM——匹配得上,就不重建 DOM(那样会闪、会丢状态),而是**「认领」现有节点**,只把事件监听器挂上去。于是同一批 DOM 节点,从「死的文本」变成了「活的、连着 React 的组件」。
水合 ≈ 「对暗号接管」。 服务端先把 HTML 摆好(布景已就位),客户端 JS 来了之后,拿着同一套组件代码「对暗号」:确认每个 DOM 节点该归哪个组件管,然后接上电(事件、状态)。整个过程复用现有 DOM,所以用户几乎察觉不到画面变化——变的是「能不能交互」。
恐怖谷:看得见,点不动
水合最反直觉的地方,是它制造了一段**「看得见却点不动」的窗口期**:
- 首屏 HTML 到达 → 用户看到了内容(FCP 达成)。
- 但 JS 还在下载 / 执行 / 水合中 → 页面上的交互全是哑的:点按钮没反应、点链接可能没跳转、表单填不了。
- 水合完成 → 页面才真正「活」过来(TTI,可交互时间达成)。
这段 FCP 和 TTI 之间的空档,是 SSR 特有的「恐怖谷」:内容看起来已经好了,用户去点却发现没反应,体验割裂。应用越大、JS 越多,这段空档越长。 缓解它,正是后面流式渲染、服务端组件要解决的核心问题之一。
水合的代价:一遍活干两次
水合不便宜。为了「认领」DOM,客户端得把所有组件再渲染一遍——服务端已经渲染过一次了,客户端又来一次,同一份渲染逻辑跑了两遍。对一个大型应用,这一遍客户端渲染是又慢又占 CPU的,而且它往往是阻塞的:水合没做完,主线程忙着,页面就卡着。
这就是「同构的双刃」:同一套代码在两端各跑一遍,换来了 SSR 的首屏优势,却也带来了「客户端要把活重做一遍」的开销。整个 SSR 生态这些年的进化——流式水合、选择性水合、岛屿架构、服务端组件——很大程度上都是在回答同一个问题:能不能少水合一点、或者晚一点、或者只水合真正需要交互的那部分? 这条线索,我们在第二站会顺着讲。
最坑的坑:水合失配
水合要求服务端渲染的结果和客户端首次渲染的结果必须一模一样——因为客户端是拿自己的渲染去「对齐」服务端的 DOM。一旦两边对不上,就是水合失配(hydration mismatch),React 会告警,甚至丢弃服务端 HTML 重画(前功尽弃 + 闪烁)。
什么会导致两边不一致?任何「在服务端和客户端会算出不同结果」的代码:
// ✗ 会失配:服务端渲染时和客户端水合时,时间不同
<span>{new Date().toLocaleString()}</span>
// ✗ 会失配:服务端没有 window
<div>{window.innerWidth}</div>
// ✗ 会失配:服务端不知道用户本地时区 / 语言
<span>{new Intl.NumberFormat().format(x)}</span>- 时间 / 随机数:
Date.now()、Math.random()每次不同。 - 浏览器专属 API:
window、localStorage、navigator在服务端根本不存在。 - 本地化差异:时区、语言、货币格式,服务端和用户端可能不一致。
解法通常是:把这类「只能在客户端确定」的内容,推迟到水合之后再渲染——比如用一个 useEffect 在客户端挂载后才设置它(首屏先渲染一个占位 / 服务端能确定的值)。框架也提供了专门的出口:如 suppressHydrationWarning(React)、<ClientOnly>(各框架的「仅客户端」组件)。记住这条铁律:服务端和客户端的首次渲染必须一致——违反它,就等着水合失配的告警和闪烁。
小结与下一章
水合是 SSR 的「点睛」步骤:它让客户端 JS 接管服务端发来的静态 HTML,装上事件与状态,把「死」的页面救「活」。代价是同一份渲染在两端各跑一遍(慢、占 CPU),还留下了「看得见点不动」的空档和「水合失配」这个大坑。
现在我们有了三块地基:CSR 的痛、SSR 是什么、水合。是时候把它们和另外两个词——SSG、ISR——摆在一起,画一张全景图,看清「渲染」到底有哪几种玩法、各自的取舍。这张图也是后面缓存讨论的坐标系。👉 渲染光谱:CSR / SSR / SSG / ISR 一图看清。
参考文献
- [1] React 官方文档.「hydrateRoot.」https://react.dev/reference/react-dom/client/hydrateRoot —— 水合 API 与失配说明。
- [2] web.dev.「Rendering on the Web — Hydration.」https://web.dev/articles/rendering-on-the-web —— 水合的代价与 TTI。
- [3] Addy Osmani.「The Cost of JavaScript.」https://medium.com/@addyosmani —— JS 解析 / 执行开销与设备差异。