Skip to content

D15 · Vapor Mode 深入 ​

对应主课: L38 Vapor Mode 固定版本: Vue / compiler-dom / compiler-vapor 3.6.0-rc.9 当前状态: 预发布 RC,API 子集与兼容边界需按版本核对 最后核对: 2026-09-23


1. Vapor Mode 的定位 ​

Vapor 将支持的 SFC 模板编译成直接驱动 DOM 的代码,其渲染路径不建立 VNode 树。它仍需响应式依赖、调度、作用域清理与列表协调。传统 VDOM 模式继续存在;这不是一条从“全量 diff”必然进化到“性能极限”的替代路线。

本篇沿用 L38 的独立 vue-vapor-lab,不要把课程商城的 Vue 3.5 依赖直接换成 RC。3.6 的响应式内部实现也发生过重构;L31–L37 的 3.5.43 源码说明不能当作 3.6 内部结构的逐行描述。3.6 RC 功能说明


2. 编译输出对比 ​

模板与复现脚本 ​

在 L38 实验根目录新增 deep-compile.mjs,执行 node deep-compile.mjs。两种编译器使用同一个模板,固定包版本均为 3.6.0-rc.9。这里是模板编译结果,完整 SFC 还会编译脚本与组件包装。

javascript
// deep-compile.mjs
import assert from 'node:assert/strict'
import { compile } from '@vue/compiler-dom'
import { compile as compileVapor } from '@vue/compiler-vapor'

const source = '<div><h1>{{ title }}</h1><p>{{ count }}</p><button @click="count++">+1</button></div>'
const vdom = compile(source, {
  mode: 'module', hoistStatic: true, cacheHandlers: true,
}).code
const vapor = compileVapor(source, {
  mode: 'module', prefixIdentifiers: true,
}).code
console.log('VDOM:\n' + vdom)
console.log('Vapor:\n' + vapor)
assert.ok(vdom.includes('export function render(_ctx, _cache)'))
assert.ok(vdom.includes('_cache[0]'))
assert.equal((vdom.match(/1 \/\* TEXT \*\//g) ?? []).length, 2)
assert.equal((vapor.match(/_renderEffect\(/g) ?? []).length, 1)
assert.equal((vapor.match(/_setText\(/g) ?? []).length, 2)
assert.ok(!vapor.includes('createElementVNode'))

VDOM 模式输出 ​

javascript
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("h1", null, _toDisplayString(_ctx.title), 1 /* TEXT */),
    _createElementVNode("p", null, _toDisplayString(_ctx.count), 1 /* TEXT */),
    _createElementVNode("button", {
      onClick: _cache[0] || (_cache[0] = $event => (_ctx.count++))
    }, "+1")
  ]))
}

两个文本节点带 TEXT 标记,事件包装函数保存在实例的 render cache。更新仍会运行相关组件的 render,再由 patch 更新需要变化的 DOM;静态缓存和 Block 优化并没有消失。

Vapor 模式输出 ​

javascript
import { child as _child, next as _next, txt as _txt, on as _on, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, template as _template } from 'vue';
const t0 = _template("<div><h1> </h1><p> </p><button>+1", 1)

export function render(_ctx) {
  const n3 = t0()
  const n0 = _child(n3)
  const n1 = _next(n0)
  const n2 = _next(n1)
  const x0 = _txt(n0)
  const x1 = _txt(n1)
  _on(n2, "click", () => (_ctx.count++))
  _renderEffect(() => {
    _setText(x0, _toDisplayString(_ctx.title))
    _setText(x1, _toDisplayString(_ctx.count))
  })
  return n3
}

注意三个细节:helper 从当前版本的 vue 导出;更新用 renderEffect,不是随意命名的普通 effect;两个绑定在这份输出里被合并进同一个 effect。因而不能推断“一个表达式必然对应一个独立 effect”,也不能说改 count 时永远不会再读取 title。

这个生成函数也叫 render,但其职责是创建 DOM 并建立绑定;后续数据更新由相关响应式任务处理,不重新构造这棵 VNode 树。helper 名称、分组策略和数字标志都是内部实现,应用不应手写这份产物来替代 SFC 编译。


3. template 工厂缓存了什么 ​

3.6.0-rc.9 的 template() 在普通客户端 HTML 路径中解析并缓存模板节点,后续调用克隆节点。源码还处理文本快速路径、SVG / MathML 命名空间、scope ID 与 hydration,不能用十行 clone 代码代表全部实现。固定版本 template.ts

下面仅说明“同一静态结构可以生成多个独立节点”。它是普通浏览器 DOM 教学函数,不是 Vue helper 的替代实现;在空白测试页控制台运行即可。

javascript
function createSingleRootFactory(html) {
  const parsed = document.createElement('template')
  parsed.innerHTML = html
  if (parsed.content.childNodes.length !== 1 || !parsed.content.firstElementChild) {
    throw new Error('教学模型只接受一个元素根节点')
  }
  const cached = parsed.content.firstElementChild
  return () => cached.cloneNode(true)
}

const createCard = createSingleRootFactory('<article><h2>标题</h2><p>正文</p></article>')
const first = createCard()
const second = createCard()
console.assert(first !== second)
first.querySelector('h2').textContent = '只修改第一份'
console.assert(second.querySelector('h2').textContent === '标题')

克隆复制结构,不会复制用 addEventListener 注册的监听器;生成的组件仍要建立事件和响应式绑定。解析与克隆是否更快,取决于结构、浏览器、初始化次数和测量方式,没有固定“3–5 倍”的保证。


4. 性能收益应该怎样验证 ​

成本VDOM 模式Vapor 模式
渲染中间表示VNode、缓存、动态节点信息DOM 引用、绑定及结构 block
更新工作相关 render 与 patch相关响应式任务与 DOM setter
列表变化keyed 协调与移动专门的条目协调与移动,仍需 key
内存组件、依赖、VNode 等对象组件、依赖、DOM 引用、列表记录等对象
包体积取决于引入的功能纯模式可减少 VDOM runtime;互操作会带回相关代码

本文不提供未经同条件测量的 KB、倍数或百分比。测试时应固定锁文件、生产构建、浏览器、数据和操作序列,分别记录初次挂载、定点更新、列表重排与卸载后的资源情况。纯版和混合版必须先具备相同功能,才能比较;L38 的两个入口用于证明接线,不是公平的性能对照组。


5. 限制与取舍 ​

需求3.6 RC 的边界
模板组件使用支持的 SFC 模板与 <script setup vapor>
ref / reactive / computed / watch可沿用支持的响应式 API;不是只有 Proxy 一条实现路径
v-if / v-for / 动态组件仍有结构协调;动态组件是否需要互操作取决于目标组件类型
Options API、render / JSX 组件保留为 VDOM 组件,混用时安装互操作插件
VNode 与公开实例代理相关能力不应假定可用,如 globalProperties、传统实例属性访问
事件与插槽有事件委托、插槽执行等差异,需要单独验证

官方 RC 说明列出了尚不支持的 API,也说明互操作覆盖常见 props、events、slots,并不保证所有组件库边界都已兼容。兼容范围

实验:祖先阻止冒泡后,委托事件为什么不执行 ​

在固定的 RC9 中,普通 @click 直接监听;显式写 @click.delegate 才会在满足条件时委托到 document。若原生祖先监听器先阻止冒泡,事件到不了 document,委托处理器便不会运行。下面在 L38 实验新增独立入口,对照显式委托与直接监听;动态事件名也使用直接监听路径。

html
<!-- delegation.html -->
<!doctype html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Vapor 事件委托</title></head>
<body><div id="app"></div><script type="module" src="/src/delegation-main.ts"></script></body>
</html>
typescript
// src/delegation-main.ts
import { createVaporApp } from 'vue'
import DelegationDemo from './DelegationDemo.vue'
createVaporApp(DelegationDemo).mount('#app')
vue
<!-- src/DelegationDemo.vue -->
<script setup lang="ts" vapor>
import { onMounted, onUnmounted, ref } from 'vue'

const delegated = ref(0)
const direct = ref(0)
const blocked = ref(true)
const eventName = ref('click')
let host: HTMLElement | null = null
function blockAtHost(event: Event) {
  if (blocked.value) event.stopPropagation()
}
onMounted(() => {
  host = document.getElementById('app')
  host?.addEventListener('click', blockAtHost)
})
onUnmounted(() => host?.removeEventListener('click', blockAtHost))
</script>

<template>
  <section>
    <p>阻止事件到达 document:{{ blocked ? '是' : '否' }}</p>
    <button @click.delegate="delegated++">委托计数:{{ delegated }}</button>
    <button @[eventName]="direct++">直接计数:{{ direct }}</button>
    <button @[eventName]="blocked = !blocked">切换祖先拦截</button>
  </section>
</template>

启动 npm run dev 后打开 http://127.0.0.1:5176/delegation.html:初始点击“委托计数”不增加,点击“直接计数”会增加;用直接监听的第三个按钮关闭祖先拦截后,委托计数也能增加。

为了把新入口纳入生产构建,将 L38 的 vite.config.ts 替换为下面完整版本,它保留原来的两个构建模式:

typescript
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => ({
  plugins: [vue()],
  build: {
    rollupOptions: {
      input: mode === 'delegation' ? 'delegation.html' : mode === 'interop' ? 'interop.html' : 'index.html',
    },
  },
}))
bash
npm run typecheck
node deep-compile.mjs
npx vite build --mode delegation --outDir dist-delegation

