# 水合 · 把死的 HTML 救活

> 服务端发来的 HTML 看得见却点不动——它是「死」的。水合(hydration)让客户端 JS 接管这份 HTML，装上事件、恢复状态，把它唤醒成可交互应用。这一章讲透水合的原理、它的「恐怖谷」、以及最坑的水合失配。

- 作者：David（道雾轩）
- 专栏：SSR 从入门到精通（https://daiw.org/manual/ssr.md）
- 最后更新：2026-07-14
- 原文：https://daiw.org/manual/ssr/hydration
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 水合 · 把死的 HTML 救活

上一章说，SSR 让服务器发来一份带内容的 HTML，浏览器立刻能**显示**。但有个残酷的真相：这份 HTML 此刻是「**死**」的——你能看到按钮，但**点了没反应**；能看到输入框，但**打不了字**。因为服务端产出的只是一段**静态文本**，上面没有任何事件监听、没有任何 JavaScript 状态。

把这份「死」HTML 唤醒成一个「活」的、能交互的应用，靠的就是**水合(hydration)**。

## 水合到底在做什么

浏览器显示完首屏后，后台会下载那包 JS。JS 跑起来后，框架(React / Vue / Svelte)做一件事：**不是把页面重画一遍，而是「接管」已经存在的 HTML。**

```mermaid
flowchart LR
  A["服务端 HTML<br/>看得见 · 点不动"] --> B["下载并执行 JS"]
  B --> C["框架重建组件树<br/>与现有 DOM 对齐"]
  C --> D["挂上事件监听<br/>恢复状态"]
  D --> E["活的应用<br/>可点击 · 可交互"]
```

具体说，React 会在客户端**再渲染一遍相同的组件树**，然后拿这次的结果去**匹配**服务端已经放在页面上的 DOM——匹配得上，就**不重建 DOM**（那样会闪、会丢状态），而是**「认领」现有节点**，只把事件监听器挂上去。于是同一批 DOM 节点，从「死的文本」变成了「活的、连着 React 的组件」。

<Callout type="info">
  **水合 ≈ 「对暗号接管」。** 服务端先把 HTML 摆好（布景已就位），客户端 JS 来了之后，拿着同一套组件代码「对暗号」：确认每个 DOM 节点该归哪个组件管，然后接上电（事件、状态）。整个过程复用现有 DOM，所以用户几乎察觉不到画面变化——变的是「能不能交互」。
</Callout>

## 恐怖谷：看得见，点不动

水合最反直觉的地方，是它制造了一段**「看得见却点不动」的窗口期**：

- **首屏 HTML 到达** → 用户看到了内容（FCP 达成）。
- **但 JS 还在下载 / 执行 / 水合中** → 页面上的交互全是**哑的**：点按钮没反应、点链接可能没跳转、表单填不了。
- **水合完成** → 页面才真正「活」过来（TTI，可交互时间达成）。

这段 FCP 和 TTI 之间的空档，是 SSR 特有的「恐怖谷」：内容看起来已经好了，用户去点却发现没反应，体验割裂。**应用越大、JS 越多，这段空档越长。** 缓解它，正是后面[流式渲染](https://daiw.org/manual/ssr/streaming)、[服务端组件](https://daiw.org/manual/ssr/server-components)要解决的核心问题之一。

## 水合的代价：一遍活干两次

水合不便宜。为了「认领」DOM，客户端得**把所有组件再渲染一遍**——服务端已经渲染过一次了，客户端又来一次，同一份渲染逻辑**跑了两遍**。对一个大型应用，这一遍客户端渲染是**又慢又占 CPU**的，而且它往往是**阻塞**的：水合没做完，主线程忙着，页面就卡着。

<Callout type="warn">
  这就是「**同构的双刃**」：同一套代码在两端各跑一遍，换来了 SSR 的首屏优势，却也带来了「客户端要把活重做一遍」的开销。整个 SSR 生态这些年的进化——流式水合、选择性水合、岛屿架构、服务端组件——很大程度上都是在回答同一个问题：**能不能少水合一点、或者晚一点、或者只水合真正需要交互的那部分？** 这条线索，我们在第二站会顺着讲。
</Callout>

## 最坑的坑：水合失配

水合要求**服务端渲染的结果**和**客户端首次渲染的结果**必须**一模一样**——因为客户端是拿自己的渲染去「对齐」服务端的 DOM。一旦两边对不上，就是**水合失配(hydration mismatch)**，React 会告警，甚至丢弃服务端 HTML 重画（前功尽弃 + 闪烁）。

什么会导致两边不一致？**任何「在服务端和客户端会算出不同结果」的代码**：

```jsx
// ✗ 会失配:服务端渲染时和客户端水合时,时间不同
<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` 在服务端根本不存在。
- **本地化差异**：时区、语言、货币格式，服务端和用户端可能不一致。

<Callout type="info">
  **解法**通常是：把这类「只能在客户端确定」的内容，**推迟到水合之后再渲染**——比如用一个 `useEffect` 在客户端挂载后才设置它（首屏先渲染一个占位 / 服务端能确定的值）。框架也提供了专门的出口：如 `suppressHydrationWarning`(React)、`<ClientOnly>`（各框架的「仅客户端」组件）。**记住这条铁律：服务端和客户端的首次渲染必须一致**——违反它，就等着水合失配的告警和闪烁。
</Callout>

## 小结与下一章

水合是 SSR 的「点睛」步骤：它让客户端 JS 接管服务端发来的静态 HTML，装上事件与状态，把「死」的页面救「活」。代价是同一份渲染在两端各跑一遍（慢、占 CPU），还留下了「看得见点不动」的空档和「水合失配」这个大坑。

现在我们有了三块地基：CSR 的痛、SSR 是什么、水合。是时候把它们和另外两个词——SSG、ISR——摆在一起，画一张全景图，看清「渲染」到底有哪几种玩法、各自的取舍。这张图也是后面缓存讨论的坐标系。👉 [渲染光谱：CSR / SSR / SSG / ISR 一图看清](https://daiw.org/manual/ssr/rendering-spectrum)。

## 参考文献

- [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 解析 / 执行开销与设备差异。
