
一句话:水合(Hydration) 是「客户端复用服务端生成的 HTML、绑定事件、变成可交互」的过程;水合不匹配(hydration mismatch) 发生在「客户端首次渲染的结果与服务端 HTML 不一致」时——常见原因是用了随机值/当前时间、依赖了浏览器信息、渲染了非法 HTML 结构。
一、水合是什么
SSR 的完整流程:
服务端:
① 渲染 React 树 → HTML 字符串(内容 + 结构)
② 把「渲染时用到的数据」脱水进 HTML(如 <script>window.__DATA__={...}</script>)
③ 返回 HTML
浏览器:
④ 立刻显示 HTML(用户看到内容 —— FCP 好,但【点不动】)
⑤ 下载并执行客户端 JS
⑥ 【水合】:
· 遍历 DOM,把 React 的「虚拟树」和「真实 DOM」对应起来
· 绑定事件监听器
· 恢复状态(从脱水的数据)
⑦ 页面变为可交互(TTI)
⚠️ 关键:水合的前提是「客户端的首次渲染结果与服务端 HTML 一致」——
否则 React 无法把「虚拟节点」和「DOM 节点」正确对应。
为什么要「复用 HTML」而不是「客户端重新渲染一遍」?
① 性能——重新渲染会产生大量 DOM 操作(浪费)
② 体验——复用不会「闪烁」(页面上已经有内容了)
③ 状态——表单输入、滚动位置、媒体播放不会被打断
所以水合的语义是「【采纳】服务端的 DOM,然后【接上】事件」。
二、为什么会不匹配
水合的匹配过程(简化):
① 客户端渲染「同一个组件」→ 得到一棵虚拟树 A
② 服务端返回的 DOM → 对应一棵「预期的树」B
③ React 遍历比较 A 与 B:
· 结构一致 → 采纳 B 并绑定事件(正常)
· 结构不一致 → 【mismatch】
- React 18+ 会「客户端重新渲染这一部分」(放弃复用)
- 开发环境会打印详细警告
- 用户可能看到「内容闪一下变了」
⭐ 根因只有一句话:
「服务端渲染时的输出」与「客户端首次渲染时的输出」不同。
常见的「不同」来源见下一节。
三、六个常见原因与修复
1) 随机值 / 当前时间
// ❌ 服务端与客户端生成不同的值
function Bad() {
return (
<div>
<span>{Math.random()}</span>
<span>{Date.now()}</span>
<span>{new Date().toLocaleString()}</span> {/* ⚠️ 还有时区问题 */}
</div>
);
}
// ✅ 修复:把「不确定的值」放到客户端生成(useEffect / useState + effect)
function Good() {
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
setNow(Date.now()); // ⭐ 只在客户端执行
}, []);
if (now === null) return <span>-</span>; // ⭐ 服务端与客户端首屏一致
return <span>{new Date(now).toLocaleString()}</span>;
}
// ✅ 另一个修复:用「服务端生成的值」通过 props 传下来(保证两端一致)
// 服务端(Server Component)
export default function Page() {
const serverTime = Date.now(); // 服务端生成
return <Clock serverTime={serverTime} />; // ⭐ 传给客户端组件
}
// ✅ 相对时间(避免时区与格式化差异)
function RelativeTime({ iso }: { iso: string }) {
return <time dateTime={iso}>{iso}</time>; // ⭐ 输出原始 ISO 字符串(两端一致)
}
2) 依赖浏览器信息
// ❌ 用「是否存在于浏览器」来分支
function Bad() {
const isBrowser = typeof window !== 'undefined'; // ⚠️ 服务端 false、客户端 true
return <div>{isBrowser ? '浏览器' : '服务端'}</div>;
}
// ✅ 修复:把「浏览器判断」延迟到客户端
function Good() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// ⭐ 首屏(服务端与客户端首次渲染)都渲染「一致的内容」
return <div>{mounted ? <BrowserOnly /> : <Skeleton />}</div>;
}
// ❌ 直接读 window / localStorage / matchMedia
const width = window.innerWidth; // ❌ 服务端没有 window
const theme = localStorage.getItem('theme'); // ❌
// ✅ 修正:用 useEffect 读取(或用「同构 Hook」)
function useTheme() {
const [theme, setTheme] = useState('light'); // ⭐ 首屏用默认值(两端一致)
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved) setTheme(saved);
}, []);
return theme;
}
// ⚠️ 但「首屏用默认值」会造成「主题闪烁」(FOUC)
// ✅ 常见解法:把「初始主题」写进 HTML(服务端读 cookie → 注入 class/style)
// Next.js 里可以在根布局读 cookie 并设置 <html className={theme}>
export default async function RootLayout({ children }) {
const theme = (await cookies()).get('theme')?.value ?? 'light';
return (
<html lang="zh-CN" className={theme}> {/* ⭐ 服务端就确定主题 */}
<body>{children}</body>
</html>
);
}
3) 非法 HTML 嵌套
// ❌ 浏览器会「修正」非法结构 → 服务端 HTML 与客户端预期不一致
function Bad() {
return (
<p>
<div>块级元素不能放在 p 里</div> {/* ⚠️ 浏览器会自动闭合 <p> */}
</p>
);
}
function Bad2() {
return (
<table>
<tr> {/* ⚠️ 缺少 <tbody>,浏览器会补 */}
<td>1</td>
</tr>
</table>
);
}
function Bad3() {
return (
<a href="/a">
<a href="/b">嵌套的 a</a> {/* ⚠️ 非法 */}
</a>
);
}
// ✅ 修复:遵守 HTML 内容模型
// · <p> 里只放「行内内容」(放块级用 <div>)
// · <table> 里显式写 <thead>/<tbody>/<tr>/<td>
// · 不嵌套 <a>、不嵌套 <button>
⭐ 为什么浏览器会「修正」?
HTML 解析器对「非法嵌套」有容错规则(如 <p><div> 会在 <div> 前自动闭合 <p>)。
于是「服务端输出的 HTML 字符串」经过浏览器解析后,DOM 结构与「React 期望的树」不同
→ 水合时 React 发现「DOM 里有它没预料到的节点」→ mismatch。
4) 时区与语言差异
// ❌ 服务端与客户端的时区/语言可能不同
new Date().toLocaleString(); // ⚠️ 服务端 UTC、客户端本地
new Intl.NumberFormat().format(1234.5); // ⚠️ 本地化差异
[1, 2, 3].sort((a, b) => a - b).toLocaleString(); // ⚠️ 同上
// ✅ 修复:显式指定时区与 locale
new Date(iso).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
new Intl.NumberFormat('zh-CN').format(1234.5);
5) 第三方浏览器扩展改写 DOM
症状:本地开发完全正常,但「部分用户」频繁见到 mismatch 警告(尤其在 <body> 上)
原因:浏览器扩展(翻译插件、广告拦截、暗色模式插件)会在「HTML 到达浏览器后、
React 水合前」修改 DOM(如给 <html> 加 class、替换文本)
→ React 水合时发现「DOM 与预期不一致」
✅ 处理方式:
① 无法完全避免(属于外部干扰)
② 可以用 `suppressHydrationWarning` 抑制「特定元素」的警告
③ 上报到监控时过滤掉这类噪音(见 8.6)
// ✅ suppressHydrationWarning 的合理使用(只用于「内容会变」的叶子元素)
<html lang="zh-CN" suppressHydrationWarning={true}>
<body suppressHydrationWarning={true}>
{/* 有些扩展/主题脚本会改 html/body 的属性 → 抑制这一层的警告 */}
</body>
</html>
6) 组件内部状态「初始值依赖客户端」
// ❌ 组件初始化时读「客户端专属数据」
function Bad() {
const [flag] = useState(() => localStorage.getItem('flag') === '1'); // ❌ 服务端报错/不一致
return <div>{flag ? 'A' : 'B'}</div>;
}
// ✅ 初始值必须「两端一致」,客户端专属数据在 Effect 里读
function Good() {
const [flag, setFlag] = useState(false); // ⭐ 默认值(两端一致)
useEffect(() => {
setFlag(localStorage.getItem('flag') === '1');
}, []);
return <div>{flag ? 'A' : 'B'}</div>;
}
四、怎么排查
四步排查法:
① 看控制台的警告信息
React 会给出「具体的节点路径」与「期望/实际」的差异:
"Hydration failed because the server rendered HTML didn't match the client.
... <span> Server: "1" Client: "2""
② 定位「不确定的来源」
在可疑组件里「渲染时打印」——
· 服务端日志与客户端控制台的输出对比(值不同 → 就是它)
③ 二分法缩小范围
· 注释掉一半子树 → 看警告是否消失
· 或者在「外层组件」用 useEffect 单独渲染可疑部分(先排除)
④ 常见「三个必查点」
· 有没有用 Math.random() / Date.now() / new Date()?
· 有没有直接读 window / localStorage / matchMedia?
· 有没有可疑的 HTML 嵌套(<p><div>、<table> 缺 <tbody>、嵌套 <a>)?
// ✅ 一个实用的排查辅助:把「关键值」在两端都打印出来
function DebugComponent({ value }: { value: unknown }) {
console.log('[render]', { value, isServer: typeof window === 'undefined' });
return <div>{String(value)}</div>;
}
// 服务端会打印到终端,客户端会打印到浏览器控制台 → 对比两边的 value
五、React 18 的改进
React 18 对 hydration mismatch 的处理更「健壮」:
① 【不再「整棵树重新渲染」】
React 17 遇到不匹配会「丢弃整棵树的 DOM、客户端全量重渲染」→ 严重闪屏
React 18 会「尽量保留」并「只重新渲染出错的那一小块」(局部回退)
② 【更好的错误提示】
会明确指出「哪个节点」不匹配、期望什么、实际是什么
③ 【与 Suspense 结合的选择性水合】
边界内的内容可以「晚一点水合」;用户点击某块时会【优先水合那块】
④ 【流式 SSR 下的渐进式水合】
shell 先水合,慢的部分到货后再水合
六、常见坑
// ① 用 Math.random() / Date.now() 渲染内容
// ② 用 new Date().toLocaleString()(时区差异)
// ③ 用 typeof window 分支渲染
// ④ 直接读 localStorage / matchMedia / navigator(渲染期)
// ⑤ 非法 HTML 嵌套(<p><div>、缺 <tbody>、嵌套 <a>)
// ⑥ useState 的初始值读「客户端专属数据」
// ⑦ 用「时间相关的相对文案」(如「3 分钟前」)
function Bad({ createdAt }: { createdAt: string }) {
return <span>{minutesAgo(createdAt)} 前</span>; // ⚠️ 服务端与客户端计算的「现在」不同
}
// ✅ 传「绝对时间」或者「服务端算好的字符串」
// ⑧ 用了「只在客户端有效」的第三方库(在渲染期读 window)
// ⑨ 用 suppressHydrationWarning 覆盖过大的范围(掩盖真问题)
// —— 它只抑制「该元素的属性/文本」的警告,不会修复逻辑
// ⑩ 认为「mismatch 只是警告,可以忽略」
// → 它会导致「界面闪变」「事件绑定错位」,必须修
// ⑪ 在开发环境「因为 mismatch 频繁」而关掉 SSR
// ⑫ 用「随机 key」渲染列表(导致 DOM 复用错乱 + mismatch)
// ⑬ 把「用户信息」渲染进「被 ISR 静态化的页面」(缓存串用户 + mismatch)
// ⑭ 用 `crypto.randomUUID()` 在渲染期生成 ID(两端不同)
// ✅ 用 React 19 的 useId(两端一致,见 2.11)
// ⑮ 忽略了「样式注入顺序」导致的 mismatch(CSS-in-JS 场景,需要有确定顺序)
// ⑭ 的正确做法
// ❌
const id = `field-${crypto.randomUUID()}`; // 服务端与客户端不同
// ✅ React 提供 SSR 安全的 ID
import { useId } from 'react';
function Field() {
const id = useId(); // ⭐ 两端一致
return (
<>
<label htmlFor={id}>标签</label>
<input id={id} />
</>
);
}
七、面试延伸
- 「什么是水合(Hydration)?」
水合是「客户端复用服务端生成的 HTML、把事件绑定上去、恢复状态,使页面变成可交互」的过程。完整流程是:服务端渲染 React 树为 HTML 并把数据「脱水」进页面 → 浏览器立刻显示 HTML(内容可见,但点不动)→ 下载执行客户端 JS → 水合(遍历 DOM,把虚拟树与真实 DOM 对应、绑定事件、恢复状态)→ 页面可交互。水合的前提是「客户端的首次渲染结果与服务端 HTML 一致」。 -
「什么是 hydration mismatch?为什么会发生?」
hydration mismatch指「客户端首次渲染的结果与服务端 HTML 不一致」——此时 React 无法把「虚拟节点」与「DOM 节点」正确对应。根因只有一句话:「两端渲染输出不同」,常见来源是:① 随机值 / 当前时间(Math.random()、Date.now());② 依赖浏览器信息(typeof window、localStorage、matchMedia);③ 非法 HTML 嵌套(浏览器会自动修正结构);④ 时区/语言差异(toLocaleString());⑤ 浏览器扩展改写 DOM;⑥ 组件初始 state 依赖客户端数据。 -
「水合不匹配的后果是什么?」
三点:① 界面闪变——React 发现不匹配后会「重新渲染那一部分」,用户会看到内容闪一下(甚至从 A 变成 B);② 事件绑定错位——如果结构不一致,事件可能被绑到错误的节点上;③ 性能浪费——丢失了「复用服务端 DOM」的收益(React 18 会尽量局部回退,但仍有成本)。在开发环境会打印警告;生产环境虽然不打印,但问题依然存在。 -
「怎么修复水合不匹配?」
按原因修复:① 随机值/时间——把「不确定的值」放到useEffect里生成(先用占位保证两端一致),或由服务端生成后通过 props 传下来;② 浏览器信息——不要在渲染期读,改用useEffect+ state(首屏用「两端一致的默认值」);③ 非法嵌套——遵守 HTML 内容模型(<p>里不放块级、<table>显式写<tbody>、不嵌套<a>);④ 时区/语言——显式指定timeZone与locale;⑤ 第三方扩展——无法避免,可在html/body上加suppressHydrationWarning并过滤噪音上报;⑥ 初始 state——首屏用「两端一致的默认值」。 -
「
suppressHydrationWarning应该怎么用?」
它只抑制「该元素自身的「文本/属性」不一致的警告」,不会修复逻辑、也不会影响子树(只作用于直接子级文本与属性)。合理用法:① 「内容本就会变」的叶子元素(如「刚刚更新」的时间戳);② 会被「浏览器扩展/主题脚本」改写的html/body属性。不应该用它来「掩盖真正的逻辑 bug」——那只会让问题隐藏到生产环境(表现为「闪变 + 事件错位」)。 -
「React 18 对水合有什么改进?」
四点:① 不再「整棵树重渲染」——React 17 遇到不匹配会丢弃整棵树的 DOM 并客户端全量重渲染(严重闪屏),React 18 会尽量保留已匹配的部分、只局部回退(体验好得多);② 更清晰的错误提示(指出哪个节点、期望什么、实际什么);③ 与 Suspense 结合的「选择性水合」——边界内内容可以晚一点水合,用户点击某块时优先水合那一块;④ 流式 SSR 下的渐进式水合(shell 先水合、慢的部分到货后补充水合)。 -
「为什么水合期间页面『点不动』?」
因为水合的语义是「复用 HTML 并绑定事件」——在事件绑定完成之前,DOM 上没有任何 React 的事件监听器(React 的事件是「委托到根容器」的,见 4.9)。此时用户看到内容但点击无效。这是 SSR 的固有特性(FCP 好但 TTI 晚)。缓解手段:① 减少客户端 JS(RSC、代码分割、减少 Client Component);② 选择性水合(用户交互的部分优先水合);③ 岛屿架构(Astro)——只对「需要交互的岛屿」水合。 -
「怎么从『架构上』减少 hydration mismatch?」
四条经验:① 把「不确定的值」和「浏览器专属逻辑」都放进useEffect(首屏渲染保持「纯 + 确定」);② 用「服务端确定的初始值」——如主题从 cookie 读(服务端注入class),而不是客户端localStorage;③ 用useId(React 提供的 SSR 安全 ID,替代Math.random()生成 id);④ 避免「渲染期读外部可变状态」(全局变量、document、navigator)。此外「组件渲染保持纯函数」也是必要条件(与 4.3、8.5 的结论一致)。
一句话速记
水合 = 「客户端复用服务端 HTML + 绑定事件 + 恢复状态」→ 页面可交互(水合前看得见但点不动);前提是「两端首次渲染结果一致」,不一致就是 hydration mismatch;六个常见原因是「随机值/当前时间、依赖浏览器信息(
window/localStorage)、非法 HTML 嵌套(<p><div>、缺<tbody>、嵌套<a>)、时区/语言差异、浏览器扩展改写 DOM、初始 state 读客户端数据」;修法是「把不确定值与浏览器逻辑放进useEffect(首屏用两端一致的默认值)、显式指定时区与 locale、遵守 HTML 内容模型、用useId替代Math.random()」;别用suppressHydrationWarning掩盖 bug(它只抑制警告,不修逻辑);React 18 起会「局部回退」而不是「整棵树重渲染」,并支持选择性水合。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!