一个 SSR 页面上了线,首屏肉眼可见地闪一下:日期先显示"2026-11-12",一瞬间变成"2025-01-01",然后又跳回来。控制台刷出一串 Text content did not match。另一个页面更严重:用户在输入框里打字打到一半,整块表单被重置,光标丢失。
两类问题的根因是同一个:水合(hydration)假设"同样的输入产生同样的输出",而页面里存在"每次执行结果都不同"的东西——当前时间、随机数、本地时区、localStorage、以及"服务端取了一次、客户端又取一次"的数据。
1. 水合做的是"复用",不是"重建"
服务端返回的是已经渲染好的 HTML,客户端拿到后不重建 DOM,而是把事件与状态挂到现有节点上。所以两边的输出必须逐节点一致,否则只能丢弃。
| 阶段 | 服务端 | 客户端 |
|---|---|---|
| 生成 | 渲染组件 → HTML 字符串 | 不执行 |
| 到达浏览器 | 立即可见(可交互前) | — |
| 水合 | — | 执行组件 → 对比 → 挂事件 |
| 不一致时 | — | 警告 + 局部替换或整树重建 |
第三行是"假设"发生的地方:客户端必须再执行一遍组件才能挂事件,于是所有"非确定性"代码都被执行两次。只要两次结果不同,就会出现失配。
// 两次执行,三种常见分歧
服务端: <time>2026-11-12 12:00</time> (服务器时区 UTC)
客户端: <time>2026-11-12 20:00</time> (用户时区 UTC+8)→ 文本失配
服务端: <div id="a7f3">…</div> (随机 id)
客户端: <div id="b91c">…</div> → 属性失配
服务端: <span>3 条未读</span> (缓存里的旧数)
客户端: <span>5 条未读</span> (重新请求)→ 文本失配
2. 三类失配源,判据是"两趟是否可能相同"
| 来源 | 例子 | 两趟能一致吗 | 处理 |
|---|---|---|---|
| 时间与随机 | Date.now()、Math.random()、uuid |
不能 | 客户端独占渲染 |
| 环境差异 | window、localStorage、时区、语言 |
不能 | 挂载后再读 |
| 数据不同步 | 服务端取一次、客户端再取 | 可能但不可靠 | 序列化注入 |
| 纯计算差异 | 浮点、Intl 格式化细节 |
通常可以 | 统一实现与 locale |
第一、二行是硬性的:只要代码读了"服务端没有的东西",两趟必然不同。第三行看起来可以一致,但它依赖两次请求返回同样的数据,而这在有缓存、有分页、有并发写的系统里不可靠——正确做法是把服务端取到的数据序列化进 HTML,客户端直接复用。
// 序列化注入:把服务端的查询结果写进 HTML,客户端不再请求
<script id="__DATA__" type="application/json">
{"unread": 3}
</script>
// 客户端首帧就用这份数据水合,之后(可选)再刷新
const initial = JSON.parse(document.getElementById('__DATA__').textContent);
// 关键:首帧必须与 HTML 完全一致,否则就是失配
3. 失配的两种量级:文本 vs 结构
不是所有失配都一样严重。React 的处理方式决定了它的实际代价。
| 失配类型 | 表现 | 代价 |
|---|---|---|
| 文本内容不同 | 控制台警告,文本被替换 | 小(一次重绘) |
| 属性不同 | 警告,属性被覆盖 | 小 |
| 结构不同(多/少节点) | 整棵子树丢弃重建 | 大(状态丢失、输入框清空) |
| 标签类型不同 | 整树重建 | 最大 |
// 结构失配:节点数量不同 → 整块子树被丢弃重建
服务端 HTML: <ul><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li></ul>
客户端首帧: <ul><li>1</li><li>2</li><li>3</li></ul> ← 数据只注入了 3 条
// 结果:<ul> 整树重建,其内部组件状态(含同级表单输入)全部丢失
第三行就是你看到"打字打到一半表单被重置"的原因:客户端发现"服务端渲染的节点树与我的不一致",只能把这段丢掉重新挂——用户的输入、滚动位置、焦点全部丢失。这类问题在开发环境常常只表现为一条警告,因为开发时数据恰好一致。
4. 两种正确做法,不是"压制警告"
suppressHydrationWarning 只让警告闭嘴,DOM 仍然会被替换。真正的修法只有两条。
| 做法 | 机制 | 适用 | 代价 |
|---|---|---|---|
| 客户端独占(两段式) | 首帧渲染占位,useEffect 后再渲染真实值 |
时间、随机、本地存储 | 首屏有一个占位态 |
| 服务端注入 + 复用 | 数据序列化进 HTML | 业务数据 | 需要 dehydrate/hydrate 逻辑 |
| 固定 locale/时区 | 两端用同一套格式化 | 界面文案里的日期 | 可能不符合用户偏好 |
| 延迟到交互后再算 | 只在用户操作时计算 | 相对时间("3 分钟前") | 首屏不显示该值 |
第一行与第三行经常一起用:界面上的绝对时间用固定时区格式化(保证一致),相对时间("3 分钟前")则改成客户端独占——因为它必然随时间变化,两趟不可能相同。
// 反例:首帧就渲染本地时间 → 必然失配
function Time({ ts }) { return <span>{new Date(ts).toLocaleString()}</span>; }
// 正解:首帧用服务端传下来的字符串,挂载后再切本地化
function Time({ ts, isoText }) {
const [local, setLocal] = useState(null); // 首帧 null
useEffect(() => { setLocal(new Date(ts).toLocaleString()); }, [ts]);
return <span>{local ?? isoText}</span>; // 首帧 = 服务端文本
}
5. 检测:怎么在合并前抓到它
失配最大的问题是"只在某些用户那里出现"。三种手段按成本排序:
| 手段 | 能抓到什么 | 成本 |
|---|---|---|
| 开发环境警告(默认开启) | 结构失配 | 零 |
| E2E 里断言"无 console.error" | 文本失配 | 低 |
生产上报 hydration-error |
真实用户侧的失配 | 需接监控 |
| 固定时区/语言跑 E2E | 环境差异型失配 | 低(CI 配置) |
第四行最容易被漏掉:CI 机器通常是 UTC,开发机是本地时区,于是"开发能复现、CI 不能"或反之。把 E2E 跑在与会话时区不同的环境里,能一次性暴露绝大多数时间相关失配。
// 用不同时区跑同一套 E2E,失配会立刻暴露
$ TZ=UTC npx playwright test
$ TZ=Asia/Shanghai npx playwright test
// 两个都过,才说明时间格式化没有把时区泄漏到首帧
6. 排错:五种"看起来是缓存问题"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 首屏闪一下再变 | 时间/随机在首帧执行 | 改两段式渲染 |
| 输入框内容被清空 | 结构失配导致子树重建 | 查条件渲染的分支 |
| 刷新后正常、首次访问异常 | 客户端二次请求数据不同 | 序列化注入 |
| 开发正常、生产失配 | 时区/locale/数据量差异 | 固定 CI 环境变量 |
| 加了 suppress 就没警告了 | 只是闭嘴 | 真正修法见第 4 节 |
第二行的典型触发是"服务端渲染了 5 条列表,客户端首帧只有 3 条"——节点数量不同,于是结构失配,整块表单被重建。这类问题常被误判成"React 的 bug",实际是数据注入没做。
7. 边界:什么情况不该硬上 SSR
| 场景 | 为什么不必 | 做法 |
|---|---|---|
| 登录后台、强交互 | 首屏价值低,失配风险高 | 客户端渲染 |
| 首屏高度个性化 | 服务端拿不到用户态数据 | 骨架屏 + 客户端渲染 |
| 实时数据看板 | 首屏数据立刻过期 | SSR 骨架 + 客户端订阅 |
| 纯静态内容页 | 无状态、无随机 | 静态生成(最稳) |
| 时间密集的界面 | 两趟不可避免不同 | 客户端独占那部分 |
判断口径一句话:首帧里出现的每一个值,都要能回答"服务端和客户端会不会算出同一个结果"——答"会"的留在服务端渲染,答"不会"或"不确定"的推迟到挂载后。这条判据比"要不要用 SSR"更具体,因为它逐项检查,而不是整体取舍。
8. 自测
- 水合为什么要求两端输出一致?不一致时客户端做了什么?
- 三类失配源里,哪一类"看起来可以一致但实际不可靠"?为什么?
- 文本失配与结构失配的代价差在哪?为什么输入框会被清空?
suppressHydrationWarning解决了什么、没解决什么?- "两段式渲染"与"序列化注入"分别针对哪类失配?
- 为什么在 CI 里用不同时区跑 E2E 能暴露失配?
9. 一句话总结
水合是"复用 DOM",所以它要求服务端与客户端算出同一个结果——所有非确定性来源(时间、随机、时区、本地存储、二次请求)都会破坏这个前提。修法只有两条:客户端独占渲染(首帧占位,挂载后替换)与数据序列化注入(客户端不再二次请求),suppressHydrationWarning 只是让警告闭嘴。判据:逐项回答首帧每个值"两端会不会算出同一个结果",答不出的就推后到挂载之后。