Skip to content
Vue3 与 Vue2 的运行时差异
概述
Vue3 不是 Vue2 的版本升级,而是一次完整的内部重写。虽然对外 API 保留了相当的延续性,但从响应式机制、模块编译器到渲染运行时都经过了重新设计。
响应式系统
基本概念
Vue2 的响应式建立在 Object.defineProperty 之上。这一选择适用于当时的浏览器环境(Proxy 尚未普及),但它决定了响应式追踪的是 对象的属性,而非对象本身。
js
// Vue2 中无法自动追踪的场景
const vm = new Vue({
data: {
obj: { a: 1 },
arr: [1, 2, 3]
}
})
// 新增属性:不触发更新
vm.obj.b = 2
// 通过索引修改数组元素:不触发更新
vm.arr[0] = 99
// 修改数组长度:不触发更新
vm.arr.length = 0Vue2 通过 Vue.set(即 this.$set)以及重写数组的七个变异方法(push、pop、shift、unshift、splice、sort、reverse)来弥补这些漏洞。但如果直接使用索引赋值或修改 length,响应式仍然失效。
Proxy 的工作原理
Vue3 使用 Proxy 代替 Object.defineProperty。Proxy 劫持的是 对象本身 的操作,因此新增属性、删除属性、数组索引赋值等都可以被自动拦截。
下面是一个简化版 reactive() 的实现模型,用以说明核心链路:
js
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, key) // 收集依赖
// 惰性深层代理:访问到的子对象才会被包装为 reactive
return isObject(result) ? reactive(result) : result
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if (oldValue !== value) {
trigger(target, key, value) // 派发更新
}
return result
},
deleteProperty(target, key) {
const hadKey = Object.prototype.hasOwnProperty.call(target, key)
const result = Reflect.deleteProperty(target, key)
if (hadKey) {
trigger(target, key, undefined) // 删除属性也触发更新
}
return result
}
})
}Reflect 在此处的作用不止于“配合 Proxy”:
Reflect.set等方法的返回值是布尔值,Proxy的 handler 需要据此决定是否抛出异常。Reflect.set的receiver参数可以保证在涉及原型链时,this正确指向 Proxy 实例,这是直接使用target[key] = value做不到的。
依赖收集与派发更新
track 和 trigger 是响应式系统的两个核心函数,流程如下:
- 读取属性时:
track(target, key)将当前活跃的effect记录到target → key → dep的依赖图中。 - 写入属性时:
trigger(target, key, value)从依赖图中取出对应的effect集合,并安排它们重新执行。
Vue3 的依赖图结构为 WeakMap<Target, Map<Key, Set<ReactiveEffect>>>。使用 WeakMap 使得当目标对象不再被引用时,其对应的依赖映射可以被垃圾回收,避免内存泄漏。
相比之下,Vue2 的依赖收集发生在 Object.defineProperty 的 getter/setter 闭包中,依赖关系按 Dep(target, key) → [Watcher] 组织,结构上不如 Vue3 清晰,且缺乏自动清理机制。
惰性深层代理
reactive() 只在属性被实际访问时才将嵌套对象转换为 Proxy。Vue2 则在执行 data() 函数时立即深度遍历整个对象,为每个属性调用 defineProperty。因此对于包含大量数据的组件,Vue3 的首屏初始化开销通常更低,响应式转换被分摊到了实际访问路径上。
ref 与 reactive
基本用法
Vue3 提供了两条响应式创建路径:ref 和 reactive。差异源于底层机制,而非风格偏好。
reactive()基于Proxy,只能代理对象类型(Object、Array、Map、Set)。基本类型(string、number、boolean)没有对象引用,无法被Proxy直接代理。ref()内部维护一个带有value属性的对象,该对象本身是 reactive 的,基本类型被包裹在其中:
js
// ref 的简化内部模型
function ref(value) {
const r = {
get value() {
track(r, 'value')
return value
},
set value(newVal) {
if (newVal !== value) {
value = newVal
trigger(r, 'value')
}
}
}
return r
}在 <script setup> 或模板编译阶段,.value 会被编译器自动解包,因此模板中可以直接书写 count。但在 JavaScript 代码中仍需显式访问 count.value。
注意点
reactive() 对象有几个使用限制:
- 不能解构:
const { x, y } = reactive({ x: 1, y: 2 })会得到两个普通数值,需要再用toRefs()保持响应性。 - 不能直接替换引用:
let state = reactive({ ... }); state = reactive({ ... })相当于创建了两个独立的响应式对象,原有引用不会自动追踪新对象。 - 基本类型无法使用
reactive()。
通常不必在这两者之间做“全局选择”。对于单值或需要频繁替换的整体状态,使用 ref;对于一组关联属性构成的对象,使用 reactive 更自然。
shallowRef 与 shallowReactive
shallowRef 和 shallowReactive 提供了更粗粒度的响应式控制:只有顶层属性(或 .value)的变更会触发更新,内部嵌套对象不会被深层代理。当列表项的更新方式主要是“整体替换”而非深层字段修改时,使用 shallowRef 可以省去遍历嵌套对象的开销。
编译器优化
Vue3 的运行时性能提升主要来自于编译器,而非仅仅因为换了 Proxy。
静态提升
Vue2 在每次渲染时都会重新创建整个 VNode 树,即使大部分节点是完全静态的。
Vue3 编译器会识别静态内容,并将其 VNode 创建提升到渲染函数之外,后续渲染直接复用同一个 VNode 引用:
js
// Vue3 编译后(简化示意)
const _hoisted_1 = h('span', '静态文本') // 只创建一次
render() {
return h('div', [
_hoisted_1, // 直接复用
h('span', this.dynamicText) // 仅处理动态节点
])
}Patch Flag(动态节点标记)
编译器会为每个带有动态绑定的节点生成一个数字标记(patch flag),例如 1 表示动态文本,16 表示动态 props。运行时 diff 阶段,先检查标记:
- 标记为
-1(静态节点)时,直接跳过比对。 - 仅有特定标记的节点才进行相应的属性或子节点比对。
这种方式避免了 Vue2 中遍历所有子节点和属性的全量 diff。
Block Tree(靶向 diff)
编译器将模板分割为若干个“block”,每个 block 内部会单独记录所有动态节点。同一 block 内的静态节点在 diff 时被完全跳过,仅处理动态节点集合。
html
<template>
<div> <!-- root block -->
<span>静态文本</span> <!-- 跳过 -->
<div v-if="show"> <!-- 嵌套 block -->
<p>静态</p> <!-- 跳过 -->
<p :id="id">动态</p> <!-- 只 diff 这个节点 -->
</div>
</div>
</template>在典型的业务模板中,静态节点比例通常远高于动态节点,因此靶向 diff 可显著减小每次更新遍历的工作量。
组合式 API
Composition API 的核心价值体现在逻辑组织方式上,它使得同一功能相关的代码可以聚合在一起。
与 Options API 的对比
Options API 按选项类型(data、methods、computed、watch)切分代码,当一个组件的逻辑变复杂时,同一功能被分散在多个配置块中。
Composition API 按逻辑关注点聚合:
js
// Options API:搜索功能分散在不同选项中
{
data() {
return {
searchQuery: '',
searchResults: [],
searchLoading: false
}
},
watch: {
searchQuery: {
handler(q) { this.doSearch(q) },
immediate: true
}
},
methods: {
async doSearch(q) { /* ... */ }
}
}
// Composition API:搜索功能集中封装为 composable
function useSearch() {
const query = ref('')
const results = ref([])
const loading = ref(false)
watch(query, async (q) => {
loading.value = true
results.value = await fetchSearchResults(q)
loading.value = false
})
return { query, results, loading }
}在组件中使用时只需显式调用 useSearch(),返回值来源清晰。相比 mixin,composable 的依赖关系是显式的,不存在命名冲突或来源不透明的问题。
TypeScript 支持
Options API 中的 this 类型推断容易受到 mixin、插件和原型扩展的影响。Composition API 基于普通函数和变量声明,更利于 TypeScript 进行静态分析,从而获得更准确的类型提示。
生命周期钩子
Vue3 的入口函数 setup() 在 beforeCreate 和 created 之间执行。此时组件实例已经创建,但 Options API 的 data 和 computed 等选项尚未初始化,因此 setup() 内部无法使用 this。所需的数据通过 setup(props, { attrs, slots, emit }) 参数显式传入。
生命周期钩子的对应关系如下:
| Vue2 Options API | Vue3 Composition API | 说明 |
|---|---|---|
| beforeCreate | —(setup 取代) | setup() 在此阶段执行 |
| created | —(setup 取代) | |
| beforeMount | onBeforeMount | 命名统一为 onXxx |
| mounted | onMounted | |
| beforeUpdate | onBeforeUpdate | |
| updated | onUpdated | |
| beforeDestroy | onBeforeUnmount | 语义从 “销毁” 调整为 “卸载” |
| destroyed | onUnmounted | |
| errorCaptured | onErrorCaptured | |
| — | onRenderTracked | 调试用,Vue3 新增 |
| — | onRenderTriggered | 调试用,Vue3 新增 |
改名为 unmount 是为了更准确地表示“从 DOM 中移除”这一行为(包括 keep-alive 缓存/激活以及 <Teleport> 的卸载与挂载场景)。
其他变更
Fragment(多根节点)
Vue2 要求组件模板必须有唯一根元素,Vue3 支持多个根节点(Fragment)。编译器内部会用不可见的 Fragment 节点包裹多个根,渲染时展开为平级 DOM 节点,消除了以往用于包裹的无意义 <div>。
Teleport
<Teleport to="body"> 允许将组件的一部分内容在 DOM 层级上挂载到指定目标位置,同时保持其在虚拟 DOM 树中的响应式连接(props、事件、provide/inject 等依旧正常工作),无需再手动操作 document.body.appendChild。
Suspense
<Suspense> 用于处理异步依赖的加载状态,可在异步 setup 或异步组件解析完成前展示 fallback 内容。截至 Vue 3.3+,Suspense 被标记为稳定功能,但部分特殊场景仍需关注其限制。
Tree-shaking
Vue2 的全局 API(Vue.nextTick、Vue.set 等)挂载在构造函数上,无论是否使用都会被引入。Vue3 所有 API 均通过命名导出,打包工具可以只保留实际 import 的部分。Vue 运行时(不包含第三方库)的体积因此缩小了约 30%。
TypeScript 支持
Vue3 本身使用 TypeScript 重写,Composition API 可以利用 defineProps<>()、defineEmits<>() 等内置编译宏获得类型推导,无需依赖额外的装饰器库。
从 Vue2 迁移的主要变更
从 Vue2 迁移到 Vue3 时,需要注意以下变更:
v-model语法变化
Vue2 中v-model绑定的是valueprop 和input事件。Vue3 改为modelValueprop 和update:modelValue事件,并支持多个v-model绑定(如v-model:title)。v-if与v-for优先级反转
Vue2 中v-for优先级高于v-if;Vue3 中v-if高于v-for。当两者同时作用于同一元素时,可能导致行为变化。事件总线 API 移除
$on、$off、$once不再提供。替代方案通常使用外部库(如 mitt)或依赖 provide/inject + 回调。filters 过滤器移除
{{ msg | capitalize }}语法不再支持。可用 computed 属性或函数代替。$listeners合并到$attrs
Vue3 中$attrs包含了所有非 props 属性以及事件处理器(onXxx),不再需要分别传递$attrs和$listeners。
官方提供了 @vue/compat 迁移构建,它模拟 Vue2 的多数行为并提供废弃 API 的警告,支持逐步修复的迁移路径。
常见问题
Vue3 的 diff 算法在任何情况下都比 Vue2 快吗?
不是。当模板中绝大多数节点都是动态节点时,patch flag 和 block tree 的跳过优化会大幅减弱。不过这类模板在实际应用中并不常见。
watchEffect 与 watch 的深层区别是什么?watchEffect 在创建时立即同步执行一次,之后依赖变化时异步执行,无法获得旧值。watch 默认懒执行(可配置 immediate),并暴露新旧值的对比。因此前者适合“依赖变化后执行副作用,但不关心变化前是什么值”的场景,后者适合需要比较值的场景(如脏检测、撤销重做)。
reactive() 有哪些限制?
只能代理对象类型,不可解构,不能整体替换引用。对于这些情况,应使用 ref 或 toRefs()。
nextTick 与 React 的 useEffect 执行时机有何不同?nextTick 在当前同步代码执行后的微任务阶段触发,此时 DOM 已更新但可能尚在浏览器渲染之前,适合需要立即操作更新后 DOM 的场景。useEffect 在浏览器完成绘制后异步执行,不会阻塞渲染。
