水合不会重新绘制
一句话总结
水合(hydration)是给服务端渲染好的 DOM 附加事件处理函数,而不是重新绘制。因此当两端渲染出的内容不同时,页面会悄无声息地一直错下去。
为什么需要它
服务端生成的 HTML 已经显示在浏览器页面上了。客户端把同一个组件再运行一遍,并不是为了生成页面,而是为了弄清楚应该把哪个处理函数附加到哪个 DOM 节点上。所以一旦“两边结果相同”这个前提被打破,处理函数就会附加到错误的节点上。
React 文档对这一点写得很明确:对于无法避免不同的值(例如时间戳),可以使用 suppressHydrationWarning,但它只是一个只对一层有效的逃生舱,而且 React 不会替你修正不一致的文本(hydrateRoot)。也就是说,消除警告并不等于让值变得一致。
原因几乎总是下面四种之一。
| 原因 | 为什么会不同 |
|---|---|
| 时间 | 服务端渲染出的时间和浏览器渲染出的时间不同 |
| 随机数、自增 id | 在两处分别生成,不可能相同 |
| 区域设置(locale)、时区 | 服务端是 UTC,浏览器是用户所在时区 |
| 只有浏览器知道的值 | localStorage、屏幕宽度、Cookie |
工作原理
把生成值的位置集中到一处
无论是时间还是随机数,只要在服务端生成一次,放进状态里传下去,两端就会使用同一个值。在 ssr-core 中把初始状态植入标签,正是为了这件事。区域设置和时区也一样:把格式化交给 Intl.DateTimeFormat,同时明确指定时区和区域设置选项,两端的结果就会相同(Intl.DateTimeFormat)。如果什么都不写,就会采用运行环境的默认值,而问题的全部就在于这个默认值在服务端和浏览器里并不相同。
用自增计数器生成 id 尤其麻烦。文档解释了 React 为什么另外提供 useId:客户端组件完成水合的顺序并不保证与服务端 HTML 生成的顺序一致,所以全局计数器无法对齐(useId)。
确实只能不同的值,推迟处理
只有浏览器才知道的值,服务端没有办法获取。这时正确的做法是让服务端和客户端的首次渲染保持一致,然后再换成浏览器里的值。首次渲染相同,就不存在不一致;值的变化发生在水合结束之后。
不一致必须被限制在局部
同样是不一致,出在哪里,代价完全不同。边界内部的不一致,只需重绘该边界以下的部分;边界之外的不一致,则会导致整个页面被重新绘制。引入 SSR 本来是为了更快显示首屏,但边界之外的一处不一致,就会把这份收益全部抵消。
因此,编写比较器时要遵守两条规则。
- 结构对不上就在当场停止。标签不同,或者子节点数量不同,说明它们往下本来就不是一一对应的。继续往下比较,一个列表里错开一项就会冒出几百个不一致,真正的那一个原因反而被埋在里面。
- 抑制只作用一层。为了放过某一处时间而对它下面的整棵子树视而不见,子树里真正的不一致就永远看不到了。这与上文 React 的规则一致。
在现场相遇的样子
最常见的反馈不是“刷新后会短暂显示另一个值,然后变掉”,而是 值就是错的。水合不会修正文本,所以服务端渲染出的旧值会一直留着,直到下一次状态变化才被更新。于是 bug 报告变成“偶尔数字对不上”,而复现步骤里又漏掉了刷新。
第二常见的情况是只在开发环境出现警告。有些团队为了让警告消失,把 suppressHydrationWarning 大范围加在上层节点上,结果只是警告不见了,错误的值依然留在页面上。而且它只对一层有效,所以下层的不一致仍然会报警告:加得越广,信号反而越差。
最后是时区。服务端用 UTC 渲染的日期,浏览器用本地时区再渲染一遍,午夜前后的日期就会整整错开一天。如果 QA 只在白天测试,永远发现不了。
修复的顺序也是固定的。先不要去消除警告,而是把原因分类:是时间、是 id、是区域设置,还是只有浏览器知道的值。前三种可以通过把生成值的位置集中到服务端来消除,只有最后一种才需要“先让首次渲染保持一致,再推迟处理”。不做这个区分、直接加上抑制,本来能修好的那三种也会被一并掩盖。
下一项实验要做什么
接收服务端树和客户端树,找出不一致的位置并给出路径,实现一个比较器:忽略属性顺序,结构对不上就停止,抑制只允许一层,并为每个不一致附上最近的边界。最后根据比较结果计算需要重新绘制的内容。