2520 字
约 8 分钟
12
S14-A27 · 水合失配:为什么"服务端渲染的 HTML"和客户端对不上

一个 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. 自测

  1. 水合为什么要求两端输出一致?不一致时客户端做了什么?
  2. 三类失配源里,哪一类"看起来可以一致但实际不可靠"?为什么?
  3. 文本失配与结构失配的代价差在哪?为什么输入框会被清空?
  4. suppressHydrationWarning 解决了什么、没解决什么?
  5. "两段式渲染"与"序列化注入"分别针对哪类失配?
  6. 为什么在 CI 里用不同时区跑 E2E 能暴露失配?

9. 一句话总结

水合是"复用 DOM",所以它要求服务端与客户端算出同一个结果——所有非确定性来源(时间、随机、时区、本地存储、二次请求)都会破坏这个前提。修法只有两条:客户端独占渲染(首帧占位,挂载后替换)与数据序列化注入(客户端不再二次请求),suppressHydrationWarning 只是让警告闭嘴。判据:逐项回答首帧每个值"两端会不会算出同一个结果",答不出的就推后到挂载之后。

S14-A27 · 水合失配:为什么"服务端渲染的 HTML"和客户端对不上
https://www.clxhxhhr.top/posts/6735/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。