Skip to main content

水合 Hydration 及不一致排查

水合是「把服务端产出的 HTML 重新绑定上事件与状态」。这一步出错,轻则报错,重则交互全丢。

一、把事件和状态重新接上​

水合不是重新渲染。服务端已经产出了 HTML 与一份数据,客户端要做的三件事是:复用已有的 DOM 节点而不是重建、把事件监听器挂上去、把组件状态建立起来。正因为是「复用」,两边必须严丝合缝——一旦客户端算出来的结构与服务端不一致,React 只能警告并丢弃服务端结果重新渲染,用户看到的就是一次闪动。

复用而非重建是水合快的根本原因:首屏的 HTML 已经在那了,浏览器不用等 JS 把整棵树画出来。代价就是这个前提——任何让客户端首帧与服务端 HTML 不同的写法,都会破坏复用。所以水合问题几乎从来不是「渲染错了」,而是「两边算出来的不一样」。

服务端产出 HTML含结构与数据,无交互浏览器首屏可见此时点击还没有反应下载并执行 JS重建组件树水合完成事件接上水合做的事:把事件处理器与状态,重新接到已有的 DOM 上,而不是重新生成 DOM。不一致时会发生什么服务端与客户端输出不同该子树退化为客户端渲染首屏优势消失
图:水合是把事件与状态接到已有 DOM 上;不一致的代价是整棵子树重来

二、时间、随机数与浏览器差异​

服务端 HTML渲染那一刻的值客户端首次渲染必须与上面完全一致能对上?才会绑事件最常见的三个不稳定来源时间与时区new Date() / toLocale随机与标识Math.random / uuid浏览器 APIwindow.innerWidth处理顺序:先把这三类的值挪到服务端或从 props 传入,再考虑 suppressHydrationWarning ——后者只是让警告消失,DOM 错位还在。
图:水合要求两次渲染逐字节一致,排查从「不稳定来源」入手而不是从 DOM 入手

按出现频率排,前几位是:渲染时读了 Date.now() / Math.random()(两次执行值不同)、日期与数字格式化依赖运行环境的 locale 或时区、在渲染阶段读 window 或 localStorage(服务端没有)、第三方脚本在 React 之前改了 DOM、以及非法 HTML 嵌套被浏览器纠正(比如 p 标签里放 div 标签,浏览器会把 div 挪出去,DOM 结构和 React 预期的不一样)。

// 服务端与客户端时区不同 → 水合不一致
<span>{new Date('2026-01-01T08:00:00Z').toLocaleString()}</span>

// 修正:固定时区与 locale,或放到 effect 里再本地化
<span>{new Date('2026-01-01T08:00:00Z').toLocaleString('zh-CN', { timeZone: 'UTC' })}</span>
// 错:渲染阶段用了随机数,两次执行必不一致
function Badge() {
return <span>{Math.random() > 0.5 ? '新' : ''}</span> // 服务端和客户端各掷一次
}
// 修正:随机数放到 state,在 effect 之后决定
function Badge() {
const [flag, setFlag] = useState(false)
useEffect(() => setFlag(Math.random() > 0.5), [])
return <span>{flag ? '新' : ''}</span>
}

前两类(时间、随机数)最好查,因为值就在你写的代码里;最后两类(第三方脚本改 DOM、非法嵌套被浏览器纠正)最难查,因为差异发生在 React 之外、由浏览器悄悄完成。

三、报错信息怎么读​

排查只有一条正路:把服务端返回的 HTML 与客户端首帧摆在一起看。比对着看,而不是靠猜。具体动作是——从服务端响应里取出那段 HTML,再从浏览器里拿 hydration 前的首帧 DOM,逐节点比对,控制台的水合 mismatch 警告会指出第一个不一致的节点位置,顺着它往上找附近的动态值。

// 最小复现:把可疑值分别打在服务端和客户端,确认是否一致
console.log('server:', renderValue()) // 服务端日志
// 客户端再打一次,两次不同就是根因

没有「先看一眼服务端输出」这个动作,后面都是在蒙。先拿到两份输出再谈原因。

四、把不稳定来源隔离掉​

具体做法三条:第一,时间、数字、locale 全部固定——时区与 locale 写死或由统一配置注入,不要依赖运行环境默认;第二,任何依赖浏览器环境、随机数、异步结果的内容,延迟到 useEffect 之后渲染,首屏先用占位;第三,HTML 结构要合法,别写 p 套 div 这类浏览器会重排的嵌套。

// suppressHydrationWarning 只压掉「已知且无害」的单节点差异,不是修复手段
<span suppressHydrationWarning>{formatOnClientOnly()}</span>
// 滥用它会把真问题藏起来:差异未被修正,只是不报警
// 浏览器专属判断放到 mount 之后,不参与首屏
function Clock() {
const [now, setNow] = useState(null)
useEffect(() => setNow(Date.now()), [])
if (now === null) return null // 首屏不渲染时间,避免不一致
return <time>{format(now)}</time>
}

suppressHydrationWarning 只能压掉已知无害的差异(比如一个时间戳文本),它不修正结构、不处理整棵子树。滥用它等于把真问题静音,哪天结构真错了你也看不到警告。

五、流式渲染下的水合次序​

Suspense 边界让渲染可以流式到达:服务端一边算一边把已完成的边界推给浏览器,浏览器对应边水合。Suspense 边界内的组件会延迟水合,外部的内容先就绪。这里的关键是——边界内的延迟不能拖累边界外的顺序,边界外的首屏内容必须自己保证结构稳定,不能依赖边界内的结果。

不要把整页塞进一个 Suspense 边界,否则退化为「整页等最慢的那个」;按内容优先级拆多个边界,能让 header、导航这类不依赖动态数据的部分先水合、先可交互。

六、不一致不只是警告​

suppressHydrationWarning 不是修复手段,它只是把警告压掉。它适合处理「已知且无害」的差异(比如时间戳文本),滥用会把真正的问题藏起来。

水合报错也不都是时间或随机数的问题——DOM 嵌套非法被浏览器纠正同样常见,而且更难查,因为报错信息指向的位置往往不是出问题的位置。

后果上,水合失败不只是控制台多一行红字:整棵子树会退化成客户端渲染,首屏优势直接消失。

水合不一致的排查只有一条路——把服务端和客户端各自的输出摆在一起看,猜是猜不出来的。

水合的报错恢复策略在 react.dev:hydrateRoot 有说明,onRecoverableError 这个参数排查时很有用。