L38 · Vapor Mode:直接驱动 DOM 的编译模式
🎯 本节目标:理解 Vue Vapor Mode 的设计目标、编译策略与适用边界
📦 本节产出:固定版本编译输出 + 纯 Vapor 与 VDOM 互操作实验
🔗 前置钩子:L33-L34 的 Virtual DOM + 编译器优化
🔗 后续钩子:Phase 4 完结,进入 D01–D15 深度专题1. 什么是 Vapor Mode
Vapor Mode 将支持的 SFC 模板编译为直接驱动 DOM 的代码,其自身渲染路径不创建 VNode 树。 本课固定使用 Vue 3.6.0-rc.9(预发布版,2026-09-23 核对);L31–L37 的 3.5.43 源码结论不直接套用到这个版本。实验放在独立目录,不升级前面商城项目。固定版本 release
编译器把模板中的 DOM 结构和绑定关系提前转成代码,运行时保留响应式依赖、DOM 引用和结构控制逻辑。动态分支、列表与组件仍需要运行时支持,不能把“没有 VNode”理解为“没有运行时”。
2. 编译输出对比
使用同一个模板,并让两种编译器都固定在 3.6.0-rc.9。以下是模板编译 API 的输出;完整 SFC 还包含脚本编译与组件包装。§6 的脚本可重现这些输出:
<div class="counter"><h1>{{ title }}</h1><p>Count: {{ count }}</p><button @click="count++">+1</button></div>传统 VDOM 模式
@vue/compiler-dom 使用 { mode: 'module', hoistStatic: true }:
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
const _hoisted_1 = { class: "counter" }
const _hoisted_2 = ["onClick"]
export function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createElementVNode("h1", null, _toDisplayString(_ctx.title), 1 /* TEXT */),
_createElementVNode("p", null, "Count: " + _toDisplayString(_ctx.count), 1 /* TEXT */),
_createElementVNode("button", {
onClick: $event => (_ctx.count++)
}, "+1", 8 /* PROPS */, _hoisted_2)
]))
}这里仍有 Block/PatchFlag 优化;其他模板还可能命中静态缓存,不能把 VDOM 路径概括为每次重建并比较整个应用。
Vapor Mode
@vue/compiler-vapor 使用 { mode: 'module', prefixIdentifiers: true }:
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 class=counter><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, "Count: " + _toDisplayString(_ctx.count))
})
return n3
}模板工厂创建结构,生成代码取得节点并注册事件、响应式更新。这份结果把 title 与 count 两个文本更新放进同一个 renderEffect,因此绑定与 effect 并非总是一一对应。分组策略与 helper 名称属于这个 RC 的实现,不是应用应该直接调用的稳定接口。
两条路径都需要依赖追踪、更新调度和清理。Vapor 省去的是其渲染路径中的 VNode 构造与树形 patch 中间层。
3. 性能对比
3.1 运行时开销
| 工作 | 传统 VDOM | Vapor Mode |
|---|---|---|
| 更新表示 | VNode、缓存与动态节点信息 | DOM 引用、绑定及结构 block |
| 内容更新 | render 后由 patch 决定 DOM 修改 | 响应式更新调用生成的 DOM 操作 |
| 列表重排 | keyed diff 协调节点 | 专门的列表逻辑协调条目与 DOM |
| 内存与清理 | VNode、组件状态、依赖等 | DOM 引用、作用域、依赖及列表记录等 |
| 性能结论 | 取决于模板、更新类型和编译优化 | 可能减少中间工作,仍须测量具体场景 |
没有 VNode 不等于只有 DOM 引用或 GC 成本接近零;两种模式都创建 JavaScript 对象,也都要管理资源。
3.2 包体积
纯 Vapor 的 createVaporApp 可以避免引入 VDOM runtime;加入互操作插件或 VDOM 组件后会带回相应代码。不要把某个最小示例的 gzip 大小当作固定的“Vue 运行时大小”。
比较时应固定依赖锁文件、生产模式、相同页面功能、压缩方式和加载边界,分别记录纯 Vapor、纯 VDOM、混合版本的产物;§6 的纯版与混合版用于验证接线,二者功能不同,不能直接用于性能排名。测量流程沿用 L30。
3.3 根据功能选择模式
| 需求 | 判断依据 |
|---|---|
| 大量 keyed 列表增删 | 两种模式都有列表协调;用目标数据规模与操作序列实测 |
| 非 DOM 自定义渲染器 | 本课的 Vapor 面向 DOM,VDOM 的 custom renderer 是另一条路径 |
| render 函数 / JSX | 仍是 VDOM 组件;在 Vapor 应用内使用需互操作 |
| Options API 组件 | 不属于 Vapor 支持的组件写法,可留在 VDOM 区域 |
| 单点高频更新 | 可测试 Vapor 是否减少渲染工作,不能直接保证应用更快 |
| 无交互的静态页面 | 先判断是否需要客户端运行时,不因静态就必选 Vapor |
4. Vapor 处理条件和列表
4.1 v-if
<div v-if="show">Hello</div><div v-else>Bye</div>固定版本输出把条件与两个分支工厂交给 createIf:
import { createIf as _createIf, template as _template } from 'vue';
const t0 = _template("<div>Hello", 3)
const t1 = _template("<div>Bye", 3)
export function render(_ctx) {
const n0 = _createIf(() => (_ctx.show), () => {
const n2 = t0()
return n2
}, () => {
const n4 = t1()
return n4
}, 357 /* TRUE_SINGLE_ROOT, FALSE_SINGLE_ROOT, TRUE_NO_SCOPE, FALSE_NO_SCOPE, KEYED_INDEX_0 */)
return n0
}createIf 管理分支切换及关联作用域;真实分支可能不止一个 DOM 节点。上面的数字是编译器内部标志,只用于观察,不要求记忆或手写。
4.2 v-for
<div v-for="item in list" :key="item.id">{{ item.name }}</div>此模板生成 createFor,传入列表 getter、每项的创建逻辑与 item => item.id 键函数。它仍要识别保留、新增、移除、移动的条目;“没有 VDOM diff”不等于“列表不用协调”。用稳定 key 保留条目身份的原则仍成立。§6 的重排按钮可验证 DOM 节点复用,修改名称则观察条目内的响应式绑定。
5. 与其他框架的对比
这里比较渲染路径,不给没有同条件测量的体积与速度排名:
| 方案 | 与编译和更新相关的特点 |
|---|---|
| React 19 | 组件 render 描述界面,由协调器处理更新;React Compiler 1.0 已稳定,可加入编译期 memoization |
| Vue VDOM | 模板编译提供静态缓存、PatchFlag 与 Block 信息,运行时仍使用 VNode |
| Svelte 5 | 编译器处理模板与 runes,运行时响应式支持仍存在,不是“完全消除框架” |
| Solid | 细粒度响应式依赖驱动 DOM 更新,组件与响应式计算的执行范围不同 |
| Vue 3.6 RC Vapor | 编译器生成 DOM 与响应式更新代码,支持 Vue API 的一个子集 |
React 不是“纯运行时、每次全量 diff”,Svelte 5 也不能只用旧式赋值转换概括。依据:React Compiler 1.0、Svelte runes、Solid 细粒度响应式。
6. 当前状态与使用方式
3.6.0-rc.9 仍是预发布版本。官方 RC 说明包含 API 子集、事件委托、插槽与互操作边界;这里验证计数、分支、keyed 列表以及标准 props/events,不据此推断所有第三方组件库都兼容。RC 功能与互操作说明
6.1 建立独立实验
使用 Node 22.12+(22.x),在课程仓库外创建 vue-vapor-lab,把以下文件保存到对应路径。版本固定便于重复编译实验;后续更新 RC 时应一起核对 compiler/runtime:
mkdir vue-vapor-lab
cd vue-vapor-lab
mkdir src// package.json(保存时删除此注释)
{
"name": "vue-vapor-lab",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5176 --strictPort",
"typecheck": "vue-tsc --noEmit",
"build": "vite build",
"build:interop": "vite build --mode interop --outDir dist-interop",
"compile": "node compile.mjs"
},
"dependencies": { "vue": "3.6.0-rc.9" },
"devDependencies": {
"@vue/compiler-dom": "3.6.0-rc.9",
"@vue/compiler-vapor": "3.6.0-rc.9",
"@vitejs/plugin-vue": "6.0.9",
"@types/node": "22",
"typescript": "5.9.3",
"vite": "6.4.3",
"vue-tsc": "3.3.11"
},
"overrides": { "vue": "$vue" }
}plugin-vue 的 Vue peer 范围尚未包括预发布版;这个独立实验用 overrides 明确选择上面声明的 RC,不使用 force 或忽略整个依赖树的校验。保存 npm install 生成的 lockfile。
// tsconfig.json(保存时删除此注释)
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "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 === 'interop' ? 'interop.html' : 'index.html' } },
}))Vapor 标记写在 SFC 上,插件沿用 vue() 默认配置。与 L35 无插件的实验不同,这里由 Vue 插件处理编译标志。
<!-- index.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/main.ts"></script></body>
</html>// src/main.ts
import { createVaporApp } from 'vue'
import App from './App.vue'
createVaporApp(App).mount('#app')<!-- src/App.vue -->
<script setup lang="ts" vapor>
import { ref } from 'vue'
withDefaults(defineProps<{ label?: string }>(), { label: '纯 Vapor' })
const emit = defineEmits<{ change: [value: number] }>()
const count = ref(0)
const show = ref(true)
const list = ref([
{ id: 1, name: 'Apple' }, { id: 2, name: 'Banana' }, { id: 3, name: 'Cherry' },
])
let nextId = 4
function increment() { emit('change', ++count.value) }
function addItem() { list.value.push({ id: nextId++, name: 'New item' }) }
</script>
<template>
<main>
<h1>{{ label }}</h1>
<p>Count: {{ count }}</p>
<button @click="increment">+1</button>
<button @click="show = !show">切换分支</button>
<p v-if="show">Hello</p><p v-else>Bye</p>
<button @click="list = [...list].reverse()">反转列表</button>
<button @click="addItem">新增条目</button>
<button :disabled="list.length === 0" @click="list = list.slice(1)">删除首项</button>
<ul>
<li v-for="item in list" :key="item.id" :data-id="item.id">
<input v-model="item.name" :aria-label="`条目 ${item.id}`">
<span>{{ item.name }}</span>
</li>
</ul>
</main>
</template>6.2 在 VDOM 应用嵌入 Vapor
增加下面三个文件。普通 VDOM 根组件通过 props 与 events 和 Vapor 子组件通信,入口必须安装 vaporInteropPlugin:
<!-- interop.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/main-interop.ts"></script></body>
</html>// src/main-interop.ts
import { createApp, vaporInteropPlugin } from 'vue'
import VdomApp from './VdomApp.vue'
createApp(VdomApp).use(vaporInteropPlugin).mount('#app')<!-- src/VdomApp.vue:没有 vapor 标记 -->
<script setup lang="ts">
import { ref } from 'vue'
import App from './App.vue'
const lastCount = ref(0)
const label = ref('VDOM 父组件传入的标题')
const visible = ref(true)
</script>
<template>
<p>父组件收到:{{ lastCount }}</p>
<button @click="label = '标题已更新'">修改子标题</button>
<button @click="visible = !visible">挂载或卸载子组件</button>
<App v-if="visible" :label="label" @change="lastCount = $event" />
</template>反向在 Vapor 根应用使用 VDOM 子组件也要安装同一个插件,并会带入 VDOM runtime。Options API 与 JSX/render function 不能直接成为 Vapor 组件;已有 VDOM 应用需要逐个确认组件用到的 API,再决定迁移范围。
6.3 重现编译输出并运行
// compile.mjs
import { compile as compileVDOM } from '@vue/compiler-dom'
import { compile as compileVapor } from '@vue/compiler-vapor'
const templates = [
'<div class="counter"><h1>{{ title }}</h1><p>Count: {{ count }}</p><button @click="count++">+1</button></div>',
'<div v-if="show">Hello</div><div v-else>Bye</div>',
'<div v-for="item in list" :key="item.id">{{ item.name }}</div>',
]
for (const template of templates) {
console.log('Template:', template)
console.log('VDOM:', compileVDOM(template, { mode: 'module', hoistStatic: true }).code)
console.log('Vapor:', compileVapor(template, { mode: 'module', prefixIdentifiers: true }).code)
}npm install
npm run typecheck
npm run compile
npm run build
npm run build:interop
npm run dev打开 http://127.0.0.1:5176/ 测试计数、分支、列表增删与重排;在输入框改名,文本应同步变化,重排时该 id 的 DOM 节点应被复用。再打开 /interop.html,检查子组件事件更新父状态、父 props 更新标题、卸载后重建子计数回到 0。构建成功只说明代码可打包,这些交互还需要浏览器验证。
7. Phase 4 总结
知识链路
用户代码 → 响应式读取(ref 访问器 / Proxy)→ 收集依赖 → 数据变化 → 通知
→ scheduler 排队 → 微任务执行 → render 生成 VNode
→ diff 找到变化 → patch 操作 DOM → 浏览器渲染 → 用户看到更新
Vapor 简化为:
响应式读取 → 依赖订阅 → 数据变化 → 调度相关更新 → DOM 操作 → 浏览器渲染Git 提交
git add .
git commit -m "L38: Vapor Mode 与 Phase 4 实验"
git tag phase-4-complete🔬 深度专题
📖 D15 · Vapor Mode 原理 — 没有虚拟 DOM 的 Vue 还是 Vue 吗?