TT Lab
开始
学习 学习路径 课程

SSR — 服务端先画

水合不会重新绘制

在 TT Lab 中继续学习

一句话总结

水合(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 本来是为了更快显示首屏,但边界之外的一处不一致,就会把这份收益全部抵消。

因此,编写比较器时要遵守两条规则。

在现场相遇的样子

最常见的反馈不是“刷新后会短暂显示另一个值,然后变掉”,而是 值就是错的。水合不会修正文本,所以服务端渲染出的旧值会一直留着,直到下一次状态变化才被更新。于是 bug 报告变成“偶尔数字对不上”,而复现步骤里又漏掉了刷新。

第二常见的情况是只在开发环境出现警告。有些团队为了让警告消失,把 suppressHydrationWarning 大范围加在上层节点上,结果只是警告不见了,错误的值依然留在页面上。而且它只对一层有效,所以下层的不一致仍然会报警告:加得越广,信号反而越差。

最后是时区。服务端用 UTC 渲染的日期,浏览器用本地时区再渲染一遍,午夜前后的日期就会整整错开一天。如果 QA 只在白天测试,永远发现不了。

修复的顺序也是固定的。先不要去消除警告,而是把原因分类:是时间、是 id、是区域设置,还是只有浏览器知道的值。前三种可以通过把生成值的位置集中到服务端来消除,只有最后一种才需要“先让首次渲染保持一致,再推迟处理”。不做这个区分、直接加上抑制,本来能修好的那三种也会被一并掩盖。

下一项实验要做什么

接收服务端树和客户端树,找出不一致的位置并给出路径,实现一个比较器:忽略属性顺序,结构对不上就停止,抑制只允许一层,并为每个不一致附上最近的边界。最后根据比较结果计算需要重新绘制的内容。