引言:在我们前端面试中面试官经常会问到你这样一个问题:"请你聊一聊object.defineProperty和proxy的区别"。这两个东西分别是Vue2和Vue3实现响应式的技术路线。接下来我们看看他们俩之间底层逻辑的区别到底是啥。
一、Object.defineProperty实现解析
我们先看这样一个场景,我们先创建一个data
对象,并且想要在修改对象里某个属性的值时触发视图更新。
js
function updateView(fn) {
console.log('视图更新');
}
let data = {
name: '牢大',
age: 18,
like: ['吃饭', '睡觉']
}
data.age = 19
我们希望的是当data.age = 19
这段代码执行时updateView
函数触发。我们要实现这个效果的关键在于我们现在执行data.age = 19
这段代码时没有地方让视图更新函数执行。
为了解决这个问题我们可以引入一个Observe
观察者,由他来监控data
对象的行为,就像考试时监控室里的老师,无时不刻地盯着屏幕看有没有人在作弊。
js
function observer(target) {
if (typeof target !== 'object' || target == null) { //判断是否为对象
return target
}
for (let key in target) { // 遍历对象
defineReactive(target, key, target[key])
}
}
我们希望有一个函数能够将传入Observe的对象变成一个响应式对象,于是我们就有了下面这个方法
js
function defineReactive(target, key, value) {
Object.defineProperty(target, key, { // 数据劫持
get() {
},
set(newVal) {
}
})
}
Object.defineProperty
是它是 ES5
中定义对象属性的方法,直接对对象的某个属性进行劫持。target
代表要劫持哪个对象,key
代表要劫持对象的哪个属性,value
代表要劫持的属性的值。当Object.defineProperty
劫持完对象后,它提供了一个get
和set
函数。
这时如果我们想要执行下面这段代码会得到什么结果呢
js
function observer(target) {
if (typeof target !== 'object' || target == null) { //判断是否为对象
return target
}
for (let key in target) { // 遍历对象
defineReactive(target, key, target[key])
}
}
function defineReactive(target, key, value) {
Object.defineProperty(target, key, { // 数据劫持
get() {
},
set(newVal) {
}
})
}
function updateView(fn) {
console.log('视图更新');
}
let data = {
name: '牢大',
age: 18,
like: ['吃饭', '睡觉']
}
observer(data)
console.log(data.age)
可以看到运行结果是undefined
,这是因为一旦对象里面的属性被劫持了,就不会按照传统的方式去访问。就像是绑匪把对象的某个属性劫持了,现在通过一般途径看不见这个属性。如果想要访问到对象里的属性就要通过绑匪里面的get
函数。
现在我们完善Object.defineProperty
里的get
函数再运行下试试看
js
function defineReactive(target, key, value) {
Object.defineProperty(target, key, { // 数据劫持
get() {
return value
},
set(newVal) {
}
})
}
可以发现可以正常访问到属性里的值了。但是如果我们返回的是其他值比如99呢。访问属性里的值得到的是什么呢?
js
function defineReactive(target, key, value) {
Object.defineProperty(target, key, { // 数据劫持
get() {
return 99
},
set(newVal) {
}
})
}
不难看出我们现在访问对象里的属性的值这个行为完全被Object.defineProperty
里的get
方法代理了
那修改值这个行为是不是也是被Object.defineProperty
里的set
方法代理了呢
js
observer(data)
data.age = 19
console.log(data.age)
我们现在得到的结果还是18,现在无法修改值,我们继续完善set
函数再试试看
js
function defineReactive(target, key, value) {
Object.defineProperty(target, key, { // 数据劫持
get() {
return value
},
set(newVal) {
if (newVal !== value) {
value = newVal
updateView()
}
}
})
}
我们可以发现我们是通过Object.defineProperty
里的set
方法来进行值修改这个行为的。
我们现在把对象里的属性的值改为引用类型看看会有什么样的结果
js
let data = {
name: '牢大',
age: {
n: 18
},
like: ['吃饭', '睡觉']
}
observer(data)
data.age.n = 19
console.log(data.age.n)
我们发现可以成功修改值,但是!仔细看,是不是少了打印视图更新。因为我们前面已经得出结论对象被劫持后修改属性的值的行为会被代理。说明我们根本没有劫持到属性!我们只能劫持到第一层属性age
,里面的n
劫持不到。
为了能够劫持更深层次的属性,我们在数据劫持时要做一个递归的操作。如果key
的value
值也是引用类型我们也把value
劫持。
js
function defineReactive(target, key, value) {
observer(value)
if (typeof value === 'object' && value !== null) {
observer(value)
}
Object.defineProperty(target, key, { // 数据劫持
get() {
return value
},
set(newVal) {
if (newVal !== value) {
value = newVal
updateView()
}
}
})
}
我们再次执行上面代码就会发现深层次的value也被劫持了
js
let data = {
name: '牢大',
age: {
n: 18
},
like: ['吃饭', '睡觉']
}
observer(data)
data.age.n = 19
console.log(data.age.n)
现在又有一个问题,假如我们对象的属性的值为一个数组类型,我们往数组里面添加元素,会触发视图更新吗?
我们先来看能不能访问得到这个数组
js
observer(data)
data.like.push('coding')
console.log(data.like)
我们可以看到我们通过Object.defineProperty
自带的Getter
方法访问到了数组,但是没有触发视图更新,但是数组上的push
方法无法触发数据劫持的Setter
方法。
如果这样的话在Vue2中遇到data.like.push('coding')
这样的代码岂不是无法做到响应式了,为了解决这个问题Vue官方重写了数组身上的方法
js
let oldArrayPrototype = Array.prototype
我们先拿到了Array
构造函数的显式原型,之所以数组能用push
这个方法就是因为Array
构造函数的显式原型上有push
这个方法。
js
let proto = Object.create(oldArrayPrototype)
我们调用Object.create方法创建了一个新对象,并且这个新对象的隐式原型为Array
构造函数的显式原型。这样我们就拷贝了一份数组的原型。
Vue官方把数组身上所有的方法都重写了,我们看样例代码
js
Array.from(['push', 'pop', 'shift', 'unshift']).forEach(method => {
proto[method] = function (...args) {
oldArrayPrototype[method].call(this, ...args) //仍然为官方的方法
updateView() // 额外添加视图更新的逻辑
}
})
既然JS官方打造的数组身上的方法无法触发响应式,那么我们就重写JS官方的代码,在官方的方法上额外添加实现响应式的功能。
那么现在问题又来了,如何让程序员用到的是Vue重写后的数组的方法而不是JS原本的数组的方法?我们接下来看代码。
js
function observer(target) {
if (typeof target !== 'object' || target == null) {
return target
}
if (Array.isArray(target)) {
target.__proto__ = proto
return
}
for (let key in target) {
defineReactive(target, key, target[key])
}
}
因为Observe已经观察了这个对象,如果这个对象是一个数组的话。那么我们就直接把对象的隐式原型赋值为我们打造的新的对象的原型。
我们再看一个例子,如果往对象里面添加一个属性是否会被劫持呢?
js
observer(data)
data.sex = 'boy'
console.log(data);
可以看到没有触发视图更新效果。说明往对象上添加属性也不会被
Object.defineProperty
劫持。
我们现在来总结下Object.defineProperty
的特性
- 属性级拦截 :通过重新定义对象的某个属性(
getter/setter
)实现拦截。 - 静态劫持:必须在对象初始化时显式定义劫持的属性,无法自动拦截新增属性。
- 侵入性修改:会直接修改原始对象的属性描述符。
- 仅支持基础操作 :只能拦截属性的读取(
get
)和设置(set
)。 - 无法拦截以下操作 :
- 属性的删除(
delete obj.a
) - 检测属性存在性(
key in obj
) - 遍历对象(
Object.keys(obj)
) - 数组的索引修改(
arr[0] = 1
)和长度变化(arr.length = 0
)
- 属性的删除(
- 无法直接监听数组变化 :需通过重写数组方法(如
push
、pop
等)实现响应式。 - 初始化性能消耗:需要递归遍历对象的所有属性进行劫持。
二、Proxy实现解析
同样的场景,我们先创建一个data
对象,并且想要在修改对象里某个属性的值时触发视图更新。
js
function reactive(target) {
return createReactiveObject(target)
}
function updateView(fn) {
console.log('视图更新');
}
let data = {
name: 'awei',
age: 18,
like: ['吃饭', '睡觉']
}
let newData = reactive(data)
newData.name = 'jack'
但是现在我们使用Proxy数据代理的方式来实现响应式,不用原来的data
对象来触发视图更新,而是用代理后的对象newData
来触发。reactive是用来返回响应式对象的一个函数。
js
function createReactiveObject(target) {
if (!isObject(target)) {
return target
}
let baseHandler = {
get(target, key, receiver) {
console.log('读取值');
let result = Reflect.get(target, key, receiver)
return isObject(result) ? reactive(result) : result
},
set(target, key, value, receiver) {
console.log('修改值');
let res = Reflect.set(target, key, value, receiver)
updateView()
return res
},
// ... 一共有13个拦截器
}
observe
是代理后得到的对象,target
是被代理的对象,baseHandler
是代理之后的行为一共有13 种。get
函数接收的三个参数的含义分别为:被代理的对象、被代理对象的key,代理后得到的对象。
我们来看个例子修改对象的行为此时有没有被代理
js
let newData = reactive(data)
newData.name = 'jack'
触发了视图更新所以修改值这个行为被代理了。
我们再来看读取深层次的值会不会被代理
js
let data = {
name: '牢大',
age: {
n: 18
},
like: ['吃饭', '睡觉']
}
let newData = reactive(data)
newData.age.n = 20
可以看到没有触发视图更新,因为我们没有做递归操作。但是Vue3与Vue2不同的是,Vue3在读取值时会返回读取后的值。所以这里也打印了"读取值"。我们把递归操作放在了get操作中,如果返回的是一个对象会再次执行
reactive
函数。
所以Proxy是在读取值的过程中来判断是否为一个对象,这样就做到了按需递归,而不是默认都递归。如果对象里的某个值没有被读取就不会被代理。
我们再来看往数组里添加一个值会不会被代理
js
let newData = reactive(data)
newData.like.push('coding')
可以看到触发了视图更新,所以这个行为是可以被Proxy代理的。
我们再来看往对象里添加值这个行为会不会被代理
js
let newData = reactive(data)
newData.sex = 'boy
可以看到视图更新了,并且走的是set。
现在我们来总结下Proxy的特性
-
对象级代理:创建一个对象的代理(Proxy),拦截整个对象的所有操作。
-
动态拦截 :无需预先定义属性,可以自动拦截新增属性和所有访问方式(包括
delete
、in
等)。 -
非侵入式:通过代理对象间接操作原始对象,不修改原对象。
-
全面拦截:支持 13 种拦截操作,包括:
- 属性读取(
get
)、设置(set
)、删除(deleteProperty
) - 检测存在性(
has
) - 遍历(
ownKeys
) - 函数调用(
apply
,用于拦截函数对象) - 构造函数调用(
construct
) - 更多(如
getPrototypeOf
、setPrototypeOf
等)
- 属性读取(
-
天然支持数组:可以直接拦截数组的索引赋值、长度修改以及原生方法调用。
-
更简洁的实现:无需重写数组方法。
-
按需拦截:仅在访问代理对象时触发拦截,初始化更快。
-
内存优化:代理对象本身是一个轻量包装器。
三、Proxy对比defineProperty
总结对比表
特性 | Object.defineProperty | Proxy |
---|---|---|
拦截粒度 | 属性级 | 对象级 |
新增属性拦截 | 不支持(需 Vue.set) | 支持 |
数组响应式 | 需重写方法 | 原生支持 |
拦截操作类型 | get /set |
13 种(如 has 、delete 等) |
兼容性 | IE9+ | 现代浏览器(IE 不支持) |
性能初始化 | 较差(需遍历属性) | 较好 |