最近尤雨溪在深圳举办的 Vue Conf 上郑重宣布了无虚拟 DOM 版 Vue:也就是 Vue Vapor 已经可用了。但目前还处于 Alpha 版本,不建议大家用在生产环境上。
相信尤雨溪也是看到了 Vue 即将抛弃虚拟 DOM 的各种谣言:



于是尤雨溪针对此类谣言也正式做出了回应:Vapor 不是放弃虚拟 DOM,而是为 Vue 插上另一只翅膀。

尤雨溪:从此开发者能在开发效率与运行时性能间自由选择,不再妥协。
那在 Vue Conf 上尤雨溪都是怎么介绍无虚拟 DOM 版 Vue 的呢?我们一起来听一听他的演讲:

Vue 1 的渲染模式有点像 Angular 1,先去遍历 DOM 模板,然后再创建响应式绑定。

而 Vue 2 则采用了虚拟 DOM,当时之所以采用虚拟 DOM 一方面是为了服务端渲染,另一方面也是为了方便用户自己写渲染函数,虚拟 DOM 确实存在一些性能方面的开销。
前面那段话是尤雨溪说的,听完他说的我也忍不住想说两句:当初
Vue 2刚出来的时候尤雨溪并不是这么说的,那时候他给出的说法是Vue 1是节点级更新,每一个动态属性和动态模板都会生成一个对应的Watcher,更新非常精准,不需要什么Diff。在项目小的时候还好,但当项目一大了之后内存占用就会骤然增加,为了解决这个问题才引入的虚拟DOM。一个组件对应一个Watcher,这样Watcher就会少很多,内存自然也就降下来了。 然后组件内Diff,来确定更新哪部分DOM。
时间太久已经记不清在哪说的了,但在知乎找到了他本人类似的回答:

尤:到了 Vue 3 的时候我们对编译器进行了大幅度的更新,通过静态分析可以生成更加优化的渲染函数。而 Vapor 模式会在此基础上更进一步,它的重点在于:

Vapor 模式只支持单文件组件,只支持 <script setup>,也就是说在 Vapor 模式里只能用 Composition Api。但是它的行为跟虚拟 DOM 的行为是一致的,唯一的区别是,它的底层编译输出完全不一样,这个编译输出能大幅度的提升性能。
另一个重点就是它支持与虚拟 DOM 混合使用,也就是说不需要为了用 Vapor 模式而把整个应用重写,你可以在一个现有的应用中的一部分里面,选择性的启用 Vapor,也可以在一个全新的应用中使用纯 Vapor 模式。
Vapor 模式整体设计架构上其实是于 Vue 现有功能几乎没影响的,这也是得益于我们在 Vue 2 到 Vue 3 升级的时候,对于内部架构做了比较好的设计,从而保证了比较良好的可扩展性。

在 Vapor 之前有 compiler-core,compiler-core 上面还有 compiler-dom,compiler-dom 上面又有 compiler-sfc。然后 reactivity 响应式系统是最底层的依赖,接下来是 runtime-core 依赖响应式系统,runtime-dom 又依赖 runtime-core。

现在的话 compiler-dom 和 runtime-dom 更应该被称为 compiler-vdom 和 runtime-vdom。因为在 Vapor 模式之后,我们发现要实现 Vapor 的话只需加入 compiler-vapor 和 runtime-vapor。底层的 compiler-core 和 runtime-core 以及响应式系统可以完全直接复用。这样的话,整个系统的可维护性还是非常强的。
虽然在做虚拟\无虚拟 DOM 兼容的时候需要对原来的某些地方做一些改动,但是整体结构上而言,我们可以保证在 Vapor 模式接入实装之后,对现有的应用不会产生什么实质性的影响。也就是说如果在目前 Alpha 阶段可能还会存在一些细小的问题的话,那么在 Beta 版发布的时候我们的 Ecosystem CI 应该是全过的。
那所谓的不同的编译输出到底是个什么概念?同样的单文件组件代码,上面是虚拟 DOM 的编译输出,下面是 Vapor 模式的编译输出:

