Vue 3 设计与实现

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

作者 杨艺韬 · 15,989 字 · 发布于 · 更新于

第 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 优化,我们仍然需要:

  1. 创建完整的 VNode 树(即使大部分节点没有变化)
  2. 逐层比对(即使 Block Tree 已经扁平化了 dynamic children)
  3. 维护 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 的三个核心目标:

  1. 更小的 Bundle:不需要把 VDOM 渲染器(renderer.ts、vnode.ts、diff 相关代码)打进产物,改由 @vue/runtime-vapor 的 helper 承担(调度器等少量基础设施仍复用 runtime-core)
  2. 更快的更新:跳过 VNode 创建和 Diff,直接从响应式变化映射到 DOM 操作
  3. 更低的内存:不创建 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)

这种策略的好处:

  1. 零字符串查找:不需要 ID 或选择器
  2. 编译期确定:节点的位置在编译时就已知
  3. 最小化 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"更高效吗?

答案是:在绝大多数场景下,是的。 因为:

  1. 每个 DOM 操作的开销远小于创建一棵 VNode 树再 diff
  2. 5 个 job 在同一个微任务里连续执行,浏览器不会在中间做 layout / paint
  3. Vapor 的 effect 是细粒度的,不相关的 DOM 操作不会被触发

批处理从哪里来

Vapor 没有自己的 scheduler 文件——packages/runtime-vapor/src 里根本没有 scheduler.ts。批处理来自两层已有机制:

  1. 响应式层:packages/reactivity/src/system.ts 里的 startBatch() / endBatch()(Alien Signals 移植过来的批处理计数器,第 6 章讲过)。一次 ref.value = x 触发的传播会先累积 effect 通知,endBatch() 时才统一调用各个 effect 的 notify()。这两个函数是内部 API,@vue/reactivity 的 index.ts 并没有导出它们。
  2. 调度层: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
}

关键设计点:

  1. ShallowRef 包装:每个列表项被 shallowRef 包装(key、index 只在渲染函数用到它们时才创建),复用旧项时只改 .value,只有依赖该项的 effect 会重新执行
  2. Key-based 复用:与 VDOM 的 key 机制类似,通过 key 匹配来复用已有的 DOM 节点和 effect;每一项有自己的 EffectScope,卸载一项就是 scope.stop() + 移除节点
  3. 没有 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 或子组件实例),不是 VNode
  • rawProps: 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>。真正还在的边界是这些:

  1. 只有 DOM 一个渲染目标:template() 直接 document.createElement('template'),helper 直接调 setAttribute / insertBefore,没有 createRenderer 那样的宿主抽象层,自定义渲染器(Canvas / Native)走不了 Vapor
  2. 不支持 Options API:VaporComponentOptions(component.ts)只有 props / emits / slots / setup / render / inheritAttrs / name / components 这些字段,没有 data / computed / methods / watch / mixins——Vapor 组件必须用 setup() 或 <script setup> 写
  3. 混用需要桥接层:VDOM 组件和 Vapor 组件互相嵌套必须 app.use(vaporInteropPlugin),桥接本身有一层 props / slots 的翻译开销(9.7 节)
  4. 生态是 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 完美匹配:

  1. 推(Push):当 signal 变化时,通知所有订阅的 effect 标记为"脏"
  2. 拉(Pull):当 effect 执行时,才真正从 computed 中拉取最新值
  3. 惰性求值: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.ts 1850 行),建议先读 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 的设计与实现:

  1. 动机:消除 VDOM 的结构性开销——VNode 创建、树 Diff、对象 GC
  2. 编译器:通过 Vapor IR 将模板转换为 DOM 操作指令,静态部分提取为模板字符串,动态部分编码为 effect
  3. 运行时:极简的执行模型——template() 创建 DOM,renderEffect() 绑定更新并经 queueJob 异步批处理,setText/setProp 等函数直接操作 DOM
  4. 控制流:createIf、createFor 在 DOM 级别实现条件渲染和列表渲染
  5. 互操作:与 VDOM 模式的桥接机制,支持渐进式迁移
  6. 性能:不打包 VDOM 渲染器、更新不建 VNode 不 diff、内存里少一棵 VNode 树——量级上的收益,具体数字以 benchmark 实测为准

思考题

  1. 设计权衡:Vapor Mode 放弃了自定义渲染器的能力(因为直接操作 DOM),如果你需要将 Vue 渲染到 Canvas 或 WebGL,你会如何设计一个 Vapor-style 的自定义渲染器?

  2. 调度策略:Vapor 的 renderEffect 通过 queueJob(job, uid, false, order) 排队,同一组件内按创建顺序执行。如果一个 effect 在执行中又修改了同组件另一个 effect 已经读过的状态(ALLOW_RECURSE 允许它自我重排),会发生什么?这种设计和 VDOM 模式"整个组件一个 job"相比,各有什么风险?

  3. 编译优化:假设一个模板中有 20 个动态绑定,但其中 15 个依赖同一个 ref,5 个依赖另一个 ref。Vapor 编译器应该生成多少个 effect?如何在"更少的 effect 对象"和"更精准的依赖追踪"之间取得平衡?

  4. SSR 激活:Vapor 模式下,服务端渲染的 HTML 不需要传统的 VNode 激活过程。但仍然需要"认领"DOM 节点并绑定事件。请设计一种高效的 Vapor SSR 激活算法。

  5. 生态兼容:如果一个第三方组件库同时提供 VDOM 和 Vapor 两个版本,当它们混用时可能出现哪些问题?如何在框架层面预防这些问题?