为什么 Vue 3 的 reactive 不再主要使用 defineProperty?
原文标题是“为什么 Vue 3.0 不再使用 defineProperty 实现数据监听”。更准确的说法是:Vue 3 的
reactive()对象/数组响应式主要使用 Proxy,但 Vue 3 并没有完全不使用 getter/setter。ref.value仍然通过 getter/setter 追踪依赖,组件 public instance 也使用 Proxy 和其他访问器。版本说明:本文以 Vue 2.7.16 与 Vue 3.5.39 的公开行为和固定源码为背景。Vue 2 已结束官方维护,新项目优先 Vue 3。
一、Object.defineProperty 真的不能监听数组下标吗
这句话需要分成两层理解:
Object.defineProperty本身可以给已经存在的数组下标属性定义 getter/setter;- Vue 2 的 Observer 选择了另一种数组实现,没有为每个下标安装 getter/setter,因此 Vue 2 直接通过下标赋值不会自动更新。
先写一个只观察已有下标的实验:
function defineReactive(target, key, value) {
Object.defineProperty(target, key, {
configurable: true,
enumerable: true,
get() {
console.log('get:', key)
return value
},
set(nextValue) {
console.log('set:', key, nextValue)
value = nextValue
},
})
}
function observeExistingIndexes(array) {
Object.keys(array).forEach(key => {
defineReactive(array, key, array[key])
})
}
const list = [1, 2, 3]
observeExistingIndexes(list)
console.log(list[0]) // 触发已有下标 0 的 getter
list[0] = 10 // 触发已有下标 0 的 setter
list.push(4) // 新下标 3 没有被上面的函数定义 getter/setter
这个实验只能证明“已有属性可以被劫持”,不能直接代表 Vue 2 的数组 Observer。
二、Vue 2.7 如何处理数组
Vue 2.7 的 Observer 对数组做了单独处理:
- 不为每个数组下标逐一定义 getter/setter;
- 通过
arrayMethods拦截七个变异方法; - 对
push、unshift和splice插入的新元素递归 observe; - 调用数组自身的 Dep 通知依赖。
七个方法是:
push、pop、shift、unshift、splice、sort、reverse
可以把核心逻辑简化为:
const arrayMethods = Object.create(Array.prototype)
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse',
]
methodsToPatch.forEach(method => {
const original = Array.prototype[method]
Object.defineProperty(arrayMethods, method, {
configurable: true,
value(...args) {
const result = original.apply(this, args)
// Vue 2 实际还会 observe 插入项,并调用 ob.dep.notify()
return result
},
})
})
因此 Vue 2 中以下写法不会自动触发响应式更新:
this.items[index] = nextValue
this.items.length = 0
应改为:
this.$set(this.items, index, nextValue)
this.items.splice(index, 1, nextValue)
或者在 Vue 2 中替换整个数组引用。Vue 2 官方文档说的是七个变异方法,不是“八种方法”。
参考:Vue 2 响应式变化检测限制、Vue 2.7 Observer 源码。
三、Vue 2 的 Vue.set 做了什么
Vue 2 对象新增属性也不能直接被自动观察:
this.user.age = 18 // 如果 age 原来不存在,Vue 2 不会自动追踪
可以使用:
this.$set(this.user, 'age', 18)
// 或 Vue.set(this.user, 'age', 18)
对数组下标,Vue.set 最终会转成 splice;对响应式对象的新 key,则会调用类似 defineReactive 的逻辑并通知依赖。根 Vue 实例和根 $data 还有不能动态扩展的限制。
四、Proxy 和 Object.defineProperty 的差异
4.1 监听对象本身的操作
Object.defineProperty 需要对每个已知属性单独定义 descriptor:
const state = {}
let value = 1
Object.defineProperty(state, 'count', {
get: () => value,
set: next => { value = next },
})
如果之后新增 state.name,原来的 descriptor 并不会自动覆盖它。Proxy 代理的是对象的整体操作:
const state = new Proxy(
{ count: 1 },
{
get(target, key, receiver) {
console.log('读取', key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
console.log('设置', key, value)
return Reflect.set(target, key, value, receiver)
},
deleteProperty(target, key) {
console.log('删除', key)
return Reflect.deleteProperty(target, key)
},
},
)
state.name = 'Vue 3' // 可以拦截新增属性
state.count++ // 可以拦截已有属性的读取和设置
Proxy 还可以拦截 has、ownKeys、getOwnPropertyDescriptor、defineProperty、deleteProperty、getPrototypeOf、setPrototypeOf、isExtensible、preventExtensions 等操作;apply 和 construct 只适用于函数代理,不是所有普通对象都能使用的 trap。
4.2 惰性代理与新增/删除
Vue 3 的 reactive() 通常在读取嵌套对象时惰性创建 Proxy,并通过 WeakMap 缓存代理:
import { reactive } from 'vue'
const state = reactive({
user: { name: 'Vue' },
items: [1, 2, 3],
})
state.user.name = 'Vue 3'
state.user.age = 18
state.items[1] = 20
state.items.length = 1
delete state.user.age
这些操作都可以被 Vue 3 的响应式系统追踪,但 Proxy 并不意味着运行时完全不需要数组处理。Vue 3 仍有 arrayInstrumentations,用于数组迭代、includes/indexOf 身份查询和长度变化时的 tracking/batch 控制。
4.3 Map 和 Set
Vue 2 Observer 主要面向普通对象和数组,Vue 3 还对 Map、Set、WeakMap、WeakSet 提供 collection handlers:
import { reactive, watchEffect } from 'vue'
const state = reactive(new Map<string, number>())
watchEffect(() => {
console.log(state.get('count'))
})
state.set('count', 1)
state.delete('count')
集合的 get、has、size、迭代、add、set、delete 和 clear 有不同的依赖类型,不能简单地用“对象 get/set”解释全部行为。
五、Vue 3 依赖收集的现代模型
教学文章常把依赖系统画成:
对象 -> key -> Set<effect>
Vue 3.5 的概念可以进一步展开为:
WeakMap<rawTarget, Map<key, Dep>>
↓
ReactiveEffect
读取属性时执行 track,设置、删除或迭代时执行 trigger。Vue 3.5 的 Dep 和 Link 用于维护订阅关系,computed、watch 和组件渲染 effect 通过 scheduler/batch 组织更新。内部结构属于实现细节,文章中的简化 Set 模型只用于建立直觉。
六、Vue 3 并没有完全删除 defineProperty
6.1 ref.value 仍然是访问器语义
import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => {
console.log(count.value)
})
count.value++
ref 对象的 .value 通过 getter 进行 track,通过 setter 进行 trigger。也就是说,“Vue 3 完全不使用 defineProperty”是错误的;准确说法是 reactive() 的对象/数组代理不再依靠逐属性 defineProperty。
6.2 组件实例的 public proxy
Vue 3 Options API 中的 this 访问也经过组件 public instance Proxy。setupState、data、props、ctx 和公共属性由不同的访问规则组成。Composition API 则不依赖 this,使用 ref/reactive 和闭包变量。
6.3 reactive 的边界
import { reactive, ref, toRefs } from 'vue'
const state = reactive({ count: 0 })
state.count++
// reactive 只能接收对象类型,不能直接包 primitive
const count = ref(0)
// 解构 reactive 时使用 toRefs 保留响应式连接
const { count: countFromState } = toRefs(state)
还需要注意:
- raw 对象和 proxy 的身份不同,应该持续使用 reactive 返回的 proxy;
reactive()绑定本身不能整体替换为另一个对象;- 直接解构 reactive 属性会断开响应式连接,应使用
toRef/toRefs; - 深层对象默认会递归代理;
- 普通对象中的 ref 可能自动解包,但数组整数元素和 Map/Set 条目不会按同样规则自动解包。
七、为什么 Vue 3 选择 Proxy
可以从几个方面概括:
7.1 能观察属性新增和删除
defineProperty 只能为已经定义的属性安装 descriptor;Proxy 可以拦截 set 和 deleteProperty,因此 Vue 3 不需要 Vue 2 的 Vue.set 来补充普通对象新属性。
7.2 能覆盖更多对象操作
Proxy 可以统一处理读取、设置、删除、in、遍历、原型和集合操作,更适合实现完整的响应式语义。
7.3 数组边界更自然
Vue 3 可以直接追踪数组 index 和 length,不需要 Vue 2 那套通过七个变异方法弥补下标响应式的设计。不过数组查询和批处理仍有专门 instrumentation,不能说“Proxy 让数组完全不需要运行时处理”。
7.4 不需要初始化时深度遍历所有属性
Vue 2 需要在 Observer 阶段递归转换对象;Vue 3 的嵌套对象通常在读取时惰性代理。这可能减少初始化工作,但 Proxy 和依赖追踪仍然有运行时成本,不能用“Proxy 必然更快”代替 profile。
八、浏览器兼容性与 Proxy polyfill
Proxy 是 Vue 3 的平台基础能力之一。Vue 3 不支持 IE11;如果必须支持 IE 等不具备原生 Proxy 的浏览器,应评估继续使用 Vue 2 或更换兼容方案。
某些 Proxy polyfill 只能覆盖少数 trap,可能要求创建代理时属性已知,对数组和完整反射语义支持不足,不能补齐 Vue 3 响应式所需的完整 Proxy 能力。不要把第三方 proxy-polyfill 的浏览器范围宣传成“Vue 3 全面兼容”。
参考:Vue FAQ:浏览器支持、Google proxy-polyfill。
九、SSR 注意事项
Vue 3 SSR 在服务端输出 HTML,服务端不会执行依赖浏览器 DOM 的 mounted/onMounted 逻辑。不要在服务端初始化阶段直接读取 window、document 或随机生成会影响 HTML 的值。
每个 SSR 请求应使用独立的 app、router、store 和请求级状态,避免不同用户之间共享响应式对象。客户端 hydration 还要求服务端和客户端首次输出一致。
十、总结
Object.defineProperty本身可以劫持已有数组下标,但 Vue 2 没有为数组下标逐一安装 getter;- Vue 2 通过七个数组变异方法、
Vue.set和splice弥补响应式限制; - Vue 3
reactive()主要使用 Proxy,能追踪新增、删除、数组下标、length 和集合操作; - Vue 3 仍在
ref.value等位置使用 getter/setter,因此不能说完全不使用defineProperty; - Vue 3 的 Proxy 也不是无成本或无需数组 instrumentation;
- IE 不支持原生 Proxy,polyfill 不能完整模拟 Vue 3 所需语义;
- 具体源码要固定到 Vue 2.7.16、Vue 3.5.39 等版本阅读,不要把旧截图或简化伪代码当成当前源码。
参考资料
- Vue 2 响应式原理
- Vue 2.7 Observer 源码
- Vue 3 响应式深入
- Vue 3 响应式基础
- Vue 3
refAPI - Vue 3.5 Proxy handlers
- Vue 3.5 collection handlers
原文主题和数组响应式实验予以保留;Proxy、ref、数组 instrumentation 和兼容性结论已按 Vue 2.7/Vue 3.5 官方资料重新整理。