VDOM 模式有点像 React JSX 编译后的输出,但 Vue 3 的编译器会做一些静态分析的优化,比方说 Block Tree 的优化、Patch Flags 的优化等。像这些数字都是通过静态分析出来的,使 VDOM 在运行时更新的时候尽可能的跳过不必要的 Diff。但这些优化属于是在一个本身已经有大量 Overhead 的情况下,尽可能地去减少 Overhead。
Vapor 模式的其实是受了 Solid 的启发:

同时我们发现 Svelte 5 的输出也已经变得非常相似:

所以大家会发现它们的性能其实也已经变得十分接近了,虚拟 DOM 的编译其实是在一个相对慢的基础上去进行一点一点的优化。而 Vapor 模式则是反其道而行,如果我们从头开始一个不同的编译,如果要我手写出性能最好的输出,我应该会怎么写?然后再把这个手写的最优的代码,设计成一个适合由编译器输出的格式,最终就得到了这样的代码:

我们来具体分析一下刚才这个 Vue 组件,编译的第一步是分析这个模板中连续的静态结构部分,并生成创建一个包含静态 DOM 片段的工厂函数,这个 t0 就是一个函数。
接下来的第二步是 <script setup> 里面的这部分代码,你会发现 <script setup> 里跟我们模板编译出来的代码是直接放在一起的,在同一个 scope 里面。所以编译出来的代码是比较容易理解的:

而且也只执行一次,这让我觉得它比 React Hook 要符合直觉的多:

第三步:这边调用这个 n0,就是我们之前创建的这个片段的工厂函数,获得了一个真实 DOM 节点。如果我们这个模板很大的话,其实这个里面大部分静态的内容,都会被包含在这里,这边就直接拿到了一块静态的 DOM 片段。我们发现这个 msg 里只有 Text 子节点,它的文字子节点是动态的,所以我们就直接把它给提取出来。提取出来之后,直接针对这个文字子节点进行响应式的绑定:

如果这个 Text 节点嵌套的很深的话,我们这里第三步生成的代码就会用一个比较轻量的 helper 直接进行 DOM 的遍历,直接去抓到嵌套在很里面的那个子节点。这样的话我们获取子节点的这个操作的开销就只有在创建的时候执行一次,从此之后,我们就拿到了所有动态节点的引用。
在回调函数里面的这一部分是每次更新的时都会跑的,它会直接拿到我们之前已经取到的引用,所以在实际更新的时候的开销实际上是很低的:

这其实就是 Vapor 模式的核心思路,难点就在于如何从基于 Vue 的模板去生成符合我们需求的编译输出。然后再去兼容我们现有的各种 API,还有一个更大的难点是要在这个基础上去支持 SSR Hydration,也就是在服务端渲染下的这个水合。
我们现在的 Alpha 是个什么状态呢:

首先,不推荐你立马上生产,这个毕竟是个 Alpha。 只需要在现有的 <script setup> 上面加个 vapor 属性就可以启用了。如果你用的 API 是 Vapor 模式所支持的 API,行为上你甚至都不会感觉到有什么变化,它就是变快了。 如果你想要创建一个纯 Vapor 模式的应用也可以用 createVaporApp。
createVaporApp 的可以避免把虚拟 DOM 的运行时代码拉进来。也就是说如果你用 createVaporApp 去创建一个新应用的话大约只有 7kb 左右,所以它的 baseline 会比虚拟 DOM 要小很多。 Async Component、Transition、KeepAlive、SSR hydration 等功能会在 Beta 前 merge 进去,现在都已经有实现了的 PR 了,目前还在 review 的过程中。等 merge 完了之后的 Beta 基本就处于一个可用的状态了。 我们已经用 Vapor 模式做了一些 TodoMVC 和跑分,那实际有多快呢?我们拭目以待。
剧透:这两天刚 merge 了 Johnson 的几个大的优化 PR,在 merge 这些 PR 之前 Vue 就已经跟 Svelte 和 Solid 基本上处于同一水平线了:

就是你跑多次的话,可能一会儿是 Vue 快,一会儿是 Svelte 快,一会儿是 Solid 快的一个状态。那么在 Johnson 这些优化 merge 进去之后 Vue 应该会是最快的:
