Proxy vs DefineProperty
几年前校招面试的时候被问过一个问题,Vue3/Vue2 如何实现数据和UI的同步,其区别是什么,Vue3的方式优势是什么?
当时背了八股,默写了一通不知所云的代码,面试没过,再也没写过Vue。
今天拿出点时间来写一下这个问题的前因后果,也算是做个笔记吧。
关键点
当我们修改某个属性值后,其"绑定"的UI不会自动更新,通常需要我们手动的再次设置UI的内容
domInstance.textContent = 'newValue'
这就导致,我们每次修改值的时候 都需要手动更新UI,Vue2/3就是简化这个过程。
这个问题的关键点是,如何能够监听到对象属性值的修改,并且在修改的过程中插入一些我们需要的逻辑(如更新UI等)
换句话说,需要将修改属性的过程 变成一个函数
const obj = { a: 100 }
obj.a = 200 // 需要在修改a为200时 监测到这个变化
ES6以前,通常使用DefineProperty的方式来设置get set描述符来实现,这也就是Vue2的方式
DefineProperty
我们通常使用以下的方式设置描述符
defineProperty(设置的对象,设置的属性值,描述符对象)
其中,属性描述符中包含 get set方法,在我们访问某个属性或者给某个属性赋值的时候,会自动调用设置的get set方法,从而实现监听修改和读取,看下面例子
// 被监听的对象
const obj = {
_a: 100,
_b: 200,
_c: {
c1: 100,
c2: 200
},
}
// 监听obj对象的属性a的改变
Object.defineProperty(obj, 'a', {
get() {
console.log('obj的a属性被访问')
return obj._a
},
set(newValue) {
console.log('obj的a属性被修改 新的值为' + newValue)
obj._a = newValue
}
})
使用Object.defineProperty给obj的属性a设置get set监听,当访问属性a 或者读取属性a的时候,就会调用get set方法,相当于"覆盖"了 读取和修改属性a的逻辑
既然对a的修改变成了一个函数,我们就可以在其中加入修改UI的逻辑,实现修改a的过程中, 自动更新其绑定的DOM元素, 如下所示
Object.defineProperty(obj, 'a', {
set(newValue) {
console.log('obj的a属性被修改 新的值为' + newValue)
domInstance.textContext = newValue // 修改UI内容
obj._a = newValue
}
})
现在,你就实现了Vue2 数据UI同步更新的原理。但是我们还需要添加一些细节
你可能会发现,defineProperty的数据监听,是相对于属性的,这也是其和Proxy 监听的最大区别,如果你想对多个属性进行监听,实现多组数据和UI的同步绑定,你就需要为每一个属性都设置get set 如下:
const obj = {
_a: 100,
_b: 200,
_c: {
c1: 100,
c2: 200
},
}
Object.defineProperty(obj, 'a', {
get() {
console.log('obj的a属性被访问')
return obj._a
},
set(newValue) {
console.log('obj的a属性被修改 新的值为' + newValue)
obj._a = newValue
}
})
Object.defineProperty(obj, 'b', {
get() {
console.log('obj的b属性被访问')
return obj._b
},
set(newValue) {
console.log('obj的b属性被修改 新的值为' + newValue)
obj._b = newValue
}
})
Object.defineProperty(obj, 'c', {
get() {
console.log('obj的c属性被访问')
return obj._c
},
set(newValue) {
console.log('obj的c属性被修改 新的值为' + newValue)
obj._c = newValue
}
})
这样写会带来一些问题,比如代码量的增多,大量的definePropery逻辑导致代码不够优雅,同时,对于属性值为引用的情况,需要递归的给每个属性创建get set ,Vue中实现了一个observe函数,专门用来递归的为对象添加get set函数 以实现对每个属性的拦截,大致实现如下
function observe(originObject) {
Object.keys(originObject).forEach(originKey => {
// 逐个属性添加代理
Object.defineProperty(originObject, originKey, {
get() {
const currentValue = Reflect.get(originObject, '_' + originKey)
if (typeof currentValue === 'object' && currentValue !== null) {
observe(currentValue)
}
return currentValue
},
set(newValue) {
// 修改UI
domInstance.textContext = newValue
Reflect.set(originObject, '_' + originKey, newValue)
}
})
})
return originObject
}
这样为每个属性,都添加getset的方式 无疑带来了更多的空间浪费,除此以外更严重的问题是
1. 当observe函数执行之后,你无法再为新增的属性设置监听,这也是为什么Vue2中,对于数据监听的设置需要放在created钩子之前。
2. 属性描述符只提供了 get set的方式,对于其他的属性操作,比如删除属性等 无法做到监听,这也是为什么Vue中无法监听到属性的删除
Proxy的优势
ES6中引入Proxy,能很好的解决上面的问题,这也是Vue3实现数据UI同步的方式
Proxy是个构造函数,其使用方式是
const proxyObject = new Proxy(被监听的对象,handler)
其中 handler是一个对象,其内部可以传入对多种属性操作的监听函数,如下:
const proxyObj = new Proxy(obj, {
// 读取属性
get: (target, propName) => {
console.log("obj对象的" + propName + "被读取");
return target[propName];
},
// 设置属性
set: (target, propName, newValue) => {
console.log("obj对象的" + propName + "被修改 新的值为:" + newValue);
target[propName] = newValue;
},
// 删除属性
deleteProperty(target, propName) {
console.log("obj对象的" + propName + "属性 被删除!");
Reflect.deleteProperty(target, propName);
},
// .. 其他属性监听
});
我们可以在handler中 实现 get set deletProperty ... 等等的监听函数 来实现代理
Proxy handler实现的方法我们以get set方法举例,
get方法接受参数为:
target: 要访问的对象,这里也就是obj
propName: 要访问的属性名称
receiver?: 实际调用者 后面会说
set方法接受参数为
target: 要修改的对象,这里也就是obj
propName: 要修改的属性名称
newValue: 要修改的新值
receiver?: 实际调用者 后面会说
通过这种方式,我们就可以在修改访问属性的过程中,完成监听,修改DOM,如下:
new Proxy(obj, {
get: (target, propName,receiver) => {
console.log("obj对象的" + propName + "被读取");
return target[propName];
},
set: (target, propName, newValue,receiver) => {
console.log("obj对象的" + propName + "被修改 新的值为:" + newValue);
target[propName] = newValue;
},
});
可以发现,Proxy的监听是以对象为单位的,也就是说,在给对象设置Proxy监听之后,对对象任意属性的操作都可以被监听到,不论属性是否新增,删除,都不受影响。
在Vue3中,同样实现了observe函数,大致实现如下:
function observe(originObject) {
return new Proxy(originObject, {
get(target, propName, receiver) {
const currentValue = Reflect.get(target, propName, receiver)
if (typeof currentValue === 'object' && currentValue !== null) {
// 访问到get的时候 创建新的proxy 后续再访问别的属性 就能检测到了 (lazy) 区别于DefineProperty
return observe(currentValue)
}
return currentValue
},
set(target, propName, newValue, receiver) {
// 修改UI
domInstance.textContext = newValue
return Reflect.set(target, propName, newValue, receiver)
},
})
}
你也许会说,啊 你用Proxy不也得递归处理引用属性吗? 是的,但是其好处在于,
首先,每个引用属性我只需要创建一个Proxy,不需要为每个属性创建get set
其次,创建Proxy的这个过程是在访问(调用get)时 创建的,类似于懒加载的形式, 可以减少开销
比如:
const obj = {
a: {
innerA: 100
}
}
const proxyObj = observe(obj)
当访问obj.a.innerA时,会从左到右依次执行LHS
访问obj.a 调用get 此时由于a是引用,创建a引用的监听,并且返回
此时就变成了 proxyAttributeA.innerA 就会进入刚创建的a的Proxy的get方法,获取innerA的值
这也是为什么Proxy效率更高,同时可以动态的实现对属性的监听。
Reflect & receiver
上面遗留了一个问题 get set的最后一个函数 receiver属性是什么?
ES6中引入了Refelct对象,这个对象可以在宿主环境中直接使用,提供了一些数据拦截的方法,比如:
Reflect.get(target, propertyKey[, receiver]):获取对象的属性值。
Reflect.set(target, propertyKey, value[, receiver]):设置对象的属性值。
Reflect.has(target, propertyKey):判断对象是否有某个属性。
Reflect.deleteProperty(target, propertyKey):删除对象的某个属性。
Reflect.defineProperty(target, propertyKey, attributes):定义对象的新属性。
Reflect.getPrototypeOf(target):获取对象的原型。
Reflect.setPrototypeOf(target, prototype):设置对象的原型。
Reflect.apply(target, thisArgument, argumentsList):调用对象的方法。
Reflect.construct(target, argumentsList[, newTarget]):使用构造函数创建对象。
这些方法中的部分和Object.xxx 方法功能重合,使用Reflect对象的方法可以使对象操作更加规范化和简单,提高代码的可读性和可维护性。
在Proxy中,推荐使用Reflect.get(target,propName) , Reflect.set(tagret,propname,newValue)的方式来修改原始对象的值,但是为什么这么做呢?
正常情况下,用target[propName] 和 Refect.get(target,PropName) 没区别,但是有一些特殊情况会存在差异,比如 原始对象的某个属性是通过 get set 这样的属性描述设置和返回的 就会出现以下问题
const person = {
_name: "bill",
get name() {
return this._name;
},
};
const personProxy = new Proxy(person, {
get: (target, propName) => {
console.log("读取person的" + propName + "属性");
return target[propName];
},
});
console.log(personProxy.name); // bill 这个没问题
const student = {
id: "UWHDIUWOA(J@*HIDJWLOD",
_name: "Jack",
};
// 把student设置为personProxy的继承对象
Object.setPrototypeOf(student, personProxy);
console.log(student.name);
// 这里还是bill 因为在Proxy.get中 使用target[propName] 会丢失this 导致this指向person
// 这里需要使用 Reflect.get(,,receiver)
student的原型__proto__ 指向personProxy,在读取student.name时,会顺着原型链找到personProxy.name 由于name是个get访问器,会返回this._name的值,但是我们期望返回的是student中的_name 也就是期望get中的this应该指向 student 但是由于我们在proxy的get中,使用了target[propName]的方式,把this指向了target 即person。
如何解决这个问题,此时就用到了Reflect
你会说如果把target[propName] 换成Reflect.get(target,propName) 会有效果吗?
对不起没有 我们还需要用到的 是get的最后一个 receiver参数
很多文章把receiver解释成Proxy 其实不全对,receiver指向get的真实调用者
当访问personProxy.name 时,此时的访问者为personProxy对象,此时receiver = 当前Proxy对象
当访问student.name时,此时指向的真正调用者 student对象
拿到了receiver之后,我们需要透传给Reflect.get/set对象,这也是使用Reflect的优势所在,其最后一个参数可以接受一个receiver,如果原始对象的属性为访问器,会将其中的this,赋为receiver,这样在多个对象继承的时候,可以正确的实现多态,如下:
const reflectPersonProxy = new Proxy(person, {
get: (target, propName, receiver) => {
// 这里的reciver 指向真正的属性访问者
// 使用reflectPersonProxy.name时 receiver指向person对象
// 使用reflectPersonProxy的parent对象.name 时 receiver指向 parent对象
console.log("读取person的" + propName + "属性");
// 把receiver透传给Refelct.get 其会修改get访问器中this的指向
return Reflect.get(target, propName, receiver);
},
});
const student2 = {
id: "WHDIUWHDOIAJODJIWWD",
_name: "Jack",
};
Object.setPrototypeOf(student2, reflectPersonProxy);
console.log(student2.name); // 正确输出 Jack
我们可以查看官方对receiver的描述:
同时,只有Reflect.get Reflect.set方法可以接受receiver 这也就对应了 receiver是为了解决 原始对象 get set访问器this指向错误的问题!
所以,在使用Proxy实现代理时,建议使用Reflect对象完成对原始对象的操作,并且一定要传入receiver!
本文所有代码可以在 https://github.com/Gravity2333/learn_proxy 找到