vapor 性能测试和基本原理浅析

性能提升测试

首屏加载速度 更新耗时 更新内存使用
traditional Mode 1.82s 到 2.28s 143ms 180 mb
Vapor Mode 1.72s 到 2s 25ms 144 mb

测试方法

浏览器:Chorme 版本 135 隐私模式

创建项目,把 vite-plugin-vue-devtools插件删除。删除默认的.vue 文件,删除样式文件。

template 核心代码如下:

js 复制代码
<div class="flat-form">
  <div v-for="item in formData" :key="item.id" class="flat-form-item">
    <input type="text" v-model="item.name" placeholder="Name" />
    <input type="email" v-model="item.email" placeholder="Email" />
    <input
      type="number"
      v-model="item.age"
      min="18"
      max="120"
      placeholder="Age"
    />
    <label class="active-label">
      <input type="checkbox" v-model="item.active" /> Active
    </label>
  </div>
</div>

数据生成逻辑如下:

js 复制代码
const generateTestData = () => {
  formData.value = Array.from({ length: totalItems }, (_, i) => ({
      id: i + 1,
      name: `User ${i + 1}`,
      email: `user${i + 1}@example.com`,
      age: Math.floor(Math.random() * 50) + 18,
      active: i % 2 === 0, // 确保正好一半数据的active为true
    }));
  isLoading.value = false;
}

active 确保正好一半数据的active为true,是为了测试数据更新的时候数据变量的量样本一致。

更新数据的关键代码如下:

js 复制代码
const fixedUpdateCount = 20000;
formData.value.forEach((item, index) => {
  if (index < fixedUpdateCount) {
    item.active = !item.active;
  }
})

性能监听关键代码如下:

js 复制代码
// 等待DOM更新完成
await nextTick();

// 记录结束时间并计算差值
const endTime = performance.now();
updateTime.value = Math.round(endTime - startTime);

if (performance.memory) {
  const memoryUsageBytes = performance.memory.usedJSHeapSize;
  const memoryUsageMB = (memoryUsageBytes / (1024 * 1024)).toFixed(2);
  memoryUsage.value = parseFloat(memoryUsageMB);
  console.log(`Memory usage after update: ${memoryUsageMB} MB`);
}

console.log(`DOM update completed in ${updateTime.value}ms`);

之所以不完全使用控制台中的 Performance 来记录,是因为电脑比较卡,打开控制台本身就消耗了电脑不少性能。而且我们不需要多么精准的数据,需要的是对比。有了对比数据即可。

数据来源

Vapor Mode:

传统模式:

Vapor 工作原理

配置流程

基于 vue3@3.6.0-alpha.2 版本

渐进式使用方式(混合渲染模式)
  1. 从 vue 中导出 vaporInteropPlugin 并 use
js 复制代码
import { createApp, vaporInteropPlugin } from 'vue'

import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(vaporInteropPlugin)
app.use(router)

app.mount('#app')
  1. 在需要使用的 SFC 文件的 setup 中配置上 vapor 即可。
全量使用

修改 main.ts 中的引入组件

js 复制代码
import { createVaporApp } from 'vue'
import App from './App.vue'
createVaporApp(App).mount('#app')

vue 的包体积从 50K 减少到 6k

vapor 执行过程

编译后的文件一般在 source 对应文件的下面,它的作用就是识别.vue 文件,然后把.vue 文件编译为 js 代码

简化代码如下

js 复制代码
const t0 = _template("<button>add count</button>");
const t1 = _template("<div> </div>");
const t2 = _template("<button>update class</button>");
const t3 = _template("<div>我是什么颜色</div>");
function _sfc_render(_ctx, $props, $emit, $attrs, $slots) {
  const n1 = t0();
  const n2 = t1();
  const n3 = t2();
  const n4 = t3();
  n1.$evtclick = _ctx.addCount;
  const x2 = _child(n2);
  n3.$evtclick = _ctx.updateClass;
  _renderEffect(() => {
    _setText(x2, _toDisplayString(_ctx.count));
    _setClass(n4, _ctx.color);
  });
  return [n1, n2, n3, n4];
}

上面是编译后的结果。

