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 还会编译脚本与组件包装。
// 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 模式输出
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 模式输出
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 的替代实现;在空白测试页控制台运行即可。
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 实验新增独立入口,对照显式委托与直接监听;动态事件名也使用直接监听路径。
<!-- 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>// src/delegation-main.ts
import { createVaporApp } from 'vue'
import DelegationDemo from './DelegationDemo.vue'
createVaporApp(DelegationDemo).mount('#app')<!-- 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 替换为下面完整版本,它保留原来的两个构建模式:
// 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',
},
},
}))npm run typecheck
node deep-compile.mjs
npx vite build --mode delegation --outDir dist-delegationRC9 的 .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,但仍有对象、调度和结构协调;性能需要场景测量,适用范围需要结合固定版本与互操作边界判断。