RC9 的 .delegate 仅用于支持的静态原生事件;动态事件名、事件选项等会影响是否能委托。§2 中没有 .delegate 的按钮因此生成 _on。不要把 RC1 发布说明中的默认委托行为直接套到 RC9,实际采用时应核对固定版本。RC9 vOn 转换、RC9 事件运行时


6. 与 Svelte / Solid 的关系 ​

方案编译与响应式的关系
Svelte 5编译模板与 runes,仍有运行时响应式机制;不能只用旧 $: 语法概括
Solid信号及观察者驱动细粒度更新,编译输出与运行时配合
Vue Vapor使用该 Vue 版本的响应式系统,模板编译输出 DOM 与绑定逻辑

共同点是减少某些更新中的树形中间工作,具体组件语义、事件和工具链并不相同。已有 Vue 知识能复用,但不能保证零迁移成本。Svelte runes、Solid 响应式说明


7. 当前使用方式 ​

截至核对日,本课选择的 3.6.0-rc.9 是预发布版本。RC 说明称预定 Vapor 功能集已完成,含义是达到该轮预定范围,不是所有 Vue API 都支持,也不是已宣布稳定版。固定版本发布页

纯 Vapor 应用用 createVaporApp;VDOM 应用使用 Vapor 组件,或反过来混用,按 L38 安装 vaporInteropPlugin。优先让模式边界清楚,逐项验证第三方组件、事件、插槽和卸载清理。升级 RC 时,compiler 与 runtime 应一起核对,不能只改一个包版本。

8. 怎样解释 Vapor ​

可以从本篇编译结果说明:模板工厂创建 DOM,生成代码取得节点并建立事件、响应式绑定,后续由相关任务更新 DOM。其路径省去 VNode 构造与树形 patch,但仍有对象、调度和结构协调;性能需要场景测量,适用范围需要结合固定版本与互操作边界判断。