别再误会水合了:既然 SSR 渲染了 HTML,为什么客户端还要重跑 setup?

30 阅读4分钟

在上一篇文章中,我们聊到了如何通过判断 BOM/DOM 环境来避免 SSR 崩溃。很多同学会产生一个灵魂质疑:

“既然服务端已经把代码跑了一遍,生成了完整的 HTML 发给浏览器,为什么客户端 JS 加载后,还要从 setup 开始完整地再跑一遍?这不是性能浪费吗?”

今天我们来聊透这个话题:水合(Hydration)的真相。

1.  “死代码” 与“活应用”:中间差了什么?

服务端渲染(SSR)生成的 HTML 被称为“死代码”。
它看起来和正常页面一模一样,但它只是一串纯文本。它没有响应式数据,没有事件绑定,更没有 Vue 实例的内存状态。

如果你点击一个由 SSR 生成的按钮,什么都不会发生。

水合(Hydration)的本质,就是将这串“死”的 HTML 字符串,转化为浏览器内存中“活”的响应式应用。

  1. 为什么客户端必须从 setup 重新开始?

要让页面“活”过来,客户端 Vue 必须重建完整的内部状态。这个过程无法跳过 setup,原因有三:

A. 重建响应式系统

服务端执行完渲染后,内存就被释放了。客户端浏览器拿到的只是 HTML 结果。Vue 必须重新运行 setup,创建 refreactive,并重新建立数据与视图的依赖追踪关系。

B. 内存状态重构

服务端无法将 JS 对象的引用(如内存中的 Store 或事件处理器)传递给客户端。客户端必须通过重新执行代码,在本地内存中重新创建这些函数和对象。

C. 生成虚拟 DOM (VNode) 进行比对

这是最关键的一步。Vue 必须生成一套自己的“虚拟 DOM 树”,然后将其与服务端传来的“真实 DOM 树”进行一一比对(Diff)。只有比对通过,Vue 才知道该把事件监听器绑定在哪个 DOM 节点上。

  1. 水合的底层逻辑:是“激活”而非“替换”

这里有一个常见的误解: “水合是不是把服务端的 HTML 删掉,用客户端生成的 DOM 重新塞进去?”

答案是:绝对不是(除非你写错了)。

  • 理想情况(Hydration Success) :Vue 遍历服务端生成的真实 DOM,发现其结构与客户端生成的虚拟 DOM 完全一致。于是,Vue 原地复用这些 DOM 节点,仅仅是给它们贴上事件监听器。这种操作极快,几乎没有 DOM 开销。
  • 失败情况(Hydration Mismatch) :如果两端生成的结构不一致(例如服务端渲染了 A,客户端 setup 跑出来是 B),Vue 会被迫抛弃掉现有的 HTML 结构,重新创建 DOM。这会导致页面闪烁和巨大的性能损耗。
  1. 性能损耗的真相:二次执行的代价

正如你所观察到的,SSR 确实带来了 “二次执行” 的开销:

  1. 服务器跑一次:生成 HTML(优化 FCP)。
  2. 客户端跑一次:进行水合(完成 TTI)。

那么,如何减少这种浪费?

  • 数据同步(Payload) :避免在客户端 setup 里重复请求 API。Nuxt 这种框架会把服务端请求到的数据序列化为 __NUXT_DATA__ 注入到 HTML 中。客户端执行 setup 时直接读取这块数据,跳过网络请求。
  • 避免不一致的逻辑:永远不要在 setup 顶层使用 Math.random() 或 new Date()。如果服务端随机出 5,客户端随机出 8,水合就会崩溃。
  1. 2026 年的前沿:我们真的需要完整水合吗?

开发者们也意识到了全量水合的性能损耗。目前行业正在向更轻量化的架构演进:

  • 独立岛屿架构 (Islands Architecture) :如 Astro。只激活需要交互的组件,静态的文本块在客户端完全不运行 JS。
  • 可恢复性 (Resumability) :如 Qwik。它宣称“水合是纯粹的开销”,通过将应用状态完全序列化,让客户端实现“零水合”启动。

总结

客户端重跑 setup 不是因为 Vue “笨”,而是为了在浏览器内存中重建灵魂(响应式与事件)

作为组件库开发者,你要做的是确保 setup 逻辑在双端运行结果高度幂等。只有这样,水合过程才能像“拉链”一样,让虚拟 DOM 与真实 DOM 完美缝合。

在下一篇(终结篇)中,我们将深入 Nuxt 的工程底层:看一看一份源代码,是如何被拆解成两套完全不同的物理产物的