Appearance
Vue 3 与 Vue 2 的架构差异对比
从 Vue 2 到 Vue 3,不是简单的版本迭代,而是一次彻底的架构重写。
本章系统梳理两个版本的核心差异。 理解这些差异,能帮你建立正确的认知基础,也能让你从 Vue 2 的经验更快过渡到 Vue 3。
响应式系统:从 defineProperty 到 Proxy
这是最根本的变化。
Vue 2 的实现:Object.defineProperty
Vue 2 的响应式基于 ES5 的 Object.defineProperty:
javascript
function defineReactive(obj, key, val) {
// 每个属性都有自己的依赖收集器
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
// 依赖收集:当属性被读取时
// 将当前正在执行的 watcher 添加到依赖列表
dep.depend()
return val
},
set(newVal) {
// 值相同则跳过
if (newVal === val) return
// 更新闭包中的值
val = newVal
// 触发更新:通知所有依赖这个属性的 watcher
dep.notify()
}
})
}
// 使用时需要对每个属性单独调用
// defineReactive(obj, 'name', obj.name)
// defineReactive(obj, 'age', obj.age)
// 这就是为什么 Vue 2 需要在初始化时遍历整个对象这个 API 的特点是:拦截的是属性的读写操作。
要让一个对象变成响应式,Vue 2 需要遍历它的所有属性,对每个属性调用 defineReactive。如果属性值还是对象,还需要递归处理。
这带来了几个问题:
无法检测属性添加:
javascript
const state = Vue.observable({ name: 'Vue' })
state.version = 3 // 不会触发更新!Object.defineProperty 只能拦截已存在的属性。新增属性没有被拦截,自然不会触发更新。Vue 2 提供了 Vue.set 作为补丁:
javascript
Vue.set(state, 'version', 3) // 这样才能触发更新数组索引问题:
javascript
const state = Vue.observable({ list: [1, 2, 3] })
state.list[0] = 10 // 不会触发更新!虽然技术上可以用 defineProperty 拦截数组索引,但性能代价太高(需要为每个索引设置 getter/setter)。Vue 2 选择重写数组的变异方法(push、pop、splice 等),而不是拦截索引:
javascript
state.list.push(4) // ✅ 触发更新
state.list.splice(0, 1, 10) // ✅ 触发更新
state.list[0] = 10 // ❌ 不触发更新初始化性能:
深层嵌套的对象在初始化时需要递归遍历,全部转换为响应式。即使某些深层属性从未被访问,也会有转换开销。
Vue 3 的实现:Proxy
Vue 3 改用 ES6 的 Proxy:
javascript
function reactive(target) {
return new Proxy(target, {
// get 拦截器:拦截所有属性读取,包括新增属性
get(target, key, receiver) {
// 收集依赖:记录"谁在读取这个属性"
track(target, key)
// 使用 Reflect 保证正确的 this 指向
const result = Reflect.get(target, key, receiver)
// 惰性代理:只有真正访问到嵌套对象时才创建代理
// 对比 Vue 2 的递归遍历,这里的性能更好
if (typeof result === 'object' && result !== null) {
return reactive(result)
}
return result
},
// set 拦截器:拦截所有属性设置,包括新增属性
set(target, key, value, receiver) {
// 先执行实际的赋值操作
const result = Reflect.set(target, key, value, receiver)
// 触发更新:通知所有订阅了这个属性的 effect
trigger(target, key)
return result
}
// Proxy 还支持 deleteProperty、has 等拦截器
// Vue 3 利用这些实现了对属性删除、in 操作符的响应式
})
}Proxy 的特点是:拦截的是对象上的所有操作。
这意味着:
自动检测属性添加:
javascript
const state = reactive({ name: 'Vue' })
state.version = 3 // ✅ 自动触发更新新属性的赋值也会经过 set 拦截器,无需任何特殊处理。
原生数组支持:
javascript
const state = reactive({ list: [1, 2, 3] })
state.list[0] = 10 // ✅ 自动触发更新
state.list.length = 0 // ✅ 清空数组也能检测Proxy 可以拦截任何属性的读写,包括数组索引和 length。
惰性代理:
Vue 3 不在初始化时递归遍历整个对象。只有当访问某个属性时,才会检查其值是否需要转换为响应式。如果一个深层属性从未被访问,就永远不会被处理。
代价
Proxy 不能被 polyfill。这意味着 Vue 3 不支持 IE11。这是一个有意的取舍——为了更好的开发体验和性能,放弃对老旧浏览器的支持。
组件 API:从 Options 到 Composition
Options API 的问题
Vue 2 的组件使用 Options API:
javascript
export default {
data() {
return {
count: 0,
user: null
}
},
computed: {
double() {
return this.count * 2
}
},
methods: {
increment() {
this.count++
},
async fetchUser() {
this.user = await api.getUser()
}
},
mounted() {
this.fetchUser()
}
}这种组织方式在小型组件中工作良好,但随着组件变大,问题出现了:
逻辑分散:一个功能的代码被拆分到 data、computed、methods、mounted 等不同位置。当组件有多个功能时,相关代码交织在一起,难以理解和维护。
逻辑复用困难:Vue 2 提供 mixins 来复用逻辑,但 mixins 有严重的问题:
- 命名冲突:多个 mixins 可能定义同名属性
- 数据来源不清晰:
this.xxx可能来自组件、可能来自某个 mixin - 类型推断困难:TypeScript 很难正确推断
this的类型
Composition API 的解决方案
Vue 3 引入了 Composition API:
javascript
import { ref, computed, onMounted } from 'vue'
// 把"计数器"相关的逻辑封装成一个函数
// 命名约定:以 use 开头,称为"组合式函数"(Composable)
function useCounter() {
// ref 创建响应式引用,.value 访问/修改值
const count = ref(0)
// computed 创建计算属性,自动追踪依赖
const double = computed(() => count.value * 2)
// 普通函数,操作响应式数据
const increment = () => count.value++
// 返回需要暴露的状态和方法
return { count, double, increment }
}
// 把"用户"相关的逻辑封装成另一个函数
function useUser() {
const user = ref(null)
async function fetchUser() {
user.value = await api.getUser()
}
// 生命周期钩子也可以在组合式函数中使用
onMounted(fetchUser)
return { user, fetchUser }
}
export default {
setup() {
// 在组件中组合多个功能
// 数据来源清晰:counter 来自 useCounter
const counter = useCounter()
// userState 来自 useUser
const userState = useUser()
// 暴露给模板
return { ...counter, ...userState }
}
}
// 优势:
// 1. 相关代码在一起(useCounter 里全是计数器逻辑)
// 2. 可复用(useCounter 可以在任何组件中使用)
// 3. 类型安全(每个变量类型明确,TypeScript 完美支持)按功能组织:相关逻辑放在一起,而不是按选项类型分散。
自然的复用:函数是 JavaScript 最基本的复用单元。useCounter 可以在任意组件中调用,没有命名冲突问题。
完美的类型推断:每个变量都有明确的类型,TypeScript 可以完整推断。
两者共存
Vue 3 同时支持 Options API 和 Composition API。你可以继续使用 Options API,甚至可以在同一个组件中混用两者。Composition API 是一个补充,不是替代。
全局 API 重构
Vue 2:全局实例
Vue 2 的 API 挂在全局 Vue 对象上:
javascript
import Vue from 'vue'
Vue.component('MyComponent', { /* ... */ })
Vue.mixin({ /* ... */ })
Vue.use(VueRouter)
Vue.use(Vuex)
new Vue({
el: '#app',
router,
store
})问题:
- 全局污染:
Vue.component注册的组件影响所有实例 - 测试困难:每个测试都共享全局状态,难以隔离
- 多应用冲突:页面上有多个 Vue 应用时,它们共享配置
Vue 3:应用实例
Vue 3 引入 createApp:
javascript
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.component('MyComponent', { /* ... */ })
app.use(VueRouter)
app.use(Vuex)
app.mount('#app')每个 createApp 创建一个独立的应用实例,拥有独立的配置和插件:
javascript
const app1 = createApp(App1)
app1.config.errorHandler = handler1
const app2 = createApp(App2)
app2.config.errorHandler = handler2 // 互不影响这对微前端场景尤其重要——一个页面上的多个 Vue 应用可以完全独立运行。
编译优化
Vue 3 的编译器生成更高效的渲染函数。
静态提升
Vue 2:静态节点每次渲染都重新创建。
javascript
// Vue 2 编译输出
function render() {
// 每次 render 都会创建新的 VNode 对象
// 即使 'static' 永远不会变化
return h('div', [
h('span', 'static'), // 每次都创建新对象,浪费内存
h('span', this.dynamic)
])
}
// 问题:运行时无法知道哪些是静态的,必须全量 DiffVue 3:静态节点提升到模块作用域,只创建一次。
javascript
// Vue 3 编译输出
// 静态节点被"提升"到 render 函数外部
// 整个应用生命周期只创建一次
const _hoisted = h('span', 'static')
function render(ctx) {
return h('div', [
_hoisted, // 直接复用同一个对象,引用不变
h('span', ctx.dynamic)
])
}
// 好处:
// 1. 节省内存(不重复创建)
// 2. Diff 时可以直接跳过(引用相同 = 内容相同)PatchFlags
Vue 2:Diff 时比较节点的所有属性。
Vue 3:编译器标记每个动态节点的变化类型:
javascript
// 编译器分析模板,知道只有 class 是动态的
// 于是生成 PatchFlag = 2(CLASS 常量)
h('span', { class: dynamicClass }, 'text', 2 /* CLASS */)
// PatchFlags 常量定义(简化):
// TEXT = 1 // 只有文本是动态的
// CLASS = 2 // 只有 class 是动态的
// STYLE = 4 // 只有 style 是动态的
// PROPS = 8 // 有动态属性(非 class/style)
// FULL_PROPS = 16 // 有动态 key,需要完整 Diff运行时只比较标记的部分,跳过静态属性。
Block Tree
Vue 2:Diff 时遍历整个子树。
Vue 3:编译器识别结构稳定的区域,生成 Block。Block 追踪其内部所有动态节点的扁平数组,Diff 时直接比较这个数组,跳过树遍历。
新增特性
Fragment
Vue 2 组件必须有单一根节点:
template
<!-- Vue 2:必须包裹 -->
<template>
<div>
<header>...</header>
<main>...</main>
</div>
</template>Vue 3 支持多根节点:
template
<!-- Vue 3:直接多根 -->
<template>
<header>...</header>
<main>...</main>
</template>Teleport
将子节点渲染到 DOM 的其他位置:
template
<template>
<button @click="open = true">打开弹窗</button>
<Teleport to="body">
<div v-if="open" class="modal">
弹窗内容
</div>
</Teleport>
</template>弹窗内容会渲染到 <body> 下,而不是组件内部,避免 CSS 层级和定位问题。
Suspense
协调异步组件的加载状态:
template
<template>
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<Loading />
</template>
</Suspense>
</template>在异步组件加载完成前显示 fallback 内容。
自定义渲染器 API
Vue 3 正式暴露了 createRenderer API,让你可以把 Vue 渲染到任何平台,不仅仅是 DOM。
TypeScript 支持
Vue 2 用 Flow 做类型检查,社区通过 @vue/composition-api 包提供 Vue 3 风格的 API。
Vue 3 完全用 TypeScript 编写,类型定义是第一公民:
typescript
// 完整的类型推断
const count = ref(0) // Ref<number>
const double = computed(() => count.value * 2) // ComputedRef<number>
function useUser() {
const user = ref<User | null>(null)
// 明确的类型签名
}IDE 可以提供准确的自动补全和错误提示。
本章小结
Vue 3 相对于 Vue 2 的核心变化:
响应式系统
- Vue 2:
Object.defineProperty,属性级拦截,需要Vue.set处理新增属性 - Vue 3:
Proxy,对象级拦截,自动检测所有变化
组件 API
- Vue 2:Options API,按选项类型组织代码
- Vue 3:Composition API(与 Options API 共存),按功能逻辑组织代码
全局 API
- Vue 2:挂载在全局
Vue对象上,所有应用共享配置 - Vue 3:
createApp创建独立应用实例,配置隔离
编译优化
- Vue 2:基础优化,每次渲染创建新 VNode
- Vue 3:静态提升 + PatchFlags + Block Tree,大幅减少运行时开销
TypeScript 支持
- Vue 2:Flow 类型检查 + 社区维护的类型定义
- Vue 3:原生 TypeScript 编写,完整类型推断
这些变化的共同目标是:更快、更小、更易维护。
从下一部分开始,我们将深入响应式系统的实现,亲手构建 reactive、ref、computed 等核心 API。
练习与思考
- 在 Vue 2 和 Vue 3 中分别测试以下代码,观察差异:
javascript
// Vue 2
const state = Vue.observable({ list: [] })
state.list[0] = 'test'
console.log(state.list) // 观察是否触发更新
// Vue 3
const state = reactive({ list: [] })
state.list[0] = 'test'
console.log(state.list) // 观察是否触发更新将一个使用 mixins 的 Vue 2 组件重构为使用 Composition API 的 Vue 3 组件,体会两种方式的差异。
思考:Vue 3 放弃 IE11 支持是否是一个正确的决定?这个决定反映了什么样的设计哲学?