编译时: 识别了我绑定的在 DOM 上面可以进行的动态变改的是什么类型的节点。这次我想要改变的是 div 标签中的文本和 div 中字体的颜色。所以在生成的代码中就提前导入了 setText 以及 setClass 两个方法。

setClass为例,参数有两个:_template("<div>我是什么颜色</div>")ctx.color

我们最后来看看_renderEffect函数,看名字你可能已经猜出来了。这个函数和vue3中的watchEffect比较相似,会立即运行一个函数。并且追踪函数中用的的依赖,这里的依赖是msg变量。当依赖的值变化时会再次执行这个函数。

这里的_setText(n1, _ctx.msg),实际就是执行了n1.textContent = _ctx.msgtextContent 属性表示一个节点及其后代的文本内容,也可能通过给它赋值的方式删除它的所有子节点,并替换为一个具有给定值的文本节点,和innerText功能比较相似。

_template 源码
  1. isHydrating 为服务端渲染,服务 SSR,跳过
  2. html[0] !== '<'为 true 说明是纯文本内容,直接创建文本节点,跳过模板解析流程。
  3. t 使用单例模式,不多于创建 <template>
  4. if(!node)缓存首次解析的 DOM 节点,避免重复解析 HTML 字符串, 如果已经存在了,通过 cloneNode 复用已经创建,减少 DOM 操作成本。
_setText 源码

nodeValue:直接修改 DOM 文本节点的内容(textContent 的底层实现)。

这里做了一个优化,如果值没有变化,就不重新个 el.nodeValue赋值。

上面代码转化为下面代码片段,直接改变的是文本本身,因为传进来的 childNodes 只有文本。

js 复制代码
const el = document.getElementById('test').childNodes[0];
el.nodeValue = el.$txt = value
_renderEffect 源码

renderEffect 继承于 ReactiveEffect类,也就是说,我们传进去的函数作为一整个副作用在运行。

watchEffect 本质上是通过封装 ReactiveEffect 类实现的高级用户 API。

this.render()就是执行我们传进行的下面的代码。

js 复制代码
() => {
  _setText(x2, _toDisplayString(_ctx.count));
  _setClass(n4, _ctx.color);
})

_renderEffect(() => { ... }) 会自动追踪其回调函数内访问的所有响应式变量(如 _ctx.count_ctx.color)。无论这些变量是否在本次更新中真正变化,只要它们被访问,就会被记录为依赖。

然后只有其中一个变量改变了,就会执行一次上面的 fn()函数,_setText_setClass都会被执行。

这个应该是需要它们后期优化的,我明明只是修改其中一个变量,为什么另外一个不相关的 _setClass 也执行了呢?但是执行函数里面有了一定的优化,看_setText 源码部分。

上面的代码简而言之,就是 直接调用 DOM API 对变量进行精细化渲染。

也就是说?Vapor在编译时就确定了t1这个DOM节点需要更新 count 数据,不需要再 diff 一遍!

compiler-vapor 源码

IR 是重点.....

编译时,在解析文本,确定哪个 Dom 是需要精准更新的,

源码看不懂,放弃,不是做源码开发,是做业务开发的,不挣扎。

apiCreateFor

diff 不完全从运行时中删除了。比如在 v-for 中。源码位置如下:

packages/runtime-vapor/src/apiCreateFor.ts

源码如下:

在运行时 中,v-for 循环依旧使用了 diff 算法。

总结一下它执行的过程:
  • 编译时:通过 AST 分析标记动态绑定与响应式依赖,生成精准的 DOM 操作指令;
  • 运行时:通过 _renderEffect 实现细粒度更新,完全跳过虚拟 DOM;

所以等同于把 "diff 算法" 或者"虚拟 DOM"这些的操作提前在 vue 的Compiler 中进行了处理。运行时只需要操作 DOM API 精准渲染,不存在寻找这次改变的是哪一个位置的逻辑了,运行时简化了计算节点的逻辑。

这就是本地初始化的时候,首屏加载的速度没有明显的提升,那是因为 Compiler 增加了对于动态绑定标记与响应式依赖分析逻辑,会在本地开发或首次构建时带来额外开销。

传统 node 执行过程

具体代码如下:

简化如下

