一、为什么会有 Vapor
Vue 3 从一开始就走的是"编译器 + 虚拟 DOM"的混合路线。模板经过编译器分析后,会生成带 patchFlag 的渲染函数------编译器提前标记出"这个节点的 class 是动态的""这个 fragment 是稳定的",运行时的 diff 算法就可以跳过大量不必要的比较。这套方案在 Vue 3 发布时非常领先,让 Vue 在大多数场景下跑得比同期的 React、Svelte 4 都快。
但虚拟 DOM 终究还是有一层绕不开的开销:你得先造出一棵新的 vnode 树,再拿它跟旧树比一遍,才能知道该往真实 DOM 上做什么操作。哪怕编译器已经帮你把比较范围缩到最小,"生成 + 比较"这两步本身仍然是要花时间和内存的。列表越大、更新越频繁,这层开销就越明显------一个 1000 行的表格,光是 vnode 树就能占到 200KB 左右的内存,在低端安卓设备上很容易触发 GC,帧率从 60fps 掉到 30fps 以下。
Vapor Mode 想解决的就是这个问题。它的思路借鉴了 SolidJS:既然 Vue 的响应式系统本来就是基于 Proxy 的细粒度追踪(读取时自动收集依赖),那为什么不让编译器直接把模板编译成"哪块响应式数据变化,就去更新哪个具体 DOM 节点"的代码,中间那层 vnode 完全跳过?
一句话总结:Vapor 不是换了一种 diff 算法,而是从根上取消了 diff 这个步骤。
二、怎么开启
Vapor 是组件级别的选项,在 <script setup> 上加一个 vapor 关键字就行,模板写法完全不用改:
xml
<script setup vapor lang="ts">
defineProps<{ label: string; value: number }>()
</script>
<template>
<div class="row">
<span>{{ label }}</span>
<span>{{ value }}</span>
</div>
</template>
真正需要注意的是它目前(3.6 beta)的几个硬性限制:
- 只支持 Composition API ,
data()/methods这套 Options API 组件没法直接变 Vapor,得先重写成<script setup>。 - 只支持
<script setup>宏语法 ,手写setup()函数的形式也不行。 <Suspense>不支持,依赖它做异步编排的组件树只能留在 vdom 路径上。- transition、SSR hydration、keepAlive、
<Teleport>等特性支持还不完整。 - Vapor 组件和普通 vdom 组件混用需要接入
vaporInteropPlugin,负责事件、ref、父子组件树在两种模式之间的桥接:
javascript
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.use(vaporInteropPlugin)
app.mount('#app')
现实中的应用大概率会是"Vapor 组件和 vdom 组件混合树"这种渐进迁移的形态,而不是一夜之间全量切换。
三、编译产物长什么样
先看一个最简单的例子建立直觉。<div>{{ count }}</div> 在两种模式下编译出来的东西,思路上有本质区别:
VDOM 模式------编译成一个会重复调用的渲染函数,每次更新都创建新 vnode:
csharp
// 每次 count 变化,都会重新执行这个函数,生成新 vnode,再去 diff
function render() {
return createVNode('div', null, count.value)
}
Vapor 模式------编译成一次性的 DOM 创建 + 一段绑定好的响应式副作用:
scss
const div = document.createElement('div')
const text = document.createTextNode(count.value)
div.appendChild(text)
// count 变化时,只有这一行代码会跑
effect(() => {
text.nodeValue = count.value
})
注意区别:VDOM 模式下"渲染函数"会被反复调用,每次都产出一整棵新树;Vapor 模式下 DOM 创建只发生一次,之后的每次更新都是编译器提前锁定好的、针对具体节点的精确写入。编译器在构建期就已经知道"哪块状态对应哪个 DOM 节点",运行时不需要再去猜。
四、v-if:条件块而不是条件 vnode
把这个思路套到 v-if 上:
xml
<template>
<p v-if="show">Hello</p>
<p v-else>Bye</p>
</template>
Vapor 编译器大致会产出(简化示意):
scss
import { createIf, template } from 'vue/vapor'
const t0 = template('<p>Hello</p>')
const t1 = template('<p>Bye</p>')
function render() {
return createIf(
() => show.value, // 条件函数
() => t0(), // true 分支:clone 出一段真实 DOM
() => t1() // false 分支
)
}
createIf 内部依赖一个叫 DynamicFragment 的占位容器,记录"当前挂载的是哪段 DOM、锚点在哪":
kotlin
function createIf(condition, thenFn, elseFn) {
const frag = new DynamicFragment()
renderEffect(() => {
const value = condition()
frag.update(value ? thenFn : elseFn)
})
return frag
}
class DynamicFragment {
update(renderFn) {
if (renderFn === this.currentFn) return // 分支没变,什么都不做
this.currentFn = renderFn
if (this.block) unmount(this.block) // 卸载旧分支
if (renderFn) {
this.block = renderFn() // 生成新分支的真实 DOM
insert(this.block, this.anchor)
}
}
}
这里没有 vnode,condition() 的返回值变化直接驱动"换不换 DOM"这个二元判断,跳过了"造 vnode → 比较 vnode"的中间过程。
移除了会不会销毁? 会,而且是完整的销毁语义,不是简单隐藏(这点和 v-show 用 CSS display: none 不一样):
- 该分支内所有
effect(子节点的文本绑定、子组件的响应式状态)所在的 effect scope 被整体stop(); - 如果分支里有子组件,会走一遍它的
unmounted生命周期,递归清理内部状态; - 真实 DOM 节点被从父节点上移除;
- 绑定在节点上的事件监听等一并清理。
VDOM 模式下这套卸载逻辑是 patch 算法"发现新 vnode 是 null"时触发的;Vapor 模式下则是编译器直接生成、绑定在 DynamicFragment 上的响应式逻辑触发的------语义一致,只是少了"先造树再发现该删"这一层。
五、v-for:每一项对应一个固定的 DOM 引用
xml
<template>
<li v-for="item in list" :key="item.id">{{ item.text }}</li>
</template>
编译产物大致是:
javascript
import { createFor, template } from 'vue/vapor'
const t0 = template('<li></li>')
function render() {
return createFor(
() => list.value, // source
(itemRef) => { // renderItem
const n0 = t0()
renderEffect(() => setText(n0, itemRef.value.text))
return n0
},
(item) => item.id // getKey
)
}
createFor 维护一个 key -> { block, itemRef } 的 Map,每次 source 变化时:
scss
function createFor(source, renderItem, getKey) {
const keyToBlock = new Map()
renderEffect(() => {
const newList = source()
const newKeys = newList.map(getKey)
// 1. 删除新列表里已经不存在的 key ------ 真卸载
for (const key of oldKeys) {
if (!newKeys.includes(key)) {
unmount(keyToBlock.get(key).block)
keyToBlock.delete(key)
}
}
// 2. 复用已存在的,新增的才创建,未变化的项完全不碰
newList.forEach((item, i) => {
const key = getKey(item)
let entry = keyToBlock.get(key)
if (!entry) {
const itemRef = shallowRef(item)
entry = { block: renderItem(itemRef), itemRef }
keyToBlock.set(key, entry)
} else {
entry.itemRef.value = item // 只触发这一项内部的 effect
}
moveToPosition(entry.block, i) // 位置没变就跳过
})
})
}
跟 v-if 是同一套哲学:更新粒度精确到单项 。1000 行列表里只有第 500 行的文本变了,只有那一项内部绑定的 renderEffect 会重新跑,其余 999 项不会被扫描、不会被比较------这也是 VDOM 模式做不到的:即便有 patchFlag,diff 算法遍历整个 fragment 这件事本身还是要发生的。
结构性变化(增删排序)依然要靠 key 识别"是不是同一项",排序时依然会用类似最长递增子序列的思路去减少 DOM 挪动次数------这点和现在的 v-for 没有本质区别,只是操作对象从"patch vnode"变成了直接 insertBefore 真实节点。
移除的项会销毁吗? 和 v-if 完全一致:effect scope 被 stop,子组件走 unmounted,DOM 节点被移除。
六、两种模式的核心差异,一张表说清楚
| VDOM 模式 | Vapor 模式 | |
|---|---|---|
| 更新触发方式 | 重新执行渲染函数,生成新 vnode 树 | 精确的 effect,只跑依赖变化的那一小段 |
| 找变化的方式 | 新旧 vnode 树 diff(即便有 patchFlag 加速) | 编译期已锁定依赖关系,无需比较 |
| v-if 语义 | 新 vnode 为 null 时触发卸载 | DynamicFragment 响应式切换分支 |
| v-for 语义 | 基于 key 的 vnode 数组 diff(LIS 优化移动) | 基于 key 的 DOM block 复用(同样有移动优化) |
| 卸载/销毁语义 | effect 清理 + 生命周期 + 移除 DOM | 完全一致,只是触发路径更直接 |
| 内存基线 | ~50KB(含 vdom runtime) | ~6KB(仅 @vue/reactivity + vapor 运行时) |
七、值得注意的取舍
Vapor 不是没有代价的免费午餐:
- 调试体验:没有 vnode 树意味着一些依赖虚拟 DOM 的开发者工具、测试工具(比如某些覆盖率统计工具)可能需要适配。
- 生态兼容:像 Vuetify 这类深度依赖 vdom 特性的组件库,短期内还是得跑在 vdom 路径上,通过 interop 插件桥接。
- 不是所有场景都受益:内容为主、交互很少的营销页,渲染成本本来就不是瓶颈,Vapor 带来的提升会很有限。
- 仍是 beta:Vue 官方的表态是"可以在生产环境评估",不等于"应该现在就设为默认"。
八、小结
如果只用一句话概括 Vapor 相对 VDOM 的差异:VDOM 是"先建树、再比较、再更新",Vapor 是编译器提前算好"谁变了该改哪",运行时直接改。这和 SolidJS、Svelte 的思路是同一个方向------把尽可能多的工作从运行时挪到编译期。对 Vue 来说,这条路走通的意义不只是"更快",更是证明了在保留 Options/Composition API、模板语法这套开发者熟悉的心智模型的前提下,依然可以拿到细粒度响应式框架的性能红利。
至于什么时候能放心用在生产项目里------目前还是那句话:先在非核心模块小范围试,多留意官方仓库和 changelog,beta 阶段的 API 和边界情况都可能还会调整。