Vue 3 设计与实现
第 9 章 Vapor Mode:无虚拟 DOM 的编译目标
第 9 章 Vapor Mode:无虚拟 DOM 的编译目标
本章要点
- Vapor Mode 的设计动机:为什么 Vue 团队要在虚拟 DOM 之外开辟第二条渲染路径
- 编译策略的根本转变:从"生成 VNode 创建代码"到"生成 DOM 操作指令"
- Vapor 编译器的完整流水线:IR 生成、指令选择、代码输出
- 运行时的极简设计:无 VNode、无 Diff,renderEffect 直接排进同一个调度器的轻量执行模型
- 响应式驱动的精准更新:Effect 如何直接绑定到 DOM 操作
- 与传统 VDOM 模式的互操作:同一应用中两种模式的共存机制
- 性能对比:Bundle Size、首屏渲染、更新效率的成本结构估算
- Vapor Mode 对 Vue 生态和未来架构演进的深远影响
前八章中,我们深入剖析了 Vue 3 的响应式系统与编译器。你已经知道,Vue 3 通过 PatchFlags、Block Tree、静态提升等编译期优化,将运行时 Diff 的开销大幅压缩。但无论怎么优化,只要渲染路径上仍然存在"创建 VNode → Diff VNode → Patch DOM"这条链路,就始终有一层抽象的开销无法消除。
2023 年,尤雨溪在多场大会演讲中公开了 Vapor Mode 的设想;同年 11 月 9 日,vuejs/vue-vapor 仓库提交了第一个 feat: init vapor package,Vapor 正式开工。这个名字暗示了它的本质——像水蒸气一样,虚拟 DOM 这层"水"被蒸发掉了,只留下最本质的东西:响应式状态到 DOM 操作的直接映射。
本章将完整拆解 Vapor Mode 的编译器与运行时。如果说前几章是在研究 Vue 的"经典力学",那本章就是它的"量子跃迁"——同样的模板语法,全新的执行模型。
本章在全专栏里的特殊位置
前八章讲的都是 Vue 一直以来的核心机制——响应式、模板编译、VDOM patch——这些是"Vue 过去十年"。本章是全专栏里最有未来感的一章:Vapor Mode 是 Vue 接下来五年的主战场。Vue 3.6 是它首次进入正式发布线的版本(写作时 3.6.0 正式版尚未发布),Vapor 的 SSR 激活、Transition / KeepAlive / Teleport 在 rc.4 里都已经有了实现(Vapor 自己的 <Suspense> 在 rc.4 里还只是 TODO 占位),接下来还会有更多东西(生态组件库的 Vapor 构建、DevTools 支持、更多编译期优化)陆续落地。读懂本章,你拿到的是"下一代 Vue"的入场券。
这一章也会反复呼应前面几章的核心概念:第 3 章讨论的"精确响应式" Vapor 做到了极致(不再通过 VNode 缓冲,而是 effect 直接绑定 DOM);第 4、5 章的 ref / effect / effectScope 是 Vapor 运行时的底层引擎;第 7、8 章的模板编译器给了 Vapor 编译器基础——Vapor 只是"换了一种 codegen 策略";第 10 章将要讲的组件系统在 Vapor 下表现不同、我会在 9.6 节详细拆解。
读完本章你应该能回答两个问题:(1)Vapor 和传统 VDOM 模式的本质差异是什么?(2)什么样的组件适合切 Vapor、什么不适合?这两个问题的答案决定了你是否能在真实项目里用好 Vapor。
9.1 为什么需要 Vapor Mode
这一节可能是全专栏最重要的"前置理解"——Vapor 不是简单的优化补丁,它是对一个十年基础假设的重新审视。2013 年 React 引入虚拟 DOM 时是一次革命、但十年后这个"革命"开始显露出自己的天花板。理解为什么 Vue 团队要在 VDOM 之外开辟第二条路,你才能理解 Vapor 的所有设计决策。
虚拟 DOM 的"不可压缩开销"
在讨论具体数字之前,我想先打一个比方:虚拟 DOM 就像一个"翻译官"——开发者用 JavaScript 对象描述 UI、翻译官把它们翻译成真实 DOM 操作。翻译官非常必要(让开发者写声明式代码),但每次渲染都要重新翻译整个剧本(生成新 VNode 树)、还要对比新旧剧本找出差异(diff),这些工作都是"翻译的间接成本"。无论翻译官多熟练、间接成本都存在。Vapor 做的事等于"让编译器在构建时提前把剧本翻译好"——运行时只要按指令执行、不再需要翻译官。这就是 Vapor 能拿掉"不可压缩开销"的根本原因:把翻译工作从运行时移到了构建时。
让我们先量化传统 VDOM 模式下一次更新的成本:
// 传统 VDOM 模式下,一个简单的计数器组件
const Counter = {
setup() {
const count = ref(0)
return () => h('div', [
h('span', { class: 'label' }, 'Count: '),
h('span', { class: 'value' }, count.value),
h('button', { onClick: () => count.value++ }, '+1')
])
}
}
当 count 从 0 变为 1 时,更新链路是这样的:
count 变化
→ 触发组件的 renderEffect
→ 执行 render 函数,创建新的 VNode 树
→ h('div', ...) 创建 div VNode
→ h('span', ...) 创建两个 span VNode
→ h('button', ...) 创建 button VNode
→ patch(oldVNode, newVNode)
→ patchElement(div)
→ patchChildren(oldChildren, newChildren)
→ patch(oldSpan1, newSpan1) // 静态节点,跳过
→ patch(oldSpan2, newSpan2) // 文本变化
→ hostSetElementText(el, '1')
→ patch(oldButton, newButton) // 无变化,跳过
即使有 PatchFlags 优化,我们仍然需要:
- 创建完整的 VNode 树(即使大部分节点没有变化)
- 逐层比对(即使 Block Tree 已经扁平化了 dynamic children)
- 维护 VNode 对象的生命周期(创建、引用、GC)
这三项开销,在 VDOM 架构下是结构性的——你无法通过更聪明的 Diff 算法来消除它们。正如 Svelte 的 Rich Harris 所言:"最快的代码是不存在的代码。"
从 Svelte 和 Solid 获得的启示
Vapor Mode 的出现不是凭空而来——它站在前人的肩膀上。在 Vue 之前,Svelte 和 Solid.js 先后用几年时间证明了"无 VDOM"路线不仅可行、还能取得惊人的性能优势。它们的成功给了 Vue 团队重要参考——不仅验证了技术路线、还暴露出各种实现陷阱(编译器复杂度、控制流处理、组件通信)。Vue 的 Vapor 某种程度上可以视为"吸取了 Svelte 和 Solid 经验教训之后的第三代无 VDOM 框架"——它避开了前两者踩过的坑、继承了它们验证过的好想法。这种"后发优势"在开源世界里很常见——创新者先趟雷、跟随者站在肩膀上走得更远。
在 Vue 之前,Svelte 和 Solid.js 已经证明了"无 VDOM"路线的可行性:
| 框架 | 策略 | 更新粒度 |
|---|---|---|
| Svelte | 编译期生成命令式 DOM 操作 | 语句级 |
| Solid.js | 编译期 + fine-grained reactivity | 表达式级 |
| Vue Vapor | 编译期 + Alien Signals | 表达式级 |
Vue Vapor Mode 在这个大背景下仍然有其独特价值:它不是一个新框架,而是同一框架的第二种编译目标。模板语法和 <script setup> 写法都不用变,只需在 SFC 上加一个 vapor 属性(<script setup vapor>,不加则默认仍走虚拟 DOM),编译器就会为这个组件输出 Vapor 代码而不是 VDOM 代码。这意味着:
- 你可以在同一个应用中混用两种模式
- 生态系统中的 Composition API 代码完全兼容
- 迁移成本很低(前提是组件已用 Composition API 编写,见 9.10 节)
Vapor Mode 的设计目标
任何架构级升级都应该有清晰的目标——"比前代快"这种模糊目标不够用。尤雨溪在公开演讲里给出的三个目标是值得学习的"技术决策模板":每个目标都可度量(能跑 benchmark 验证)、可达成(已经有参考实现 Svelte/Solid 证明可行)。这种"可度量 + 可达成"的目标设定是把"想做升级"变成"做成升级"的关键。
Vapor 的三个核心目标:
- 更小的 Bundle:不需要把 VDOM 渲染器(
renderer.ts、vnode.ts、diff 相关代码)打进产物,改由@vue/runtime-vapor的 helper 承担(调度器等少量基础设施仍复用 runtime-core) - 更快的更新:跳过 VNode 创建和 Diff,直接从响应式变化映射到 DOM 操作
- 更低的内存:不创建 VNode 对象,不维护新旧两棵树
9.2 Vapor 编译器架构
Vapor 编译器是 Vue 3.6 最大的单一新增组件——整整一个 @vue/compiler-vapor 包。但它不是从零起步的——和传统编译器共享了 Parse 阶段(前端),只在 Transform 和 Codegen(中后端)分叉出自己的路径。这种"共享前端、分叉后端"的设计是 Vapor 能在 Vue 3.6 被快速落地的工程前提——如果 Vapor 要求用户学新的模板语法、写新的组件格式,生态将要面对巨大迁移成本;保持模板语法不变、只在生成代码上分叉,意味着用户代码几乎不用改。
编译流水线对比
对比两种编译流水线是理解 Vapor 架构最直接的方式。请特别注意下面两条流水线的分叉点——它们在 Transform 阶段开始分离:VDOM 路径生成"返回 VNode 的 render 函数";Vapor 路径生成"直接建 DOM、返回 DOM 节点(Block)的 render 函数"。这个分叉点正是 Vue 3.6 加入 Vapor 所有新逻辑的地方——Parse 阶段(tokenizer + parser)在分叉前是共享的,Transform 与 Codegen 在分叉后各走各的路。这种"共享最大、分叉最小"的设计正是第 2 章讨论过的"窄接口 = 自由度"工程哲学在这里的具体体现。
传统模式和 Vapor 模式共享相同的 Parse 阶段,但在 Transform 和 Codegen 阶段完全不同:
传统模式:
Template → Parse → AST → Transform → AST(with codegenNode) → Codegen → render()
↓
h() / createVNode()
Vapor 模式:
Template → Parse → AST → IR Transform → VaporIR → Codegen → render()
↓
DOM 操作指令(返回 Block)
flowchart LR
A[Template String] --> B[Parse]
B --> C[AST]
C --> D{Mode?}
D -->|VDOM| E[VDOM Transform]
D -->|Vapor| F[Vapor IR Transform]
E --> G[VDOM Codegen]
F --> H[Vapor IR]
H --> I[Vapor Codegen]
G --> J["render() + h()"]
I --> K["render() + DOM ops"]
Vapor IR:中间表示
"IR"(Intermediate Representation,中间表示)在编译器理论里是一个核心概念——LLVM、V8、Rust 编译器都有自己的 IR。IR 的价值在于"把问题从 AST 空间映射到一个更接近目标代码的空间"——AST 是源代码的树状表示,离最终 DOM 操作还很远;Vapor IR 已经是"DOM 操作指令的序列",离最终代码只差一步。在 AST 和最终代码之间插入 IR 有两个好处:(1)分步骤降低复杂度——一次性从 AST 跳到最终代码认知负担太大、分两步每一步都能看清;(2)便于后续优化——IR 是可遍历、可分析的数据结构,未来可以在这一层做更多分析和优化(死代码消除、公共子表达式提取、指令合并等)。第一版 Vapor 可能还没用到这些高级优化,但 IR 的存在给未来留了空间。
Vapor 编译器引入了一个全新的中间表示(Intermediate Representation),这是传统编译器中不存在的层级。Vapor IR 不是 AST 的简单变换,而是一种面向 DOM 操作的指令序列:
// packages/compiler-vapor/src/ir/index.ts:74(rc.4,有删节)
export interface RootIRNode {
type: IRNodeTypes.ROOT
node: RootNode
source: string
template: TemplateRegistry // 静态模板片段登记表
component: Set<string> // 使用到的组件
directive: Set<string> // 使用到的指令
block: BlockIRNode // 根 block
hasTemplateRef: boolean
}
export interface BlockIRNode extends BaseIRNode {
type: IRNodeTypes.BLOCK
node: RootNode | TemplateChildNode
dynamic: IRDynamicInfo // 动态节点信息
tempId: number
effect: IREffect[] // 此 block 的副作用(expressions + operations)
operation: OperationNode[] // 一次性操作指令序列
returns: number[] // 返回的节点 id
}
export interface IRTemplate {
content: string // 模板 HTML 片段
ns: Namespace
root: boolean // 是否组件根(对应 TemplateFlags.ROOT)
static: boolean // 是否纯静态(对应 TemplateFlags.STATIC)
}
// 操作指令类型
export enum IRNodeTypes {
ROOT,
BLOCK,
SET_BLOCK_KEY,
SET_PROP, // 设置属性 / class / style
SET_DYNAMIC_PROPS, // v-bind="obj"
SET_TEXT, // 设置文本内容
SET_EVENT, // 静态事件名
SET_DYNAMIC_EVENTS, // v-on="obj"
SET_HTML, // v-html
SET_TEMPLATE_REF, // ref
INSERT_NODE, // 插入节点
CREATE_COMPONENT_NODE,// 创建组件节点
SLOT_OUTLET_NODE, // <slot> 出口
DIRECTIVE, // 自定义指令 / v-show / v-model
IF, // v-if
FOR, // v-for
KEY, // 带 key 的 fragment
GET_TEXT_CHILD,
}
这套 IR 设计的精妙之处在于:它将模板的静态结构和动态行为完全分离。静态部分被登记进 TemplateRegistry(template.entries,每项一个 HTML 片段字符串),动态部分被编码为操作指令。
这种"静动分离"的思路是所有高性能 UI 框架的共同哲学。React 的 VDOM 是"全动态"模型——整棵树每次渲染都重新构建;Vue 3.0 的 Block Tree + PatchFlag 是"部分静态"的优化——运行时识别哪些节点是静态的;Vapor 把这个优化推到了编译期的极致——静态部分在编译时就被固化为一个 HTML 字符串、运行时只处理动态操作。这种推进路线是 UI 框架发展的主线:每次进步都是把更多"运行时动态处理"推到"编译时静态确定"。Vapor 代表了这条主线目前的最前沿。
从 AST 到 Vapor IR 的转换
理论讲完了,让我们看一个具体的例子——用一个最简单的模板看 Vapor 编译器是怎么把它一步步从 AST 转成 Vapor IR 的。这个"跟着代码走一遍"的过程对理解编译器内部极其重要——抽象的流程图看再多遍也不如亲眼看一次转换过程走心。
让我们跟踪一个具体的模板,看它如何被转换为 Vapor IR:
<template>
<div class="container">
<h1>{{ title }}</h1>
<p :class="textClass">{{ message }}</p>
<button @click="handleClick">Click me</button>
</div>
</template>
第一步:提取静态模板
编译器扫描 AST,识别出所有静态部分,生成一个模板字符串:
// 提取的静态模板
const template = `<div class="container"><h1></h1><p></p><button>Click me</button></div>`
注意:{{ title }}、{{ message }}、:class="textClass"、@click="handleClick" 都被剥离了。它们将以操作指令的形式被还原。
这一步提取极其关键——它是 Vapor 性能优势的核心来源。HTML 字符串交给浏览器的 HTML 解析器(innerHTML)一次性建好结构,之后每次都只是 cloneNode,通常比用 JavaScript 逐个创建元素、设置属性更省。静态部分越多、Vapor 相对 VDOM 的优势越大。
第二步:生成操作指令
// 生成的 IR 操作指令(简化表示)
const operations = [
{ type: SET_TEXT, element: 'h1', values: [ctx.title] },
{ type: SET_TEXT, element: 'p', values: [ctx.message] },
{ type: SET_PROP, element: 'p', prop: { key: 'class', values: [ctx.textClass] } },
{ type: SET_EVENT, element: 'button', key: 'click', value: ctx.handleClick }
]
第三步:生成副作用绑定
// 每个非常量的动态绑定被登记为一个 IREffect { expressions, operations }
const effects = [
{ expressions: [title], operations: [SET_TEXT on h1] },
{ expressions: [message], operations: [SET_TEXT on p] },
{ expressions: [textClass], operations: [SET_PROP class on p] },
]
// 静态事件名的事件绑定不需要 effect,它是一次性的 operation
IR 转换的核心实现
具体的 Transform 主干代码就不长——transform() 本身只有二十来行,把节点交给一组按顺序运行的 nodeTransforms 插件(compile.ts 的 getBaseTransformPreset() 列出了全部顺序:transformVOnce → transformVIf → transformVFor → transformKey → transformSlotOutlet → transformTemplateRef → transformElement → transformText → transformVSlot → transformComment → transformChildren)。真正的复杂度藏在每个具体 transformer 里(packages/compiler-vapor/src/transforms/transformElement.ts 一个文件就有 1304 行)。读下面的主干代码时请把它当成一张目录——你在预设里找到 "transformVIf",然后去 packages/compiler-vapor/src/transforms/vIf.ts 找编译实现、去 packages/runtime-vapor/src/apiCreateIf.ts 找运行时实现。这种"总分目录 + 具体实现"的代码组织方式是大型编译器的标配,Vue Vapor 编译器也是这个结构。
// packages/compiler-vapor/src/transform.ts:505(rc.4)
export function transform(
node: RootNode,
options: TransformOptions = {},
): RootIRNode {
const ir: RootIRNode = {
type: IRNodeTypes.ROOT,
node,
source: node.source,
template: new TemplateRegistry(),
component: new Set(),
directive: new Set(),
block: newBlock(node),
hasTemplateRef: false,
}
const context = new TransformContext(ir, node, options)
transformNode(context)
ir.node.imports = context.imports
return ir
}
// 和 compiler-core 一样是"插件式"遍历:依次调用 nodeTransforms,
// 每个 transform 可以返回一个 onExit 回调,在子节点处理完后再执行
export function transformNode(
context: TransformContext<RootNode | TemplateChildNode>,
): void {
let { node } = context
// apply transform plugins
const { nodeTransforms } = context.options
const exitFns = []
for (const nodeTransform of nodeTransforms) {
const onExit = nodeTransform(node, context)
if (onExit) {
if (isArray(onExit)) exitFns.push(...onExit)
else exitFns.push(onExit)
}
if (!context.node) return // node was removed
else node = context.node // node may have been replaced
}
// exit transforms
context.node = node
let i = exitFns.length
while (i--) exitFns[i]()
if (context.node.type === NodeTypes.ROOT) {
context.registerTemplate() // 把收集到的静态 HTML 登记进 TemplateRegistry
}
}
9.3 Vapor Codegen:生成 DOM 操作代码
Codegen 是编译器流水线的最后一站——它把 IR 变成最终的 JavaScript 代码。这一步看似简单(本质就是字符串拼接),实际上涉及很多微妙决策:生成的代码要可读吗?要不要加 sourcemap?变量命名有什么约定?一个 effect 怎么绑定到多个 DOM 操作?。读下面的生成代码示例时,请注意它和你平时写的 Vue 组件的视觉差异——这是 Vapor 模式下用户原本写的 .vue 文件最终的样子,它和 VDOM 模式输出的 render 函数在结构上完全不同。
生成的代码结构
Vapor 生成的代码可读性相当高——这是有意为之的设计。传统编译器生成的代码往往是优化过度的"机器友好"样子(缩写、混淆、难读),但 Vapor 生成的代码几乎像是手写的一样清晰——template(...) 建结构、on(...) 挂事件、renderEffect(...) 绑定响应式——命名和结构都和用户原始模板有清晰对应关系。这种可读性的价值不只是美观:开发者在排查性能问题时直接看生成代码就能理解发生了什么、不需要额外的调试工具。这也是 Vapor 团队在工程取舍上做得很不错的一点——为了可读性愿意稍微牺牲一点优化空间。
Vapor Codegen 将 IR 转换为最终的 JavaScript 代码。让我们看看上面的模板最终会生成什么:
// Vapor 编译器的输出(按 rc.4 测试快照的风格整理,去掉了 sourcemap)
import {
child as _child,
next as _next,
txt as _txt,
on as _on,
toDisplayString as _toDisplayString,
setText as _setText,
setClass as _setClass,
renderEffect as _renderEffect,
template as _template,
} from 'vue'
// 第二个参数 1 = TemplateFlags.ROOT;文本插值的位置留一个空格占位文本节点
// rc.4 会省略最右侧可省的闭合标签,属性值不含空白等字符时也不加引号
const t0 = _template(
'<div class=container><h1> </h1><p> </p><button>Click me',
1,
)
export function render(_ctx) {
// 从模板 clone 出 DOM 节点
const n3 = t0()
// 获取需要操作的节点引用(编译期就确定了路径)
const n0 = _child(n3) // <h1>
const n1 = _next(n0) // <p>
const n2 = _next(n1) // <button>
const x0 = _txt(n0) // <h1> 里的文本节点
const x1 = _txt(n1) // <p> 里的文本节点
// 事件绑定(一次性操作,无需 effect)
_on(n2, 'click', _ctx.handleClick)
// 响应式绑定:同一 block 的所有动态绑定合并进一个 renderEffect(见 9.9 节)
_renderEffect(() => {
_setText(x0, _toDisplayString(_ctx.title))
_setClass(n1, _ctx.textClass)
_setText(x1, _toDisplayString(_ctx.message))
})
return n3
}
注意几个和直觉不同的地方:(1)helper 全部从 'vue' 导入,没有单独的 'vue/vapor' 入口——packages/vue/src/index-with-vapor.ts 把 @vue/runtime-vapor 的导出并进了 vue 包;(2)生成的是 render(_ctx) 而不是 setup()——只是在 <script setup vapor> 的 inline 模式下,compiler-sfc 会把这段代码直接拼到 setup 函数末尾、让 setup 返回根节点(compileScript.ts 里的注释:vapor mode generates its own return when inlined);(3)响应式绑定用的是 renderEffect,它是 ReactiveEffect 的子类,9.4 节会讲它的调度方式。
对比传统 VDOM 模式的输出:
// 传统 VDOM 编译器的输出
import {
createElementVNode as _createElementVNode,
toDisplayString as _toDisplayString,
normalizeClass as _normalizeClass,
openBlock as _openBlock,
createElementBlock as _createElementBlock
} from 'vue'
export function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock('div', { class: 'container' }, [
_createElementVNode('h1', null,
_toDisplayString(_ctx.title), 1 /* TEXT */),
_createElementVNode('p', {
class: _normalizeClass(_ctx.textClass)
}, _toDisplayString(_ctx.message), 3 /* TEXT | CLASS */),
_createElementVNode('button', { onClick: _ctx.handleClick }, 'Click me', 8 /* PROPS */, ['onClick'])
]))
}
差异一目了然:
| 维度 | VDOM 模式 | Vapor 模式 |
|---|---|---|
| 每次更新 | 重新执行整个 render 函数 | 只执行变化的 effect |
| 创建的对象 | 每次创建完整 VNode 树 | 零对象创建 |
| DOM 操作 | 经过 Diff 后 patch | 直接操作 |
| 内存分配 | 每次更新 O(n) 个 VNode 对象 | 更新时基本不分配(effect 闭包在初始化时建好) |
template 函数的实现
// packages/runtime-vapor/src/dom/template.ts:15(rc.4,去掉了 hydration 分支)
let t: HTMLTemplateElement
/*@__NO_SIDE_EFFECTS__*/
export function template(html: string, flags: number = 0, ns?: Namespace) {
const root = !!(flags & TemplateFlags.ROOT)
let node: Node
return (): Node & { $root?: true } => {
// 首次调用之后:直接 clone
if (node) {
const ret = node.cloneNode(true)
if (root) (ret as any).$root = true
return ret
}
// 纯文本模板的快速路径:不需要走 innerHTML
if (html[0] !== '<') {
return createTextNode(html)
}
// 整个应用共用一个 <template> 元素来解析 HTML
t = t || document.createElement('template')
if (ns) {
const tag = ns === Namespaces.SVG ? 'svg' : 'math'
t.innerHTML = `<${tag}>${html}</${tag}>`
node = _child(_child(t.content) as ParentNode)
} else {
t.innerHTML = html
node = _child(t.content)
}
const ret = node.cloneNode(true)
if (root) (ret as any).$root = true
return ret
}
}
这个看似简单的函数蕴含了一个重要的性能优化:模板只解析一次,后续都是 cloneNode。浏览器的 cloneNode(true) 是一个非常高效的 native 操作,比逐个创建元素并设置属性快得多。
节点定位策略
生成的代码通过 firstChild / nextSibling 链式访问来定位节点,而不是使用 querySelector 或 getElementById。这种策略:
// packages/runtime-vapor/src/dom/node.ts:44(rc.4,去掉了 hydration 分支)
// 第一个子节点
export function child(node: InsertionParent): Node {
return _child(node)
}
// 第 i 个子节点(要取的节点与上一个已取节点不相邻时才用)
export function nthChild(node: InsertionParent, i: number): Node {
return node.childNodes[i]
}
// 下一个兄弟节点
export function next(node: Node): Node {
return _next(node)
}
// 元素内部的文本节点(模板里用一个空格占位)
export function txt(node: ParentNode): Node {
return _child(node)
}
export function _child(node: InsertionParent): Node {
return node.firstChild!
}
export function _next(node: Node): Node {
return node.nextSibling!
}
// 编译器会生成类似这样的代码:
// const n0 = _child(n3)
// const n1 = _next(n0)
// const n2 = _nthChild(n3, 3)
这种策略的好处:
- 零字符串查找:不需要 ID 或选择器
- 编译期确定:节点的位置在编译时就已知
- 最小化 DOM API 调用:
firstChild/nextSibling是最轻量的 DOM 遍历操作
9.4 Vapor 运行时:精准更新引擎
Vapor 运行时是本章里最让人兴奋的部分——它解释了为什么 Vapor 组件的更新能比 VDOM 组件快得多。秘密不在于它做了更多巧妙的事,而在于它做得更少:没有 VNode 创建、没有树间 diff、没有 patch 遍历。动态绑定直接编进 effect、每个 effect 直接就是几条 DOM 操作。链路短了一半,性能自然起飞。
Effect 的直接绑定
这一小节展示的"链路对比"是理解 Vapor 性能优势最直接的方式。请仔细看下面两条链路的差异——VDOM 模式的链路有 5 个环节、Vapor 模式只有 3 个。每个环节都有成本(创建对象、比较、分配内存、函数调用)——环节少一半,成本自然少得多。软件性能优化的一条要义是"减少环节"——而不是"让每个环节更快"。让环节更快通常只是常数倍的改进,减少环节才可能是数量级的改进。Vapor 走的是后者这条路。
在传统 VDOM 模式中,响应式更新链路是这样的:
ref 变化 → trigger → component update effect → render() → VNode Diff → DOM patch
在 Vapor 模式中,链路被极大简化:
ref 变化 → trigger → DOM operation effect → DOM 直接操作
没有中间商赚差价。动态绑定直接编译进 renderEffect(同一 block 的绑定合并成一个,见 9.9 节),当依赖变化时直接执行 DOM 操作:
// packages/runtime-vapor/src/renderEffect.ts:24(rc.4,有删节)
export class RenderEffect extends ReactiveEffect {
i: VaporComponentInstance | null
job: SchedulerJob
render: () => void
// 在所属组件里的创建顺序,用来决定同一组件内多个 effect 的执行先后
order: number
constructor(render: () => void, noLifecycle = false) {
super(noLifecycle ? render : undefined)
this.render = render
const instance = currentInstance as VaporComponentInstance | null
this.order = instance ? instance.effectCount++ : 0
const job: SchedulerJob = () => {
if (!(this.flags & EffectFlags.PAUSED) && this.dirty) {
this.run()
}
}
if (instance) job.i = instance
this.job = job
this.i = instance
// 允许 render / 钩子里再改响应式状态而自我重排——
// 因为 Vapor 的更新一律经 queueJob() 异步进行,这样做是安全的
this.flags |= EffectFlags.ALLOW_RECURSE
this.job.flags! |= SchedulerJobFlags.ALLOW_RECURSE
}
fn(): void {
const instance = this.i
// 首次挂载后:beforeUpdate → render → (post 队列)updated
const hasUpdateHooks = instance && (instance.bu || instance.u)
if (hasUpdateHooks && instance.isMounted && !instance.isUpdating) {
instance.isUpdating = true
instance.bu && invokeArrayFns(instance.bu)
this.render()
queuePostRenderEffect(this.updateJob ||= () => {
instance.isUpdating = false
instance.u && invokeArrayFns(instance.u)
}, undefined, instance.suspense)
} else {
this.render()
}
}
// 依赖变化时被响应式系统调用:不是同步 run,而是排进调度器
notify(): void {
if (!(this.flags & EffectFlags.PAUSED)) {
queueJob(this.job, this.i ? this.i.uid : undefined, false, this.order)
}
}
}
export function renderEffect(fn: () => void, noLifecycle = false): void {
// v-once 插槽里直接跑一次,不建 effect
if (inOnceSlot) return fn()
const effect = new RenderEffect(fn, noLifecycle)
// 立即执行一次,建立依赖关系
effect.run()
}
注意这里的调度策略:Vapor 的 renderEffect 和 VDOM 组件的渲染 effect走的是同一个调度器。notify() 里没有直接 run(),而是 queueJob(job, uid, false, order)——把 job 排进 runtime-core/src/scheduler.ts 的队列,在微任务里统一 flush。queueJob 的第二、四个参数(组件 uid、effect 在组件内的创建序号)决定了 job 在队列里的位置:父组件的 effect 先于子组件,同一组件内按模板出现顺序执行。同一个 job 在一次 flush 前只会入队一次(SchedulerJobFlags.QUEUED 去重),所以一次事件里连改十个 ref、命中同一个 effect,也只会写一次 DOM。
"同步 vs 异步调度"是前端框架里一个微妙的设计选择。React 选了异步调度(Concurrent Rendering),好处是可以中断、可以让路给高优先级任务;代价是状态更新和 DOM 变化之间有不可避免的延迟。Vue 3 的 VDOM 模式用的是微任务异步调度——批量合并同一 tick 内的多次更新。Vapor 没有反其道而行之——它复用了同一套微任务批处理,只是把批处理的单元从"整个组件的 render"缩小到了"一个 DOM 绑定"。这意味着 Vapor 组件和 VDOM 组件的更新可见时机一致(都在 nextTick 之后),也意味着两种组件混用时不会出现一边同步、一边异步的时序错乱。
但这也带来了一个问题:如果一个组件中有 10 个动态绑定、分属几个不同的 block(v-if 分支、v-for 各项),一次状态变化触发了其中 5 个 renderEffect,它们会在同一次 flush 里被连续执行 5 次。这比传统模式中"一次 render + 一次 Diff"更高效吗?
答案是:在绝大多数场景下,是的。 因为:
- 每个 DOM 操作的开销远小于创建一棵 VNode 树再 diff
- 5 个 job 在同一个微任务里连续执行,浏览器不会在中间做 layout / paint
- Vapor 的 effect 是细粒度的,不相关的 DOM 操作不会被触发
批处理从哪里来
Vapor 没有自己的 scheduler 文件——packages/runtime-vapor/src 里根本没有 scheduler.ts。批处理来自两层已有机制:
- 响应式层:
packages/reactivity/src/system.ts里的startBatch()/endBatch()(Alien Signals 移植过来的批处理计数器,第 6 章讲过)。一次ref.value = x触发的传播会先累积 effect 通知,endBatch()时才统一调用各个 effect 的notify()。这两个函数是内部 API,@vue/reactivity的index.ts并没有导出它们。 - 调度层:
RenderEffect.notify()只做queueJob。queueJob(packages/runtime-core/src/scheduler.ts:103-108)用二分插入按(id, order)维护有序队列,然后queueFlush()用Promise.resolve().then(flushJobs)排一个微任务;已经在队列里的 job 不会重复入队。
// packages/runtime-core/src/scheduler.ts:103-108(rc.4 签名)
export function queueJob(
job: SchedulerJob,
id?: number,
isPre = false,
order = 0,
): void {
四个参数的含义:id 是组件 uid(父先于子),isPre 表示这是不是 pre-flush job(watch 的 flush: 'pre'),order 是同一组件内 effect 的创建序号。
所以在 Vapor 里你不需要(也没有)手动 startBatch() 的入口——把多次赋值放在同一个同步代码块里,它们自然合并到同一次 flush:
// 三次赋值命中的 renderEffect 各自只会执行一次,且都在下一个微任务里
count.value++
message.value = 'updated'
title.value = 'new title'
await nextTick() // 此时 DOM 已更新
setText / setProp 等操作函数
这些操作函数是 Vapor 运行时的工作马——每一个 DOM 更新最终都要调用它们。它们看起来朴素,但里面每一个细节都经过调优:(1)脏检查防止无效 DOM 写入——操作 DOM 是浏览器里最昂贵的操作之一,能少一次就少一次;(2)处理 null / undefined / false 的边界值语义——让用户的响应式代码在这些"表示不存在"的值上有合理的 DOM 行为(如 removeAttribute);(3)normalizeClass / normalizeStyle 处理各种用户书写风格——字符串、数组、对象、嵌套数组都要映射到正确的 CSS。这些细节加起来让 Vapor 的 DOM 操作能"闭着眼睛用"——用户不用担心自己写得是否规范,库会帮他搞定。
// packages/runtime-vapor/src/dom/prop.ts:409(rc.4,去掉了 hydration 分支和若干特殊情况,有删节)
// 文本节点:上次写入的值缓存在 el.$txt 上,不读 DOM
export function setText(el: Text & { $txt?: string }, value: string): void {
if (el.$txt !== value) {
el.nodeValue = el.$txt = value
}
}
// 元素整体文本(源码注释:只供 setDynamicProps 处理 textContent 时使用)
export function setElementText(el: Node & { $txt?: string }, value: unknown): void {
value = toDisplayString(value)
if (el.$txt !== value) {
el.textContent = el.$txt = value as string
}
}
// 通用入口:key 是 DOM property 就走 setDOMProp,否则走 setAttr
export function setProp(el: any, key: string, value: any): void {
if (key in el) {
setDOMProp(el, key, value)
} else {
setAttr(el, key, value)
}
}
export function setAttr(el: any, key: string, value: any, isSVG = false): void {
// 上次写入的值缓存在 el.$<key> 上
if (value !== el[`$${key}`]) {
el[`$${key}`] = value
const isBoolean = isSpecialBooleanAttr(key)
if (value == null || (isBoolean && !includeBooleanAttr(value))) {
el.removeAttribute(key)
} else {
el.setAttribute(key, isBoolean ? '' : isSymbol(value) ? String(value) : value)
}
}
}
export function setClass(el: TargetElement, value: any, isSVG = false, isNormalized = false): void {
if (el.$root) {
// 组件根元素:要和父组件透传下来的 class 合并,走增量路径
setClassIncremental(el, value, isNormalized)
} else {
if (!isNormalized) value = normalizeClass(value) // 数组 / 对象 → 字符串
if (value !== el.$cls) {
if (isSVG) el.setAttribute('class', (el.$cls = value))
else el.className = el.$cls = value
}
}
}
export function setStyle(el: TargetElement, value: any): void {
if (el.$root) {
setStyleIncremental(el, value)
} else {
const normalizedValue = normalizeStyle(value)
// 复用 runtime-dom 的 patchStyle:逐属性对比新旧值、清理消失的属性
patchStyle(el, el.$sty, (el.$sty = normalizedValue))
}
}
每个操作函数都包含了脏检查,但检查的不是"新值 vs 当前 DOM 值"——读 DOM 本身就有开销——而是"新值 vs 上一次写入的值":上次写入的值被缓存在节点对象的 $txt / $cls / $sty / $<attr> 属性上,比较一次就知道要不要碰 DOM。带 $root 标记的组件根元素还有一条"增量"路径,用来把组件自己的 class/style 和父组件透传(fallthrough)下来的 class/style 合并。
9.5 控制流的 Vapor 实现
模板里的 v-if 和 v-for 是所有 UI 框架最头疼的两个指令——它们打破了"结构静态"的简单假设,引入了"结构会动态变化"的复杂性。在 VDOM 模式下这两个指令相对容易处理——VNode 树可以自由新增/删除;在 Vapor 模式下要难得多——必须用真实 DOM 操作、而且要精确追踪哪些节点该留哪些该删。本节讲的就是 Vapor 如何解决这两个指令的实现——这是 Vapor 编译器中最精巧的部分,值得慢慢读。
v-if 的 Vapor 编译
传统模式中,v-if 通过条件性创建 VNode 实现。Vapor 模式中,它变成了真正的 DOM 操作:
<template>
<div>
<span v-if="show">Hello</span>
<span v-else>Goodbye</span>
</div>
</template>
Vapor 编译输出:
// 仿照 rc.4 compiler-vapor 测试快照 "v-if + v-else" 整理
// (快照模板是 <div v-if="ok"/><p v-else/>,这里换成本例的模板)
import { createIf as _createIf, template as _template } from 'vue'
const t0 = _template("<span>Hello", 3) // 3 = ROOT | STATIC
const t1 = _template("<span>Goodbye", 3)
export function render(_ctx) {
const n0 = _createIf(
() => _ctx.show,
// truthy branch
() => {
const n2 = t0()
return n2
},
// falsy branch
() => {
const n4 = t1()
return n4
},
357 /* TRUE_SINGLE_ROOT, FALSE_SINGLE_ROOT, TRUE_NO_SCOPE, FALSE_NO_SCOPE, KEYED_INDEX_0 */,
)
return n0
}
(为了让产物短一点,这里去掉了外层 <div>;有外层元素时,编译器会在模板里留一个 <!> 注释占位,并在 createIf 之前调用 setInsertionState(parent, anchor) 告诉运行时往哪插。)
注意这里没有出现"锚点"变量,但锚点确实存在——createIf 内部创建的 DynamicFragment 会持有一个 anchor 节点(开发构建下是 <!--if--> 注释,生产构建下是一个空文本节点,见 fragment.ts 的 resolveFragmentAnchor),真实分支内容永远插在这个锚点之前。用一个不可见节点在 DOM 里"占位"是 Vapor 处理动态内容的标准手法,这个 trick 在 React 的 Portal、Vue 2 的 <template v-if> 里都能看到——它是"在 DOM 里表达'这里可能会出现/消失东西'"的经典实现方式。最后一个参数 357 是编译器打包好的位标志(packages/shared/src/vaporFlags.ts 的 VaporIfFlags):低 4 位编码两个分支的形状(空 / 单根 / 多根),第 4 位是 v-once,第 5、6 位表示分支是纯静态模板、运行时可以省掉分支自己的 EffectScope,第 8 位起是分支索引。
createIf 的核心实现在 packages/runtime-vapor/src/apiCreateIf.ts:20-112(下面按原文逐字摘录,只把 hydration 和 v-once 两条支路省略掉,省略处都标了 ...):
// packages/runtime-vapor/src/apiCreateIf.ts:20-112
export function createIf(
condition: () => any,
b1: BlockFn,
b2?: BlockFn,
// Default flags encode true single-root + false empty, matching the compiler's
// only omitted-flags case.
flags: number = VaporBlockShape.SINGLE_ROOT,
): Block {
const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor
if (!isHydrating) resetInsertionState()
// ...(hydration 用的 hydrationCursor / branchShape 两个局部变量)
let adopted = false
let frag: Block
if (flags & VaporIfFlags.ONCE) {
// ...(v-once:直接执行一次分支,不建 effect)
} else {
// DynamicFragment should be keyed for correct transition behavior
// and KeepAlive cache identity. The encoded value is index + 1, so 0 is
// the unkeyed sentinel and source index 0 becomes encoded index 1.
const index = flags >> VaporIfFlags.INDEX_SHIFT
const keyed = index > 0
const keyBase = keyed ? (index - 1) * 2 : 0
const trackSlotBoundary = !!(flags & VaporIfFlags.SLOT_ROOT)
const dynamicFragment = new DynamicFragment(
isHydrating || __DEV__ ? 'if' : undefined,
keyed,
false,
trackSlotBoundary,
trackSlotBoundary
? () => {
const anchor = dynamicFragment.anchor
const parent = anchor.parentNode
if (parent) removeNode(anchor, parent)
}
: undefined,
_insertionAnchor,
)
adopted = isAdoptedAnchor(dynamicFragment.anchor, _insertionAnchor)
frag = dynamicFragment
renderEffect(() => {
const ok = condition()
// ...(hydration 时先进 cursor)
dynamicFragment.update(
ok ? b1 : b2,
keyed ? keyBase + (ok ? 0 : 1) : undefined,
isNoScopeBranch(flags, ok),
)
})
}
if (!isHydrating) {
// adopted fragments render in place through their template anchor
if (_insertionParent && !adopted) {
insert(frag, _insertionParent, _insertionAnchor)
}
}
// ...(else 分支是 hydration 收尾)
return frag
}
真正的切换逻辑在 DynamicFragment.update() 里:它把上一次的 key 记在 this.current 上,key 没变就直接返回;变了就先 scope.stop() 停掉旧分支收集的全部 effect、remove() 旧节点,再 new EffectScope() 包着执行新分支的工厂函数、把返回的节点插到锚点前。每个分支拥有独立的 EffectScope,这就是为什么隐藏掉的 v-if 分支里的绑定不会再被触发——它们的 effect 已经被停掉了。
v-for 的 Vapor 编译
v-for 是 Vapor 实现里最难的一部分——它需要处理增删改移位、key 匹配、过渡动画、键重复检测等所有列表操作的复杂性。而且这一切都不能借助 VDOM diff(因为 Vapor 没有 VDOM)——必须直接对真实 DOM 做增删移。读下面这段代码你会看到 Vapor 把列表更新归结为"快速路径 + 首尾 key 匹配 + 按 key 复用/移动"几步走。如果你理解了第 11 章 VDOM 模式的 diff 算法、这里会感到很多似曾相识——Vapor 只是把 diff 从"新旧 VNode 树对比"换成了"新旧 key 数组对比",本质思想没变,只是载体变了——不过有一个明显的区别:Vapor 的 createFor 没有用最长递增子序列(getSequence 只被 runtime-core/src/renderer.ts 的 patchKeyedChildren 使用),它用一条临时的双向链表来决定移动顺序。
v-for 是 Vapor 模式中最复杂的控制流,因为它需要处理列表的增删改操作:
<template>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</ul>
</template>
Vapor 编译输出:
// 按 rc.4 compiler-vapor 测试快照 "basic v-for" 的风格整理
import {
txt as _txt,
toDisplayString as _toDisplayString,
setText as _setText,
renderEffect as _renderEffect,
createFor as _createFor,
template as _template,
} from 'vue'
const t0 = _template("<li> ")
export function render(_ctx) {
const n0 = _createFor(
() => _ctx.items, // 源数据
(_for_item0) => { // 每项的渲染工厂,参数是 ShallowRef
const n2 = t0()
const x2 = _txt(n2)
// 每项内部的响应式绑定:读 _for_item0.value
_renderEffect(() => _setText(x2, _toDisplayString(_for_item0.value.name)))
return n2
},
(item) => item.id, // key 提取函数
8 /* IS_SINGLE_NODE */, // VaporVForFlags
)
return n0
}
(外层 <ul> 同样是通过模板里的 <!> 占位 + setInsertionState 来定位的,这里省略。)
createFor 的核心实现是一个 key-based 对比算法:
// packages/runtime-vapor/src/apiCreateFor.ts:97(rc.4,大幅精简)
export const createFor = (
src: () => Source, // 数组 / 对象 / 数字 / Set / Map
renderItem: (
item: ShallowRef<any>,
key: ShallowRef<any>,
index: ShallowRef<number | undefined>,
) => Block,
getKey?: (item: any, key: any, index?: number) => any,
flags = 0,
): ForFragment => {
let isMounted = false
let oldBlocks: ForBlock[] = []
let newBlocks: ForBlock[]
const frag = new ForFragment(oldBlocks, ...)
const parentAnchor = resolveFragmentAnchor(insertionAnchor, 'for')
// 每一项是一个 ForBlock:nodes + 独立的 EffectScope + itemRef/keyRef/indexRef + key
const mount = (source, idx, anchor = parentAnchor, [item, key, index] = getItem(source, idx), key2) => {
const itemRef = shallowRef(item)
const keyRef = needKey ? shallowRef(key) : undefined
const indexRef = needIndex ? shallowRef(index) : undefined
const scope = new EffectScope(true)
const nodes = scope.run(() => renderItem(itemRef, keyRef, indexRef))!
const block = (newBlocks[idx] = new ForBlock(nodes, scope, itemRef, keyRef, indexRef, key2))
if (parent) insertForBlock(block, anchor)
return block
}
// 复用旧项:只改三个 ShallowRef 的 value,项内的 renderEffect 自己会重跑
const update = (block, item, key, index) => { ... }
const unmount = (block, doRemove = true) => { block.scope.stop(); doRemove && removeForBlock(block) }
const renderList = () => {
const source = normalizeSource(src())
const newLength = source.values.length
const oldLength = oldBlocks.length
newBlocks = new Array(newLength)
// key 表达式可能依赖项字段,所以在 effect 还是活跃订阅者时先全部算好
const newKeys = getKey ? source.values.map((_, i) => getKey(...getItem(source, i))) : undefined
const prevSub = setActiveSub() // 后面的 DOM 操作不再收集依赖
if (!isMounted) {
isMounted = true
for (let i = 0; i < newLength; i++) mount(source, i)
} else if (!oldLength) {
// 快速路径 1:全新
for (let i = 0; i < newLength; i++) mount(source, i)
} else if (!newLength) {
// 快速路径 2:清空(FAST_REMOVE 时直接 parent.textContent = '')
for (let i = 0; i < oldLength; i++) unmount(oldBlocks[i], !canUseFastRemove)
} else if (!getKey) {
// 快速路径 3:无 key —— 按位置复用
const commonLength = Math.min(newLength, oldLength)
for (let i = 0; i < commonLength; i++) update((newBlocks[i] = oldBlocks[i]), ...getItem(source, i))
for (let i = oldLength; i < newLength; i++) mount(source, i)
for (let i = newLength; i < oldLength; i++) unmount(oldBlocks[i])
} else {
// 有 key:先从尾部匹配相同 key,再从头部匹配,
// 剩下的放进 queuedBlocks / oldKeyIndexMap,
// 按 key 复用旧 block(update)或新建(mount),
// 多余的旧 block unmount;
// 最后用 prev / next / prevAnchor 链表算出每个 block 该插到谁前面并移动 DOM
...
}
frag.nodes = [(oldBlocks = newBlocks)]
setActiveSub(prevSub)
}
renderEffect(renderList)
return frag
}
关键设计点:
- ShallowRef 包装:每个列表项被
shallowRef包装(key、index 只在渲染函数用到它们时才创建),复用旧项时只改.value,只有依赖该项的 effect 会重新执行 - Key-based 复用:与 VDOM 的 key 机制类似,通过 key 匹配来复用已有的 DOM 节点和 effect;每一项有自己的
EffectScope,卸载一项就是scope.stop()+ 移除节点 - 没有 LIS:Vapor 用首尾预匹配 + 一条临时双向链表(
ForBlock.prev / next / prevAnchor)决定移动顺序,并为"全新""清空""无 key"三种情况开了快速路径;把IS_SINGLE_NODE、FAST_REMOVE这类信息以VaporVForFlags从编译期传给运行时,让运行时能直接用insertBefore/textContent = ''而不必逐节点判断
9.6 组件在 Vapor 中的表现
"组件"这个概念在 Vapor 模式下发生了微妙的语义转变——从"返回 VNode 的 setup 函数 + render 函数"变成"直接操作 DOM 的 setup 函数"。这个转变对大多数用户来说是透明的(你的 .vue 文件照常写),但对框架内部影响深远——组件实例、props、slots、lifecycle 钩子全都要重新适配 Vapor 的无 VNode 语义。本节拆解这些适配,让你理解为什么 Vapor 和 VDOM 模式下"看起来一样"的组件代码在底层完全不同。
Vapor 组件的创建
// packages/runtime-vapor/src/component.ts:252(rc.4,大幅精简)
export function createComponent(
component: VaporComponent,
rawProps?: LooseRawProps | null, // { id: "foo", class: () => [...], $: [() => obj] }
rawSlots?: LooseRawSlots | null,
isSingleRoot?: boolean, // 是否父组件模板的唯一根 → 决定 attrs 透传
once?: boolean,
appContext: GenericAppContext = (currentInstance && currentInstance.appContext) || emptyContext,
managedMount = false,
ce?: (instance: VaporComponentInstance) => void,
): VaporComponentInstance {
const _insertionParent = insertionParent
const _insertionAnchor = insertionAnchor
resetInsertionState()
// 开启了 vaporInteropPlugin 且目标不是 Vapor 组件 → 交给 VDOM 渲染器
if (isInteropEnabled && appContext.vdom && !component.__vapor) {
const frag = appContext.vdom.mount(component, currentInstance, rawProps, normalizeRawSlots(rawSlots), once)
if (_insertionParent) insert(frag, _insertionParent, _insertionAnchor)
return frag
}
// 组件实例是一个 class,构造函数里建好 scope / props 代理 / attrs / slots / emit
const instance = new VaporComponentInstance(component, rawProps, rawSlots, appContext, once, ce)
// 执行 setup:注意第二个参数直接就是 instance(它实现了 emit / slots / attrs / expose)
setupComponent(instance, component)
onScopeDispose(() => unmountComponent(instance), true)
if (!managedMount && _insertionParent) {
mountComponent(instance, _insertionParent, _insertionAnchor) // beforeMount → insert → mounted
}
return instance
}
export function setupComponent(instance: VaporComponentInstance, component: VaporComponent): void {
const prevInstance = setCurrentInstance(instance)
const prevSub = setActiveSub()
const setupFn = isFunction(component) ? component : component.setup
const setupResult = setupFn
? callWithErrorHandling(setupFn, instance, ErrorCodes.SETUP_FUNCTION, [instance.props, instance]) || EMPTY_OBJ
: EMPTY_OBJ
if (isPromise(setupResult)) {
instance.asyncDep = setupResult // 交给 Suspense
} else {
handleSetupResult(setupResult, component, instance)
}
setActiveSub(prevSub)
restoreCurrentInstance(prevInstance)
}
VaporComponentInstance 是一个 class(component.ts 第 729 行起),字段和 VDOM 的 ComponentInternalInstance 大量重合——uid / type / parent / appContext / scope / props / attrs / slots / emit / exposed / provides / refs / isMounted / bm / m / bu / u / bum / um…——但没有 vnode / subTree / next / update / render,取而代之的是:
block: Block:组件渲染出来的真实 DOM(一个节点、节点数组、Fragment 或子组件实例),不是 VNoderawProps: RawProps/rawSlots: RawSlots:编译器传进来的"原始 props"——每个动态值是一个 getter 函数effectCount:给RenderEffect.order编号用
handleSetupResult 做的事很直接:setup() 返回的如果是 Block(DOM 节点 / 数组 / Fragment / 组件实例),直接 instance.block = setupResult;如果返回的是状态对象且组件带 render(非 inline 模式下 compiler-sfc 生成的 render(_ctx)),就调用一次 render 拿 Block。
注意最关键的区别:在 VDOM 模式中,setup 返回一个 render 函数,每次更新时重新调用;在 Vapor 模式中,setup(或 render)返回 DOM 节点,只调用一次——之后所有更新都由它内部创建的那些 renderEffect 各自完成。
Props 的响应式处理
Vapor 的 props 没有 initProps 这一步——编译器直接把动态 prop 编译成 getter,运行时用一个 Proxy 按需求值:
// 编译产物:静态值原样传,动态值是 getter,v-bind="obj" 进 $ 数组
_createComponent(Foo, { id: "foo", class: () => ["foo", { bar: _ctx.isBar }], $: [() => _ctx.obj] }, null, true)
// packages/runtime-vapor/src/componentProps.ts(rc.4)
export type RawProps = Record<string, unknown> & {
// generated by compiler for :[key]="x" or v-bind="x"
$?: DynamicPropsSource[]
}
export type DynamicPropsSource = (() => Record<string, unknown>) | Record<string, unknown>
// instance.props 是 new Proxy(instance, propsHandlers):
// get(key) → 先倒序查 rawProps.$ 里的动态源,再查 rawProps 自己的键,
// 找到就 resolveSource(value)(是函数就调用)→ resolvePropValue 做 Boolean cast / default,
// 找不到就按声明给默认值
export function getPropsProxyHandlers(comp: VaporComponent): [ProxyHandler | null, ProxyHandler] { ... }
export function resolveSource<T>(source: T | (() => T)): T {
return isFunction(source) ? source() : source
}
动态 props 通过 getter 函数实现,这样当父组件的状态变化时,子组件在自己的 renderEffect 里读 props.xxx,会穿过 Proxy 调用父组件传来的 getter,从而直接订阅父组件的响应式源——中间没有"父组件 render → 生成新 props 对象 → updateProps → trigger"这条链路。isolatePropSources / snapshotRawProps 分别服务于 KeepAlive 缓存与 v-once,是同一套 getter 模型上的两个特例。
9.7 与 VDOM 模式的互操作
这是 Vapor 设计里最工程化的一部分——它决定了 Vapor 能否真正被大规模项目采用。如果 Vapor 和 VDOM 不能混用,用户要么全部切 Vapor(代价巨大)、要么放弃 Vapor(失去收益)——这是一个"全有全无"的选择,大多数团队会选"全无"。Vue 团队花了大量工程心血做到"组件级别粒度的混用"——一个应用里 Vapor 组件和 VDOM 组件可以任意嵌套、互相 props 传递、slot 互通。这种无缝混用是 Vapor 能在 Vue 3.6 成为正式可选项的关键。
混合渲染:同一应用中两种模式共存
Vue 3.6 支持在同一应用中混用 VDOM 和 Vapor 组件。这通过一个桥接层实现:
// packages/runtime-vapor/src/vdomInterop.ts:2368(rc.4,3300 行的文件,这里只看骨架)
// 安装桥接层:createApp(App).use(vaporInteropPlugin)
export const vaporInteropPlugin: Plugin = app => {
setInteropEnabled()
// VDOM 渲染器碰到 Vapor 组件时调用这一组接口
app._context.vapor = vaporInteropImpl // { mount, update, unmount, move, slot, hydrate, ... }
// Vapor 组件碰到 VDOM 组件时调用这一组接口
const internals = ensureRenderer().internals
app._context.vdom = {
mount: createVDOMComponent.bind(null, internals),
slot: renderVDOMSlot.bind(null, internals),
mountVNode: mountVNode.bind(null, internals),
}
}
// 方向一:在 VDOM 树里挂载一个 Vapor 组件(由 renderer 的 mountComponent 调到这里)
const vaporInteropImpl: VaporInVdomInterface = {
mount(vnode, container, anchor, parentComponent, parentSuspense) {
const selfAnchor = (vnode.anchor = createTextNode())
container.insertBefore(selfAnchor, anchor)
// 把 VNode 的 props / slots 放进 shallowRef,包装成 Vapor 的 getter 形式的 RawProps
const propsRef = shallowRef(filterReservedProps(vnode.props))
const slotsRef = shallowRef(normalizeInteropSlots(vnode.children))
const instance = (vnode.component = createComponent(
vnode.type as VaporComponent,
{ $: [() => propsRef.value] } as RawProps,
createInteropRawSlots(slotsRef),
undefined, undefined,
parentComponent ? parentComponent.appContext : vnode.appContext,
true, // VDOM 自己负责 mount
))
instance.rawPropsRef = propsRef // 父组件 patch 时只需更新这两个 ref
instance.rawSlotsRef = slotsRef
mountComponent(instance, container, selfAnchor)
return instance
},
update(n1, n2, shouldUpdate) { /* n2.component.rawPropsRef.value = n2.props ... */ },
unmount(vnode, doRemove) { /* unmountComponent + remove anchor */ },
move(vnode, container, anchor) { /* insert(instance.block, container, anchor) */ },
// ...
}
// 方向二:在 Vapor 组件里挂载一个 VDOM 组件(createComponent 的 interop 分支调到这里)
function createVDOMComponent(
internals: RendererInternals,
component: ConcreteComponent,
parentComponent: VaporComponentInstance | null,
rawProps?: LooseRawProps | null,
rawSlots?: LooseRawSlots | null,
once?: boolean,
): VaporFragment {
// 用 Vapor 的 RawProps 造一个 VNode;vnode.vi 钩子让 VDOM 实例改用 Vapor 的 props/attrs 代理
const vnode = createVNode(component, rawProps && extend({}, new Proxy(rawProps, rawPropsProxyHandlers)))
const { frag } = createVNodeFragment(vnode)
vnode.vi = (instance: ComponentInternalInstance) => { /* instance.props = shallowReactive(wrapper.props) ... */ }
// 挂载 / 移动 / 卸载都委托给 renderer 的 internals:
// 首次 insert 走 internals.mt(mountComponent),再次 insert 走 internals.m(move)
frag.insert = (parent, anchor) => { /* isMounted ? internals.m(...) : internals.mt(vnode, parent, anchor, parentComponent, ...) */ }
frag.remove = unmount // 内部调用 renderer 的 unmount(vnode, ...)
return frag
}
两个方向的核心手法是一样的:把对方的"输入格式"翻译成自己的输入格式,然后让对方的挂载/卸载逻辑照常运行。Vapor 组件进 VDOM 树时,VNode 的 props 变成 { $: [() => propsRef.value] } 这种 getter 型 RawProps;VDOM 组件进 Vapor 树时,getter 型 RawProps 被 rawPropsProxyHandlers 包成一个"读起来像普通对象"的 Proxy 再交给 createVNode。runtime-core/src/apiCreateApp.ts 里的 VaporInVdomInterface / VdomInVaporInterface 两个接口定义了这条边界上全部的方法。
flowchart TB
A[App Root - VDOM] --> B[HeaderComponent - VDOM]
A --> C[ContentComponent - Vapor]
A --> D[FooterComponent - Vapor]
C --> E[DataTable - Vapor]
C --> F[LegacyWidget - VDOM via Bridge]
style C fill:#e1f5fe
style D fill:#e1f5fe
style E fill:#e1f5fe
style F fill:#fff3e0
渐进式迁移策略
这种混合模式使得从 VDOM 到 Vapor 的迁移可以渐进进行:
// main.ts —— 三种入口
import { createApp, createVaporApp, vaporInteropPlugin } from 'vue'
// 1. 纯 Vapor 应用:根组件和所有子组件都是 Vapor 组件
createVaporApp(App).mount('#app')
// 2. VDOM 应用里混用 Vapor 组件:装上桥接插件
createApp(App).use(vaporInteropPlugin).mount('#app')
// 3. 纯 VDOM 应用:什么都不用改
createApp(App).mount('#app')
编译侧的开关在 compiler-sfc:compileScript / compileTemplate 都接受 vapor: boolean(packages/compiler-sfc/src/compileScript.ts 第 147 行、compileTemplate.ts 第 60 行),构建插件把这个选项传下去即可全局开启(@vitejs/plugin-vue 暴露的选项名以其文档为准)。更常用的是在组件级别控制——SFC 解析器会识别 <script vapor> / <script setup vapor> / <template vapor> 上的 vapor 属性(parse.ts 第 167-204 行,descriptor.vapor ||= ...):
<!-- 在 script 标签上声明 vapor -->
<script setup vapor>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>
9.8 性能估算分析
性能数据是衡量任何"新架构"的最终标准——设计再优雅,跑不过前代就没有说服力。本节用几组示意性的估算把 Vapor 和 VDOM 的成本结构拆开对比——下面代码块里的数字是为了说明数量级关系而写的推演值,不是对 rc.4 的实测(Vue 核心仓库里没有随源码发布的 benchmark 结果,真实数据请以 js-framework-benchmark 上 vue-vapor 条目的最新一轮跑分为准)。你会看到 Vapor 的性能优势在不同场景下的差异、以及哪些场景下 Vapor 优势最大。
Bundle Size 对比
// 构建体积的量级示意(gzip 后,估算值,非实测)
const bundleComparison = {
vdomRuntime: {
'vue runtime-dom': '16.2 KB',
'vue runtime-core': '28.4 KB',
'reactivity': '5.8 KB',
total: '50.4 KB'
},
vaporRuntime: {
'vue runtime-vapor': '5.6 KB',
'reactivity': '5.8 KB',
total: '11.4 KB' // 量级示意
}
}
纯 Vapor 应用(createVaporApp)不需要打进产物的模块:
runtime-core/src/renderer.ts:完整的 VDOM 渲染器(patch / diff / mount / unmount)runtime-core/src/vnode.ts:VNode 创建与标准化runtime-core/src/componentRenderUtils.ts:renderComponentRoot 等渲染工具runtime-core/src/hydration.ts:VDOM 侧的 SSR 激活(Vapor 有自己的runtime-vapor/src/dom/hydration.ts)
注意 Vapor 运行时本身并不小——rc.4 的 packages/runtime-vapor/src 有一万七千多行源码(含 Transition / KeepAlive / Teleport / Suspense / 互操作 / 激活),但 helper 都是独立导出的具名函数,tree-shaking 之后只会保留模板真正用到的那些。
更新性能对比
以一个包含 1000 行的表格为例,更新其中 10 行的数据:
// 基准测试设计
const benchmark = {
scenario: '1000 行表格,更新 10 行',
vdom: {
vnodesCreated: 1000 * 4, // 1000行 × 4列 = 4000 VNode
patchCalls: 4000, // 每个 VNode 需要 patch
actualDomOps: 10, // 最终只有 10 行需要 DOM 操作
timeMs: 8.2 // 示意值
},
vapor: {
vnodesCreated: 0, // 无 VNode
effectsTriggered: 10, // 每行一个 block,4 列绑定合并进一个 renderEffect
actualDomOps: 40, // 40 次精准 DOM 操作
timeMs: 1.8 // 示意值
}
}
graph LR
subgraph VDOM模式
A1[创建 4000 VNode] --> A2[Diff 4000 节点]
A2 --> A3[Patch 10 个 DOM]
end
subgraph Vapor模式
B1[触发 10 个 Effect] --> B2[执行 40 次 DOM 操作]
end
style A1 fill:#ffcdd2
style A2 fill:#ffcdd2
style B1 fill:#c8e6c9
style B2 fill:#c8e6c9
内存占用对比
// 内存快照对比
const memoryComparison = {
scenario: '渲染 1000 个列表项',
vdom: {
vnodeObjects: '1000 个 VNode 对象',
perVnodeSize: '~200 bytes',
totalVnodeMemory: '~200 KB',
componentInstances: '1000 个完整实例',
perInstanceSize: '~800 bytes',
totalMemory: '~1 MB'
},
vapor: {
vnodeObjects: '0',
effectClosures: '1000 个轻量闭包',
perClosureSize: '~80 bytes',
componentInstances: '1000 个精简实例',
perInstanceSize: '~300 bytes',
totalMemory: '~380 KB' // 示意值
}
}
首屏渲染对比
首屏渲染是 Vapor 优势最小的场景,因为两种模式都需要创建 DOM 节点(CSR)或认领 DOM 节点(SSR 激活):
const ssrHydration = {
scenario: '100 个组件的页面首屏(示意)',
vdom: {
parseTime: 2.1, // 浏览器解析 HTML
hydrationTime: 12.5, // 先 render 出整棵 VNode 树,再逐节点和 DOM 对齐
bindingTime: 3.2, // 事件绑定
totalMs: 17.8
},
vapor: {
parseTime: 2.1, // 解析 HTML(相同)
hydrationTime: 4.0, // 不建 VNode,但仍要沿 DOM 游标逐个 adopt 节点
bindingTime: 5.8, // 更多的细粒度 effect 绑定
totalMs: 11.9
}
}
Vapor 同样需要激活,只是方式不同。createVaporSSRApp 会把 isHydrating 置为 true,之后 template() 不再 cloneNode,而是认领当前激活游标(currentHydrationNode)指向的服务端 DOM——静态模板走 resolveHydrationTarget() 跳过锚点注释后直接认领,其余模板调用 adoptTemplate()(runtime-vapor/src/dom/template.ts:34-58);child / next / nthChild / txt 在激活模式下沿"逻辑索引"(跳过 SSR 留下的 <!--[--> / <!--]--> 等锚点注释)定位节点;setText / setAttr / setClass 在激活时只做 mismatch 校验并把值缓存到 $txt / $cls,不写 DOM。这套逻辑集中在 runtime-vapor/src/dom/hydration.ts 和 hydrateFragment.ts。Vapor 激活的优势在于不需要先渲染出一棵完整的 VNode 树再去和 DOM 对齐——认领过程直接跟着编译器生成的节点访问路径走。
9.9 Vapor 编译器的高级优化
有了 IR 层(9.2 节讲过),Vapor 编译器就有了进行各种高级优化的空间——死代码消除、公共子表达式提取、effect 合并、静态部分提取。这些优化在第一版 Vapor 里部分已经落地、部分还在规划中。本节介绍几个已经实现的关键优化——看完你会对"编译器优化"这件事从概念变成直觉:优化不是魔法,是一系列有迹可循的小改造,每一步都让最终代码快一点。
常量表达式不进 effect
TransformContext.registerEffect()(compiler-vapor/src/transform.ts)在登记一个 effect 之前,会先把常量表达式过滤掉(isConstantExpression);如果剩下的表达式全是静态的(isStaticExpression,例如 <script setup> 里的 const 字面量绑定),这条操作就被降级为一次性的 registerOperation,不再包进 renderEffect:
// 编译前
{{ 1 }}{{ 2 }}
// rc.4 测试快照 "dynamic root":常量插值直接 setText,没有 renderEffect
const t0 = _template(" ")
export function render(_ctx) {
const n0 = t0()
_setText(n0, _toDisplayString(1) + _toDisplayString(2))
return n0
}
同样的机制让 v-once 下的绑定也只执行一次(context.inVOnce 为 true 时 registerEffect 直接走 registerOperation)。
Effect 合并
同一个 block 里连续登记的多个 effect,Codegen(generators/operation.ts 的 genEffects)会把它们合并到同一个 renderEffect 回调里,减少 effect 对象的数量:
// 编译前
<div :id="foo">{{ bar }}</div>
// rc.4 测试快照 "execution order > basic":两个操作、一个 renderEffect
_renderEffect(() => {
_setProp(n0, "id", _ctx.foo)
_setText(x0, _toDisplayString(_ctx.bar))
})
代价是:foo 或 bar 任何一个变化,两条 DOM 操作都会重跑——好在 setProp / setText 自带上一次写入值的缓存,没变的那条不会真的碰 DOM。这是"更少的 effect 对象"和"更精准的依赖追踪"之间的一个折中。
事件处理器的处理
元素上的静态事件名直接编译成一次性的 _on(el, "click", handler),内联语句会被包成箭头函数;组件上的事件监听器则会先提成 render 里的一个常量再以 getter 的形式传进 props(rc.4 测试快照 "cache v-on expression with unique handler name"):
// 编译前
<button @click="count++">+1</button>
<Foo @bar="handleBar($event)" />
// 编译后
const n0 = t0()
_on(n0, "click", () => (_ctx.count++))
const _on_bar = $event => (_ctx.handleBar($event))
const n1 = _createAssetComponent("Foo", { onBar: () => _on_bar })
因为 render 只执行一次,元素事件的 handler 天然只会创建一次,不需要 VDOM 模式下 _cache[n] 那种跨 render 的缓存。
9.10 Vapor Mode 的限制与权衡
没有银弹——Vapor Mode 也有自己的代价和限制。这一节的价值不在讲 Vapor 有多好,而在讲它什么时候不应该用。理解这些限制比理解它的优势更能帮你做出成熟的技术决策。盲目追新的工程师会把 Vapor 用到一切地方、结果遇到边界问题抓瞎;成熟工程师会先了解 Vapor 的适用场景和边界、再对症下药——这就是本节想帮你培养的判断力。
当前的边界
到 rc.4 为止,早期 Vapor 预告里"暂不支持"的内置组件大多已经有了 Vapor 实现:packages/runtime-vapor/src/components/ 下的 Transition.ts(VaporTransition)、TransitionGroup.ts(VaporTransitionGroup)、KeepAlive.ts(VaporKeepAlive)、Teleport.ts(VaporTeleport)都从 vue 导出;同目录的 Suspense.ts 则还是一个标着 TODO: implement this 的空壳(VaporSuspenseImpl,未导出),Vapor 组件目前要借助互操作层进入 VDOM 的 <Suspense>。真正还在的边界是这些:
- 只有 DOM 一个渲染目标:
template()直接document.createElement('template'),helper 直接调setAttribute/insertBefore,没有createRenderer那样的宿主抽象层,自定义渲染器(Canvas / Native)走不了 Vapor - 不支持 Options API:
VaporComponentOptions(component.ts)只有props / emits / slots / setup / render / inheritAttrs / name / components这些字段,没有data / computed / methods / watch / mixins——Vapor 组件必须用setup()或<script setup>写 - 混用需要桥接层:VDOM 组件和 Vapor 组件互相嵌套必须
app.use(vaporInteropPlugin),桥接本身有一层 props / slots 的翻译开销(9.7 节) - 生态是 VDOM 的:现有组件库全部按 VDOM 组件构建,进 Vapor 树要走互操作路径,享受不到 Vapor 的收益
何时不该使用 Vapor
// 判断指南
const shouldUseVapor = {
// 适合 Vapor 的场景
goodCases: [
'数据密集型的列表/表格',
'实时更新的仪表盘',
'高频交互的表单',
'对 Bundle Size 敏感的移动端页面',
'简单到中等复杂度的组件',
],
// 暂时不适合 Vapor 的场景
notYetCases: [
'大量 Options API 写法的老组件(Vapor 只支持 setup / <script setup>)',
'重度依赖第三方 VDOM 组件库(全部要经过 vaporInteropPlugin 桥接)',
'使用自定义渲染器(非 DOM 目标)',
]
}
与 VDOM 模式的哲学差异
| 方面 | VDOM 模式 | Vapor 模式 |
|---|---|---|
| 心智模型 | 声明式描述 → 自动 Diff | 声明式描述 → 编译期展开 |
| 调试体验 | DevTools 可视化 VNode 树 | 需要新的 DevTools 支持 |
| 错误边界 | 组件级 errorCaptured / app.config.errorHandler | 同一套 callWithErrorHandling / errorCaptured |
| 可预测性 | 统一的更新时机(nextTick) | 同一个调度器,同样在 nextTick 后可见 |
| 可扩展性 | 自定义渲染器 | 仅 DOM 目标 |
9.11 Vapor 与响应式系统的深度整合
Vapor 和响应式系统(第 3~5 章讲的响应式基础、第 6 章详述的 Alien Signals 内核)是一对完美搭档——后者是前者能成立的前提。没有细粒度响应式 + 惰性求值,Vapor 的"每个 effect 直接对应 DOM 操作"模型就会失去意义(会有大量 effect 被不必要地触发)。有了 Alien Signals 的精确传播,Vapor 才能让"每个 effect 只在真正需要时才运行"。这一节讨论的是两个子系统如何协同——Vapor 的 effect 如何接入 Alien Signals、怎么避免无效触发、怎么利用 checkDirty 的惰性评估能力。
Alien Signals 的协同
在第 6 章中我们详细讨论了 Vue 3.6 的 Alien Signals 响应式内核。Vapor Mode 是 Alien Signals 的最佳搭档,两者的设计理念高度一致:
// Alien Signals 的 signal 和 computed
import { signal, computed, effect } from 'alien-signals'
// 在 Vapor 组件中,每个 ref 本质上是一个 signal
// 每个 DOM 绑定本质上是一个 effect
// 这种映射是直接的、无中间层的
const count = signal(0)
const doubled = computed(() => count() * 2)
// Vapor 生成的代码等价于:
effect(() => {
setText(span, String(doubled()))
})
Alien Signals 的推拉混合调度策略与 Vapor 的细粒度 effect 完美匹配:
- 推(Push):当 signal 变化时,通知所有订阅的 effect 标记为"脏"
- 拉(Pull):当 effect 执行时,才真正从 computed 中拉取最新值
- 惰性求值:
computed只在被读到时才checkDirty()重算;而被v-if切走的分支,其EffectScope已被DynamicFragment.update()停掉,里面的 effect 不再订阅任何依赖,自然也不会拉动 computed 重算
Effect Scope 的作用
Vapor 组件中的所有 effect 都被收集在一个 effectScope 中:
// packages/runtime-vapor/src/component.ts:1343(rc.4,去掉了 KeepAlive / Suspense 分支)
export function unmountComponent(
instance: VaporComponentInstance,
parentNode?: ParentNode,
): void {
if (!instance.isUnmounted && instance.isMounted) {
invalidateMount(instance.m) // 还没跑的 mounted 钩子作废
if (instance.bum) invokeArrayFns(instance.bum)
// 停止所有 effect:renderEffect、watch、computed、子组件(子组件通过 onScopeDispose 挂在父 scope 上)
instance.scope.stop()
if (instance.um) queuePostRenderEffect(instance.um, undefined, instance.suspense)
instance.isUnmounted = true
}
// 移除 DOM 节点(Block 可能是节点、数组、Fragment 或子组件实例)
if (parentNode && instance.block) {
remove(instance.block, parentNode)
}
}
这比 VDOM 模式的卸载流程简单——不需要递归遍历 VNode 子树去 unmount 每一个 VNode,子组件的卸载是通过 createComponent 时注册的 onScopeDispose(() => unmountComponent(instance)) 随父 scope 一起停掉的。生命周期钩子(beforeUnmount / unmounted)照常触发,事件监听器随节点一起被 GC,不需要单独解绑。
9.12 展望:Vapor 对 Vue 生态的影响
Vapor Mode 在 Vue 3.6 的出现只是一个起点。我的预测是:接下来的 3-5 年里,Vapor 会逐步从"高性能场景下的可选项"变成"很多场景下的默认选择"。本节从生态视角展望这个演进——组件库如何响应、Nuxt 等框架如何整合、SSR 如何适配、开发工具如何更新。读本节你会看到 Vapor 不只是一个编译选项,它是 Vue 生态未来几年演进的主轴。
组件库的适配
主流 Vue 组件库(Element Plus、Naive UI、Vuetify 等)目前基于 VDOM API 构建。Vapor Mode 的推广需要组件库的配合:
// 未来的组件库可能提供双模式构建
// package.json
{
"exports": {
".": {
"vapor": "./dist/vapor/index.js",
"default": "./dist/vdom/index.js"
}
}
}
对 SSR 的影响
服务端这一侧,Vapor 和 VDOM 共用同一个 @vue/compiler-ssr:compileTemplate.ts 里 ssr 为 true 时不会走 compiler-vapor,而是照常生成字符串拼接式的 ssrRender(第 17 章会展开):
// SSR 产物(两种模式相同):直接字符串拼接(简化:省略了根元素合并 _attrs 的部分)
function ssrRender(_ctx, _push, _parent, _attrs) {
_push(`<div class="container"><h1>${_ssrInterpolate(_ctx.title)}</h1>` +
`<p class="${_ssrRenderClass(_ctx.textClass)}">${_ssrInterpolate(_ctx.message)}</p>` +
`<button>Click me</button></div>`)
}
真正不同的是客户端:createVaporSSRApp(runtime-vapor/src/apiCreateApp.ts)用 Vapor 自己的激活逻辑认领服务端 DOM(9.8 节),不需要先构建 VNode 树。
长期架构演进
Vapor Mode 代表了 Vue 框架的一个重要架构转向:
timeline
title Vue 渲染架构演进
section Vue 1.x
细粒度绑定 : 每个绑定一个 Watcher
section Vue 2.x
VDOM + 组件级响应 : Watcher 触发组件重渲染
section Vue 3.0-3.5
优化的 VDOM : PatchFlags + Block Tree
section Vue 3.6+
双模式共存 : VDOM + Vapor
section 未来
Vapor 为默认 : VDOM 作为兼容层
有趣的是,Vue 的架构走了一个螺旋上升的路径:从 Vue 1 的细粒度绑定,经过 Vue 2/3 的 VDOM 时代,又回到了 Vapor 的细粒度绑定——但这次拥有了编译器的加持,在开发体验和运行性能之间取得了更好的平衡。
本章小结
Vapor Mode 是 Vue 3 最激进也最意义深远的升级。它不是在原有架构上做加法、而是开辟了一条并行的新路径——让 Vue 从"只有 VDOM 一种模式"变成"VDOM + Vapor 双轨共存"。这种"双轨设计"是 Vue 能在保留兼容性的同时获得显著性能提升的关键——模板和 Composition API 代码不用重写,给组件加上 vapor 属性,就能在性能关键场景享受 Vapor 的收益。这是框架设计里极其难得的升级方式。
回头再看本章开头的"两个问题"——现在你应该能回答了:
- Vapor 和 VDOM 的本质差异:VDOM 是"每次渲染先造 VNode 树再 diff 再 patch",Vapor 是"动态绑定直接编译成操作 DOM 的 renderEffect"。链路少了两环、内存少了一棵 VNode 树。
- 什么组件适合切 Vapor:静态结构多 + 动态绑定少 + 更新频繁 的组件(榜单、表格、dashboard 的数据卡片)收益最大;组件层级深 + 大量 slot + 高度动态化的结构(设计器、CMS 编辑器)短期内建议继续用 VDOM。先量、再切、观察收益——不要一上来就"全部切 Vapor"。
延伸阅读
- Vue 3 源码
packages/compiler-vapor/、packages/runtime-vapor/:本章讨论的所有代码的原始位置。rc.4 里编译器src约 1.05 万行、运行时src约 1.75 万行(不含测试;其中vdomInterop.ts一个文件 3300 行、component.ts1850 行),建议先读runtime-vapor/src/index.ts的导出列表建立地图,再配合本章逐个文件读。 - 尤雨溪 2023 年起在 Vue Amsterdam / VueConf 等大会上关于 Vapor Mode 的系列演讲:Vue 作者本人对 Vapor 的官方介绍,YouTube 和 Bilibili 都有录像。
- Solid.js 源码:Vapor 设计的重要参考——Solid 的 fine-grained reactivity + compile-time optimization 路线和 Vapor 殊途同归。
- Rich Harris Rethinking Reactivity 2019:Svelte 作者对"不要 VDOM"路线最有代表性的公开论述之一,对编译派框架影响很广。
- vuejs/vue-vapor 仓库(2023-11 起)的提交历史与 issue 讨论,以及 vuejs/core 合入后 v3.6.0-alpha.1(2025-07-12)到各个 rc 的 CHANGELOG:Vapor 从原型到落地的全部设计讨论记录,理解细节决策的最权威材料。
Vapor Mode 不是对 VDOM 的否定,而是 Vue 编译优化哲学的集中体现。它证明了一个深刻的洞察:当编译器足够智能时,运行时可以足够简单。
在本章中,我们完整地追踪了 Vapor Mode 的设计与实现:
- 动机:消除 VDOM 的结构性开销——VNode 创建、树 Diff、对象 GC
- 编译器:通过 Vapor IR 将模板转换为 DOM 操作指令,静态部分提取为模板字符串,动态部分编码为 effect
- 运行时:极简的执行模型——
template()创建 DOM,renderEffect()绑定更新并经queueJob异步批处理,setText/setProp等函数直接操作 DOM - 控制流:
createIf、createFor在 DOM 级别实现条件渲染和列表渲染 - 互操作:与 VDOM 模式的桥接机制,支持渐进式迁移
- 性能:不打包 VDOM 渲染器、更新不建 VNode 不 diff、内存里少一棵 VNode 树——量级上的收益,具体数字以 benchmark 实测为准
思考题
-
设计权衡:Vapor Mode 放弃了自定义渲染器的能力(因为直接操作 DOM),如果你需要将 Vue 渲染到 Canvas 或 WebGL,你会如何设计一个 Vapor-style 的自定义渲染器?
-
调度策略:Vapor 的
renderEffect通过queueJob(job, uid, false, order)排队,同一组件内按创建顺序执行。如果一个 effect 在执行中又修改了同组件另一个 effect 已经读过的状态(ALLOW_RECURSE允许它自我重排),会发生什么?这种设计和 VDOM 模式"整个组件一个 job"相比,各有什么风险? -
编译优化:假设一个模板中有 20 个动态绑定,但其中 15 个依赖同一个 ref,5 个依赖另一个 ref。Vapor 编译器应该生成多少个 effect?如何在"更少的 effect 对象"和"更精准的依赖追踪"之间取得平衡?
-
SSR 激活:Vapor 模式下,服务端渲染的 HTML 不需要传统的 VNode 激活过程。但仍然需要"认领"DOM 节点并绑定事件。请设计一种高效的 Vapor SSR 激活算法。
-
生态兼容:如果一个第三方组件库同时提供 VDOM 和 Vapor 两个版本,当它们混用时可能出现哪些问题?如何在框架层面预防这些问题?