在 Next.js 开发中,你一定会遇到一个词:水合(Hydration) 。
很多同学第一次听到这个词会一脸懵:水合?是化学反应吗?跟渲染有什么关系?
其实说白了,水合就是让服务端渲染出来的"静态 HTML"活过来的过程。这篇文章用最直白的语言,带你彻底搞懂水合的原理、流程、坑点和最佳实践。
一、水合到底是什么?
想象一下这个场景:
你在餐厅点了一份水饺,服务员端上来一盘已经煮熟的饺子 (服务端渲染好的 HTML)。
饺子看起来完整、能吃,但如果你要蘸醋、加辣椒油,得自己动手。
水合就是你自己调蘸料、动筷子的过程------让静态的饺子变成一顿可以"交互"的饭。
对应到 React:
- 服务端渲染(SSR) :服务器把组件跑一遍,生成完整的 HTML 字符串发给浏览器。此时页面有内容但无交互,就像煮熟的饺子,能吃但没蘸料。
- 水合(Hydration) :浏览器下载并执行 JS 后,React 在客户端接管这个静态 HTML,给每个元素绑定事件监听器、恢复组件状态。于是,按钮能点了,输入框能打字了,页面"活"了。
一句话概括:水合是 React 将事件监听器和内部状态附加到服务端渲染的静态 HTML 上的过程。
二、为什么需要水合?
你可能会问:SSR 已经返回了完整的 HTML,为什么还要再执行一遍 JS?
因为 HTML 只是 UI 的"快照",没有交互能力。
服务端渲染的 HTML 长这样:
css
<button>点击我</button>
但这个按钮在浏览器里是"死"的,点击没反应。因为没有任何 JS 给它绑定 onClick 事件。
要让按钮能响应点击,必须执行客户端 JS,React 内部会创建一个虚拟 DOM 树,然后与服务器生成的真实 DOM 对比,最后把事件监听器"贴"上去。这个过程就是水合。
没有水合,SSR 页面就是一张漂亮的静态截图,而不是一个可用的应用。
三、水合的具体流程
以 Next.js 的一个客户端组件为例:
javascript
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
点击次数:{count}
</button>
);
}
这个组件在请求时会经历以下几个阶段:
1. 服务端渲染(SSR)
服务器执行组件函数,生成 HTML:
css
<button>点击次数:0</button>
注意:此时 onClick 事件不存在 ,因为事件监听器无法被序列化成 HTML。
同时,组件状态 count 的初始值 0 被渲染到了 HTML 里。
2. 客户端加载 JS
浏览器收到 HTML 后,会并行下载对应的客户端 JS 包(包含 React 运行时和该组件的代码)。
3. 水合(Hydration)
JS 加载完成后,React 在客户端重新执行一遍组件函数 ,构建出一棵虚拟 DOM 树。
然后,React 将这棵虚拟 DOM 树与服务器生成的真实 DOM 进行对比(这个过程叫 hydrateRoot)。
如果两者结构一致,React 就会:
- 保留服务器生成的 DOM 节点(不重新创建)
- 附加事件监听器(
onClick等) - 恢复组件状态(
useState等)
从此,按钮就可以点击了,count 状态也能正常更新。
4. 交互阶段
用户点击按钮 → 触发 onClick → setCount 更新状态 → React 在客户端重新渲染 → 只更新变化的部分。
关键点:水合只做一次,发生在页面首次加载时。 之后的所有更新都是客户端正常的 React 渲染。
四、水合与 CSR 的区别
很多同学会把水合和 CSR 搞混,觉得"都要在客户端执行 JS,有什么区别?"
区别很大:
| 渲染方式 | 客户端初始 DOM | React 在客户端的动作 |
|---|---|---|
| CSR | 空 #root |
从零创建整个 DOM 树 |
| SSR + 水合 | 服务器生成的完整 HTML | 复用已有 DOM,只附加事件 |
CSR 就像给你一袋面粉和馅料,让你自己包、自己煮 。
水合则是餐厅已经煮好饺子端上来,你只需要蘸醋吃。
水合比 CSR 更快,因为浏览器不用等待 JS 下载执行后才开始渲染页面,HTML 已经在首屏显示出来了。水合只是"锦上添花"地添加交互。
五、水合失败:最常见的坑
水合有一个严格的要求:服务器渲染的 HTML 必须和客户端第一次渲染的虚拟 DOM 完全一致 。
如果不一致,React 就会报错或产生奇怪的行为。
看一个经典的错误示例:
javascript
'use client';
export default function BadComponent() {
return (
<div>
当前时间:{new Date().toLocaleTimeString()}
</div>
);
}
为什么会出问题?
- 服务器渲染时,
new Date()是服务器时间,比如12:00:00。 - 客户端水合时,
new Date()是浏览器本地时间,比如12:00:01(甚至不同时区)。 - 两者不一致,React 发现虚拟 DOM 和真实 DOM 不匹配,就会报错:
sql
Hydration failed because the initial UI does not match what was rendered on the server.
解决方法:
- 把动态内容放到
useEffect里,水合完成后再更新:
javascript
'use client';
import { useEffect, useState } from 'react';
export default function GoodComponent() {
const [time, setTime] = useState('');
useEffect(() => {
setTime(new Date().toLocaleTimeString());
}, []);
return <div>当前时间:{time}</div>;
}
- 使用
suppressHydrationWarning属性(不推荐,治标不治本)。
水合失败的常见原因:
- 使用了
Math.random()、Date.now()等不确定值 - 直接访问
window、localStorage等浏览器 API - 使用了浏览器特有的差异(如不同时区、不同语言)
- 服务器和客户端的依赖版本不一致
记住:水合要求服务器和客户端第一次渲染结果完全一致。任何不确定的东西,都放到
useEffect里去做。
六、Next.js 中的水合策略
Next.js 的 App Router 对水合做了很多优化:
1. 服务端组件不参与水合
只有 'use client' 组件才会被水合。服务端组件没有客户端 JS,它们的 HTML 输出后就是最终的静态内容。所以:
- 服务端组件永远不会水合,也没有事件监听器。
- 客户端组件先服务端预渲染,再在客户端水合。
2. 选择性水合(Selective Hydration)
React 18 引入了选择性水合 ,可以优先水合用户正在交互的部分,延迟水合其他部分。
比如一个页面有 Header(客户端组件)和 Sidebar(客户端组件),用户先点击了 Header,React 可以优先水合 Header ,让用户立刻得到响应,Sidebar 稍后再水合。
这大大提升了页面的可交互时间(TTI)。
3. 流式 SSR 与水合
Next.js 支持流式渲染 ,服务器可以边渲染边发送 HTML 片段。
浏览器可以更早地开始显示内容,同时客户端 JS 也在并行加载,从而实现边加载边水合。
七、总结
水合是 SSR 和 CSR 之间的桥梁,它让服务端渲染的静态页面具备了客户端交互能力。
- 概念:水合是 React 将事件监听器和状态附加到服务端 HTML 的过程。
- 流程:SSR 生成 HTML → 浏览器加载 JS → React 对比虚拟 DOM → 绑定事件 → 页面可交互。
- 与 CSR 区别:水合是"复用已有 DOM",CSR 是"从零创建 DOM"。
- 最大坑:服务器和客户端渲染结果必须一致,否则水合失败。
- Next.js 优化:选择性水合、流式 SSR,让交互更快。
用一个比喻收尾:
服务端渲染是给你端上一盘煮好的水饺,水合是你自己调蘸料、动筷子,把这盘饺子真正吃进肚子里。
下次面试官问你"什么是水合",别再说"就是 hydration",你可以告诉他:水合是让静态 HTML 活过来的过程,是 SSR 和 CSR 之间的黏合剂。