2、前置知识:Object.defineProperty 深度解析与 Proxy 初探
在正式进入 Vue 响应式源码之前,有两个东西你必须搞明白。一个是 Object.defineProperty,另一个是 Proxy。
Vue 2 的响应式全靠前者撑着,Vue 3 则换成了后者。说白了,这两个 API 就是 Vue 响应式的「心脏」。今天咱们就把它们彻底扒开看看。
2.1 Object.defineProperty:Vue 2 的基石
这个 API 的作用很简单——给对象定义一个新属性,或者修改已有的属性。但它的厉害之处在于,你可以控制这个属性的「行为」。
我个人习惯把它拆成三部分来理解:
- 数据描述符:value、writable
- 存取描述符:get、set
- 通用描述符:enumerable、configurable
先看个最基础的例子:
const person = { name: '张三' }
Object.defineProperty(person, 'age', {
value: 25,
writable: false, // 不可修改
enumerable: true, // 可枚举
configurable: false // 不可删除
})
person.age = 30
console.log(person.age) // 还是 25,因为 writable 为 false
嗯,这里要注意:configurable: false 意味着你不能再通过 Object.defineProperty 去修改这个属性的描述符了。我在项目中遇到过有人踩这个坑,想动态修改属性配置,结果发现改不了,排查了半天。
2.2 getter 和 setter:这才是核心
Vue 响应式的精髓,其实就在 getter 和 setter 里。
你想想看,当你在 Vue 模板里用 {{ message }},Vue 是怎么知道这个数据变了,然后去更新视图的?
答案就是:Vue 把数据对象的每个属性都「劫持」了。读取属性时触发 getter,修改属性时触发 setter。
const data = { message: 'Hello' }
let _message = data.message
Object.defineProperty(data, 'message', {
get() {
console.log('有人读取了 message')
return _message
},
set(newVal) {
console.log('有人修改了 message')
_message = newVal
// 这里就可以触发视图更新
}
})
data.message // 输出:有人读取了 message
data.message = 'Hi' // 输出:有人修改了 message
是不是很简单?Vue 2 就是靠这个机制,在 setter 里通知所有依赖这个属性的「观察者」去更新。
关键点:Vue 2 的响应式是「递归遍历」的。它会遍历 data 对象的所有属性,对每个属性都调用 Object.defineProperty。这也是为什么 Vue 2 无法检测到对象属性的「新增」和「删除」——因为新增的属性没有经过 defineProperty 处理。
2.3 Object.defineProperty 的局限性
说实话,这个 API 在 Vue 2 里用得挺憋屈的。我总结了几大痛点:
- 无法监听数组变化:直接通过索引修改数组元素,或者修改数组长度,都不会触发 setter。Vue 2 不得不重写了数组的 7 个方法(push、pop、shift、unshift、splice、sort、reverse)来变相实现。
- 无法监听属性新增:给对象新增一个属性,setter 根本不会触发。所以 Vue 2 提供了
Vue.set这个 API。 - 性能问题:递归遍历整个对象,如果对象层级很深,初始化时会有明显的性能开销。
- 需要一次性定义所有属性:你必须在初始化时就定义好所有可能用到的属性,否则后面加的不响应。
避坑指南:我曾经在维护一个老项目时,发现某个表格数据改了但视图不更新。查了半天,原来是后端返回的数据里多了一个字段,而这个字段在 data 初始化时没定义。最后用 Vue.set 才解决。如果你还在用 Vue 2,记住:所有响应式属性必须在 data 里提前声明。
2.4 Proxy:Vue 3 的进化
Vue 3 直接换掉了 Object.defineProperty,改用 Proxy。为什么?因为 Proxy 能解决上面所有问题。
Proxy 可以理解成一个「代理层」。你操作对象时,实际上是在操作这个代理。代理可以拦截 13 种操作,包括读取、赋值、删除、in 操作符等等。
const data = { message: 'Hello', count: 0 }
const proxy = new Proxy(data, {
get(target, key) {
console.log(`读取了 ${key}`)
return target[key]
},
set(target, key, value) {
console.log(`修改了 ${key} 为 ${value}`)
target[key] = value
// 触发视图更新
return true
}
})
proxy.message // 读取了 message
proxy.count = 10 // 修改了 count 为 10
你看,写法上其实差不多。但 Proxy 有几个巨大的优势:
| 对比项 | Object.defineProperty | Proxy |
|---|---|---|
| 拦截方式 | 只能拦截属性的 get/set | 可拦截 13 种操作 |
| 数组支持 | 需要 hack 数组方法 | 原生支持数组索引和长度变化 |
| 属性新增/删除 | 无法检测 | 可以检测 |
| 初始化性能 | 需要递归遍历所有属性 | 懒代理,按需代理 |
| 返回值 | 修改原对象 | 返回新代理对象 |
2.5 Proxy 的进阶用法
除了 get 和 set,Proxy 还有很多实用的拦截操作。我挑几个 Vue 3 里用到的:
const handler = {
// 拦截属性删除
deleteProperty(target, key) {
console.log(`删除了 ${key}`)
delete target[key]
return true
},
// 拦截 in 操作符
has(target, key) {
console.log(`检查 ${key} 是否存在`)
return key in target
},
// 拦截 Object.keys()
ownKeys(target) {
console.log('获取所有属性名')
return Reflect.ownKeys(target)
}
}
const proxy = new Proxy({ name: '张三', age: 25 }, handler)
delete proxy.age // 删除了 age
'name' in proxy // 检查 name 是否存在
Object.keys(proxy) // 获取所有属性名
你可能会问:Reflect 是什么?简单说,Reflect 是 ES6 提供的一个全局对象,它和 Proxy 的 handler 方法一一对应。Vue 3 源码里大量使用了 Reflect.get 和 Reflect.set,而不是直接操作 target[key]。
小技巧:用 Reflect.get 代替 target[key] 的好处是,它能正确处理 getter 的 this 指向。比如对象有继承关系时,Reflect.get 会沿着原型链查找,而 target[key] 只会查当前对象。Vue 3 选择 Reflect 就是为了避免这些边界问题。
2.6 从 defineProperty 到 Proxy:Vue 的进化之路
说实话,Vue 2 用 Object.defineProperty 是当时的最佳选择。毕竟 Proxy 是 ES6 才引入的,而且浏览器兼容性在 2016 年左右还不算好。
但到了 Vue 3 的时代,Proxy 已经可以放心使用了。Vue 3 的响应式系统(reactive、ref、computed 等)全部基于 Proxy 实现。代码更简洁,功能更强大,性能也更好。
我记得刚开始看 Vue 3 源码时,发现它用了一个叫 WeakMap 的东西来存储依赖关系。这个设计非常巧妙——当对象被垃圾回收时,对应的依赖也会自动清除,不会造成内存泄漏。这是 Object.defineProperty 时代很难做到的事情。
好了,前置知识就讲到这里。下一章我们开始手写一个简易的响应式系统,你会亲眼看到这些 API 是如何组合在一起工作的。