在上一篇文章中,我们聊到了如何通过判断 BOM/DOM 环境来避免 SSR 崩溃。很多同学会产生一个灵魂质疑:
“既然服务端已经把代码跑了一遍,生成了完整的 HTML 发给浏览器,为什么客户端 JS 加载后,还要从 setup 开始完整地再跑一遍?这不是性能浪费吗?”
今天我们来聊透这个话题:水合(Hydration)的真相。
1. “死代码” 与“活应用”:中间差了什么?
服务端渲染(SSR)生成的 HTML 被称为“死代码”。
它看起来和正常页面一模一样,但它只是一串纯文本。它没有响应式数据,没有事件绑定,更没有 Vue 实例的内存状态。
如果你点击一个由 SSR 生成的按钮,什么都不会发生。
水合(Hydration)的本质,就是将这串“死”的 HTML 字符串,转化为浏览器内存中“活”的响应式应用。
- 为什么客户端必须从
setup重新开始?
要让页面“活”过来,客户端 Vue 必须重建完整的内部状态。这个过程无法跳过 setup,原因有三:
A. 重建响应式系统
服务端执行完渲染后,内存就被释放了。客户端浏览器拿到的只是 HTML 结果。Vue 必须重新运行 setup,创建 ref、reactive,并重新建立数据与视图的依赖追踪关系。
B. 内存状态重构
服务端无法将 JS 对象的引用(如内存中的 Store 或事件处理器)传递给客户端。客户端必须通过重新执行代码,在本地内存中重新创建这些函数和对象。
C. 生成虚拟 DOM (VNode) 进行比对
这是最关键的一步。Vue 必须生成一套自己的“虚拟 DOM 树”,然后将其与服务端传来的“真实 DOM 树”进行一一比对(Diff)。只有比对通过,Vue 才知道该把事件监听器绑定在哪个 DOM 节点上。
- 水合的底层逻辑:是“激活”而非“替换”
这里有一个常见的误解: “水合是不是把服务端的 HTML 删掉,用客户端生成的 DOM 重新塞进去?”
答案是:绝对不是(除非你写错了)。
- 理想情况(Hydration Success) :Vue 遍历服务端生成的真实 DOM,发现其结构与客户端生成的虚拟 DOM 完全一致。于是,Vue 原地复用这些 DOM 节点,仅仅是给它们贴上事件监听器。这种操作极快,几乎没有 DOM 开销。
- 失败情况(Hydration Mismatch) :如果两端生成的结构不一致(例如服务端渲染了 A,客户端
setup跑出来是 B),Vue 会被迫抛弃掉现有的 HTML 结构,重新创建 DOM。这会导致页面闪烁和巨大的性能损耗。
- 性能损耗的真相:二次执行的代价
正如你所观察到的,SSR 确实带来了 “二次执行” 的开销:
- 服务器跑一次:生成 HTML(优化 FCP)。
- 客户端跑一次:进行水合(完成 TTI)。
那么,如何减少这种浪费?
- 数据同步(Payload) :避免在客户端
setup里重复请求 API。Nuxt 这种框架会把服务端请求到的数据序列化为__NUXT_DATA__注入到 HTML 中。客户端执行setup时直接读取这块数据,跳过网络请求。 - 避免不一致的逻辑:永远不要在
setup顶层使用Math.random()或new Date()。如果服务端随机出 5,客户端随机出 8,水合就会崩溃。
- 2026 年的前沿:我们真的需要完整水合吗?
开发者们也意识到了全量水合的性能损耗。目前行业正在向更轻量化的架构演进:
- 独立岛屿架构 (Islands Architecture) :如 Astro。只激活需要交互的组件,静态的文本块在客户端完全不运行 JS。
- 可恢复性 (Resumability) :如 Qwik。它宣称“水合是纯粹的开销”,通过将应用状态完全序列化,让客户端实现“零水合”启动。
总结
客户端重跑 setup 不是因为 Vue “笨”,而是为了在浏览器内存中重建灵魂(响应式与事件) 。
作为组件库开发者,你要做的是确保 setup 逻辑在双端运行结果高度幂等。只有这样,水合过程才能像“拉链”一样,让虚拟 DOM 与真实 DOM 完美缝合。
在下一篇(终结篇)中,我们将深入 Nuxt 的工程底层:看一看一份源代码,是如何被拆解成两套完全不同的物理产物的。