js 复制代码
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return _openBlock(), _createElementBlock(
    _Fragment,
    null,
    [
      _createElementVNode("button", { onClick: $setup.addCount }, "add count"),
      _createElementVNode(
        "div",
        null,
        _toDisplayString($setup.count),
        1
        /* TEXT */
      ),
      _createElementVNode("button", { onClick: $setup.updateClass }, "update class"),
      _createElementVNode(
        "div",
        {
          class: _normalizeClass($setup.color)
        },
        "我是什么颜色",
        2
        /* CLASS */
      )
    ],
    64
    /* STABLE_FRAGMENT */
  );
}

相比传统的执行过程,vapor 模式执行如下:

总结:

  1. diff 算法的删除减少了 patch 的执行时间。
  2. 虚拟 DOM 删除,减少了大量变量的存在,直接减少了内存的占用。

执行过程

1. 初始化阶段

模板编译:Vue 的编译器会将你的 <template> 部分编译为 渲染函数(render function),生成的虚拟DOM 结构大致如下:

js 复制代码
function render() {
  return h('div', [
    h('h1', 'HelloWorld'),
    h('button', { onClick: () => count.value++ }, `count is ${count.value}`)
  ]);
}

响应式数据绑定:count 被 ref(0) 包裹后,Vue 会将其转换为响应式对象,并建立依赖追踪。

2. 首次渲染

• 生成虚拟 DOM 树,执行渲染函数,生成初始虚拟 DOM(VNode)。

js 复制代码
{
  type: 'div',
    children: [
    { type: 'h1', children: 'HelloWorld' },
    { type: 'button', props: { onClick: handler }, children: 'count is 0' }
  ]
}

• 挂载真实 DOM:Vue 将虚拟 DOM 转换为真实 DOM 并插入页面。

js 复制代码
<div>
  <h1>HelloWorld</h1>
  <button>count is 0</button>
</div>
3. 更新阶段
  • 触发响应式更新:点击按钮 → count.value++ → 触发 count 的 setter 通知依赖(副作用)。
  • 重新生成虚拟 DOM:重新执行渲染函数,生成新的虚拟 DOM。
js 复制代码
{
  button: { ..., children: 'count is 1' } // 只有 count 文本变化
}
  • Diff 算法对比差异:Vue 对比新旧虚拟 DOM,发现只有 button 的文本子节点变化(0 → 1)。
  • 精准更新真实 DOM:直接修改按钮的 textContent,无需重新创建整个 DOM 树。

button.textContent = 'count is 1';

以上便是对 Vue 虚拟 DOM 工作流程的简化描述,Vue 的虚拟 DOM 工作流程在实际执行中还有一些优化细节和边界情况,这个大家有兴趣可以去看一下Vue的源码,这里不做过多的阐述,只是让大家有个印象。

感想

痛苦不会消失,只是会转移,Vapor 将耗时的逻辑从运行时转移到了编译时。但是优化编译时的逻辑,所以实现了总体性能的极大提升的同时,总的痛苦也减少了很多。

当前版本注意事项

官方的话:

  • 适用场景
    ✅ 新项目初始化(createVaporApp
    ✅ 性能敏感页面(如首屏、数据看板)的局部启用(<script setup vapor>
  • 规避风险
    ❌ 避免老项目全量迁移

❌ 暂勿依赖高级功能(如 SSR 激活、异步组件)

❌ 大量依赖第三方 VDOM 组件库(兼容性存在边界问题);

不使用的具体 API

defineAsyncComponent、KeepAlive><Teleport><Suspense>都没有支持,计划中。

列表渲染 v-for 中依旧存在 diff 算法,而且是完整的 diff 算法。

Vapor并不是简单粗暴地抛弃了所有diff算法,而是做了精细化的场景区分。

  • 在90%的常规场景下,通过编译时优化实现了"无diff"更新
  • 在复杂的列表场景下,保留了成熟稳定的diff算法

理出一个简单的表格总结:

场景 Vapor的策略 原因
简单绑定 {{ name }} 直接更新,无diff 编译时就能确定更新目标
条件渲染 v-if 简单替换,无diff 只需要显示/隐藏,不复杂
列表渲染 v-for 保留diff算法 需要处理复杂的增删改移动
相关推荐
weixin_382395235 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__6 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.6 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~7 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华8 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子10 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅10 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni11 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学12 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端