最近尤雨溪在深圳举办的 Vue Conf
上郑重宣布了无虚拟 DOM
版 Vue
:也就是 Vue Vapor
已经可用了。但目前还处于 Alpha
版本,不建议大家用在生产环境上。
虽说不建议使用,但这玩意已经处于箭在弦上的状态了,很快就会出现一大堆针对无虚拟 DOM
的面试题了:


那在 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
应该会是最快的:
