Skip to content

第 9 章 Vapor Mode:无虚拟 DOM 的编译目标

本章要点

  • Vapor Mode 的设计动机:为什么 Vue 团队要在虚拟 DOM 之外开辟第二条渲染路径
  • 编译策略的根本转变:从"生成 VNode 创建代码"到"生成 DOM 操作指令"
  • Vapor 编译器的完整流水线:IR 生成、指令选择、代码输出
  • 运行时的极简设计:无 VNode、无 Diff、无 Scheduler 的轻量执行模型
  • 响应式驱动的精准更新:Effect 如何直接绑定到 DOM 操作
  • 与传统 VDOM 模式的互操作:同一应用中两种模式的共存机制
  • 性能对比:Bundle Size、首屏渲染、更新效率的实测数据分析
  • Vapor Mode 对 Vue 生态和未来架构演进的深远影响

前八章中,我们深入剖析了 Vue 3 的响应式系统与编译器。你已经知道,Vue 3 通过 PatchFlags、Block Tree、静态提升等编译期优化,将运行时 Diff 的开销压缩到了极致。但无论怎么优化,只要渲染路径上仍然存在"创建 VNode → Diff VNode → Patch DOM"这条链路,就始终有一层抽象的开销无法消除。

2023 年底,尤雨溪在 VueConf 上首次公开了 Vapor Mode 的设计。这个名字暗示了它的本质——像水蒸气一样,虚拟 DOM 这层"水"被蒸发掉了,只留下最本质的东西:响应式状态到 DOM 操作的直接映射。

本章将完整拆解 Vapor Mode 的编译器与运行时。如果说前几章是在研究 Vue 的"经典力学",那本章就是它的"量子跃迁"——同样的模板语法,全新的执行模型。

9.1 为什么需要 Vapor Mode

虚拟 DOM 的"不可压缩开销"

让我们先量化传统 VDOM 模式下一次更新的成本:

typescript
// 传统 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 优化,我们仍然需要:

  1. 创建完整的 VNode 树(即使大部分节点没有变化)
  2. 逐层比对(即使 Block Tree 已经扁平化了 dynamic children)
  3. 维护 VNode 对象的生命周期(创建、引用、GC)

这三项开销,在 VDOM 架构下是结构性的——你无法通过更聪明的 Diff 算法来消除它们。正如 Svelte 的 Rich Harris 所言:"最快的代码是不存在的代码。"

从 Svelte 和 Solid 获得的启示

在 Vue 之前,Svelte 和 Solid.js 已经证明了"无 VDOM"路线的可行性:

框架策略更新粒度
Svelte编译期生成命令式 DOM 操作语句级
Solid.js编译期 + fine-grained reactivity表达式级
Vue Vapor编译期 + alien signals表达式级

Vue Vapor Mode 的独特之处在于:它不是一个新框架,而是同一框架的第二种编译目标。你的 .vue 文件不需要任何修改,编译器会根据配置选择输出 VDOM 代码还是 Vapor 代码。这意味着:

  • 你可以在同一个应用中混用两种模式
  • 生态系统中的 Composition API 代码完全兼容
  • 迁移成本几乎为零

Vapor Mode 的设计目标

尤雨溪在 RFC 中明确了三个核心目标:

  1. 更小的 Bundle:不需要 VDOM runtime(renderer.tsvnode.tsdiff 相关代码约 15KB gzip),Vapor runtime 仅约 3KB gzip
  2. 更快的更新:跳过 VNode 创建和 Diff,直接从响应式变化映射到 DOM 操作
  3. 更低的内存:不创建 VNode 对象,不维护新旧两棵树

9.2 Vapor 编译器架构

编译流水线对比

传统模式和 Vapor 模式共享相同的 Parse 阶段,但在 Transform 和 Codegen 阶段完全不同:

传统模式:
  Template → Parse → AST → Transform → AST(with codegenNode) → Codegen → render()

                                                                  h() / createVNode()

Vapor 模式:
  Template → Parse → AST → IR Transform → VaporIR → Codegen → setup()

                                                        DOM 操作指令

Vapor IR:中间表示

Vapor 编译器引入了一个全新的中间表示(Intermediate Representation),这是传统编译器中不存在的层级。Vapor IR 不是 AST 的简单变换,而是一种面向 DOM 操作的指令序列:

typescript
// packages/compiler-vapor/src/ir/index.ts
export interface RootIRNode {
  type: IRNodeTypes.ROOT
  source: string
  template: string[]           // 静态模板片段
  block: BlockIRNode           // 根 block
  component: Set<string>       // 使用到的组件
  directive: Set<string>       // 使用到的指令
  effect: IREffect[]           // 副作用列表
}

export interface BlockIRNode {
  type: IRNodeTypes.BLOCK
  dynamic: IRDynamicInfo       // 动态节点信息
  effect: IREffect[]           // 此 block 的副作用
  operation: OperationNode[]   // 操作指令序列
  returns: number[]            // 返回的节点索引
}

// 操作指令类型
export const enum IRNodeTypes {
  ROOT,
  BLOCK,

  // 创建操作
  SET_TEXT,           // 设置文本内容
  SET_HTML,           // 设置 innerHTML
  SET_PROP,           // 设置属性
  SET_DYNAMIC_EVENTS, // 设置动态事件
  SET_CLASS,          // 设置 class
  SET_STYLE,          // 设置 style
  SET_MODEL_VALUE,    // 设置 v-model 值

  // 结构操作
  INSERT_NODE,        // 插入节点
  CREATE_TEXT_NODE,   // 创建文本节点
  CREATE_COMPONENT_NODE, // 创建组件节点

  // 控制流
  IF,                 // v-if
  FOR,                // v-for
  SLOT_OUTLET,        // slot 出口
}

这套 IR 设计的精妙之处在于:它将模板的静态结构动态行为完全分离。静态部分被提取为 template 字符串数组,动态部分被编码为操作指令。

从 AST 到 Vapor IR 的转换

让我们跟踪一个具体的模板,看它如何被转换为 Vapor IR:

html
<template>
  <div class="container">
    <h1>{{ title }}</h1>
    <p :class="textClass">{{ message }}</p>
    <button @click="handleClick">Click me</button>
  </div>
</template>

第一步:提取静态模板

编译器扫描 AST,识别出所有静态部分,生成一个模板字符串:

typescript
// 提取的静态模板
const template = `<div class="container"><h1></h1><p></p><button>Click me</button></div>`

注意:{{ title }}{{ message }}:class="textClass"@click="handleClick" 都被剥离了。它们将以操作指令的形式被还原。

第二步:生成操作指令

typescript
// 生成的 IR 操作指令(简化表示)
const operations = [
  { type: SET_TEXT, element: 'h1', value: () => ctx.title },
  { type: SET_TEXT, element: 'p', value: () => ctx.message },
  { type: SET_CLASS, element: 'p', value: () => ctx.textClass },
  { type: SET_DYNAMIC_EVENTS, element: 'button',
    events: { click: () => ctx.handleClick } }
]

第三步:生成副作用绑定

typescript
// 每个动态绑定被包装为一个 effect
const effects = [
  { deps: ['title'],    operations: [SET_TEXT on h1] },
  { deps: ['message'],  operations: [SET_TEXT on p] },
  { deps: ['textClass'], operations: [SET_CLASS on p] },
]
// 事件绑定不需要 effect,它是一次性的

IR 转换的核心实现

typescript
// packages/compiler-vapor/src/transform.ts
export function transform(
  node: RootNode,
  options: TransformOptions = {}
): RootIRNode {
  const ir: RootIRNode = {
    type: IRNodeTypes.ROOT,
    source: node.source,
    template: [],
    block: createBlock(node),
    component: new Set(),
    directive: new Set(),
    effect: [],
  }

  const context = createTransformContext(ir, node, options)

  // 递归转换每个节点
  transformNode(context, node)

  // 解析模板引用
  resolveTemplate(context)

  return ir
}

function transformNode(
  context: TransformContext,
  node: TemplateChildNode
) {
  switch (node.type) {
    case NodeTypes.ELEMENT:
      transformElement(context, node)
      break
    case NodeTypes.INTERPOLATION:
      transformInterpolation(context, node)
      break
    case NodeTypes.IF:
      transformIf(context, node)
      break
    case NodeTypes.FOR:
      transformFor(context, node)
      break
    case NodeTypes.TEXT:
      // 静态文本,直接归入 template
      break
  }
}

9.3 Vapor Codegen:生成 DOM 操作代码

生成的代码结构

Vapor Codegen 将 IR 转换为最终的 JavaScript 代码。让我们看看上面的模板最终会生成什么:

typescript
// Vapor 编译器的输出
import {
  template,
  children,
  effect,
  setText,
  setClass,
  on,
  createComponent
} from 'vue/vapor'

const t0 = template(
  '<div class="container"><h1></h1><p></p><button>Click me</button></div>'
)

export function setup(_props, { expose }) {
  // 从模板创建 DOM 节点
  const root = t0()

  // 获取需要操作的节点引用
  const h1 = root.firstChild             // <h1>
  const p = h1.nextSibling               // <p>
  const button = p.nextSibling           // <button>

  // 事件绑定(一次性操作,无需 effect)
  on(button, 'click', handleClick)

  // 响应式绑定(用 effect 包裹)
  effect(() => {
    setText(h1, title.value)
  })

  effect(() => {
    setText(p, message.value)
  })

  effect(() => {
    setClass(p, textClass.value)
  })

  return root
}

对比传统 VDOM 模式的输出:

typescript
// 传统 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')
  ]))
}

差异一目了然:

维度VDOM 模式Vapor 模式
每次更新重新执行整个 render 函数只执行变化的 effect
创建的对象每次创建完整 VNode 树零对象创建
DOM 操作经过 Diff 后 patch直接操作
内存分配O(n) VNode 对象O(1) 闭包

基于 VitePress 构建