前言
在最近面试中,经常遇到面试官问到 Vue 的响应式是怎么实现的,我们知道 Vue2 与 Vue3 的响应式实现是有差别的,Vue 2 使用Object.defineProperty
来实现响应式,但由于它的一些局限,Vue 3 改用Proxy
实现,Proxy
和Object.defineProperty
虽然都是用于创建和管理对象属性的方法,但还是有所差别,这篇文章咱们就来好好分析一下🧐。
Object.defineProperty
Object.defineProperty()
是 ES5 中监听对象属性的方法,它会直接在一个对象上定义一个新属性,或修改其现有属性,并返回此对象。详情参考(Object.defineProperty() - JavaScript | MDN (mozilla.org))
语法
js
Object.defineProperty(obj, prop, descriptor)
其中obj
是要定义属性的对象,prop
为一个String
或 Symbol
,descriptor
为要定义或修改的属性的描述符,返回值为这个对象,并且它所指定的属性已被添加或修改。
对象中存在的属性描述符有两种主要类型:数据描述符和访问器描述符。
⚠️注意:描述符只能是这两种类型之一,不能同时为两者。
数据描述符
是一个具有可写或不可写值的属性。
value | writable | enumerable | configurable | |
---|---|---|---|---|
类型 | 任意 | Boolean | Boolean | Boolean |
默认值 | undefined | false | false | false |
描述 | 设置属性的值 | 属性是否可写 | 属性是否可以枚举 | 属性是否可以删除 |
举个栗子🌰
js
//默认值
const obj = {};
Object.defineProperty(obj, 'name', {
value: '阳阳羊',
});
obj.name = '喜羊羊';
console.log(obj.name); //阳阳羊
console.log(Object.keys(obj)); //[]
delete obj.name
console.log(obj.name); //阳阳羊
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
//全部改为true
Object.defineProperty(obj, 'name', {
value: '阳阳羊',
writable: true, //可写
enumerable : true, //可迭代
configurable : true //可删
});
obj.name = '喜羊羊';
console.log(obj.name); //喜羊羊
console.log(Object.keys(obj)); //[ 'name' ]
delete obj.name
console.log(obj.name); //undefined
根据对比,很容易理解,这里就不过多赘述。
访问器描述符
是由 getter/setter 函数对描述的属性。
get | set | |
---|---|---|
类型 | Function 或 undefined | Function 或 undefined |
默认值 | undefined | undefined |
描述 | 当访问该属性时,会调用此方法,并返回其返回值作为属性的值 | 当属性值被修改时,会调用此方法,传入新的值 |
举个栗子🌰
js
let obj = {};
let name = '阳阳羊'
Object.defineProperty(obj, 'name', {
get() {
console.log('访问了');
return name
},
set(newVal) {
console.log('修改了');
name = newVal
}
});
console.log(obj.name); //访问了 阳阳羊
obj.name = '修改后的阳阳羊'; //修改了
console.log(obj.name); //访问了 修改后的阳阳羊
监听多个属性
通过静态方法Object.keys()
拿到对象的key
数组,遍历每个key
并监听:
js
let obj = {
name : '阳阳羊',
age : 18,
};
Object.keys(obj).forEach(key => {
Object.defineProperty(obj, key, {
get() {
console.log('访问了');
return obj[key];
},
set(newValue) {
console.log('修改了');
obj[key] = newValue;
}
});
});
console.log(obj.name); //爆栈
这里每个属性的 get 会陷入无限循环,因为我们在 get 中 return obj[key]
,访问 obj[key]
也会调用 get 方法,导致递归调用,从而爆栈。
解决这个问题的一种方法是使用一个内部的存储来保存属性值,而不是直接在对象上存储。
js
let obj = {
name : '阳阳羊',
age : 18,
};
let data = {};
Object.keys(obj).forEach(key => {
data[key] = obj[key];
Object.defineProperty(obj, key, {
get() {
console.log('访问了');
return data[key];
},
set(newValue) {
console.log('修改了');
data[key] = newValue;
}
});
});
console.log(obj.name); // 访问了 阳阳羊
obj.name = '喜羊羊'; // 修改了
console.log(obj.name); // 访问了 喜羊羊
或者我们可以使用两个函数来解决
js
let obj = {
name: '阳阳羊',
age: 0
}
// 实现一个响应式函数
function defineProperty(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
console.log('访问了')
return val
},
set(newVal) {
console.log('修改了')
val = newVal
}
})
}
// 实现一个遍历函数Observer
function Observer(obj) {
Object.keys(obj).forEach((key) => {
defineProperty(obj, key, obj[key])
})
}
Observer(obj)
console.log(obj.name); // 访问了 阳阳羊
obj.name = '喜羊羊'; // 修改了
console.log(obj.name); // 访问了 喜羊羊
深度监听对象
可以使用递归来遍历对象的每个属性
js
function deepProperty(obj) {
let data = {};
Object.keys(obj).forEach(key => {
if (typeof obj[key] === 'object' && obj[key] !== null) {
data[key] = deepProperty(obj[key]); // 属性是对象时进行递归
} else {
data[key] = obj[key];
}
Object.defineProperty(obj, key, {
get() {
console.log('访问了');
return data[key];
},
set(newValue) {
console.log('修改了');
if (typeof newValue === 'object' && newValue !== null) {
data[key] = deepProperty(newValue); // 对新值进行递归拦截
} else {
data[key] = newValue;
}
}
});
});
return obj;
}
let obj = {
name: '阳阳羊',
age: 18,
address: {
city: '南昌',
street: '广兰大道'
}
};
let propertyObj = deepProperty(obj);
console.log(propertyObj.name); // 访问了 阳阳羊
console.log(propertyObj.address.city); // 访问了 访问了 南昌
propertyObj.name = '喜羊羊'; // 修改了
propertyObj.address.city = '北京'; // 访问了 修改了
console.log(propertyObj.name); // 访问了 喜羊羊
console.log(propertyObj.address.city); // 访问了 访问了 北京
这里递归监听,所以会打印两个访问了
监听数组属性
实现监听数组属性的过程与监听对象属性的过程类似,但需要考虑到数组的特殊性,比如数组的长度属性和一些数组 API
js
let arr = [1, 2, 3]
let obj = {}
Object.defineProperty(obj, 'arr', {
get() {
console.log('访问了')
return arr
},
set(newVal) {
console.log('修改了')
arr = newVal
}
})
console.log(obj.arr) //访问了 [ 1, 2, 3 ]
obj.arr = [1, 2, 3, 4] //修改了
obj.arr.push(5) //访问了
obj.arr.pop() //访问了
这里我们发现数组的push()
,pop()
方法添加或删除元素并不能被 set 监听到,这就是defineProperty
其中一大缺陷,针对这个缺陷vue2的做法是:对数组的一系列方法进行了重写,使其能够被监听,具体重写方法详见 vue2官网文档
小结
Object.defineProperty
只能监听对象的属性- 监听多个属性时,需要遍历每个属性
- 需要递归深度监听每个对象的属性
- 无法监听数组的方法
Proxy
Proxy
对象是在 ES6 中引入的一种新机制,它用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。与 Object.defineProperty
相比,Proxy
提供了更加灵活强大的功能,是处理拦截需求的首选。
语法
js
const p = new Proxy(target, handler)
其中target
是要使用 Proxy
包装的目标对象;第二个参数handler
通常是一个配置对象,对于每一个被代理的操作,需要提供一个对应的处理函数,该函数将拦截对应的操作。
举个例子🌰
js
let target = {
name: '阳阳羊',
age:'18',
}
let handler = {
get(obj, key) {
console.log('访问了')
return key in obj ? obj[key] : null
},
set(obj, key, val) {
console.log('修改了')
obj[key] = val
return true
}
}
const p = new Proxy(target, handler)
console.log(p.name) //访问了 阳阳羊
p.name = '喜羊羊' // 修改了
这里明显可见,使用 proxy 拦截的是整个对象,而不是对象的属性,使用proxy
监听这个对象就相当于监听了每个属性,所以我们不用像Object.defineProperty
一样遍历对象属性。
- get 接收三个参数
target
:目标对象,property
:被获取的属性名,receiver
(可选):Proxy 或者继承 Proxy 的对象。 - set 接收四个参数
target
:目标对象,property
:将被设置的属性名或Symbol
,value
:新属性值,receiver
:最初接收赋值的对象。
hander
对象不仅支持 get 和 set 方法,还有其他共13种方法,摘自 阮一峰老师的ES6入门
- get(target, propKey, receiver) :拦截对象属性的读取,比如
proxy.foo
和proxy['foo']
。 - set(target, propKey, value, receiver) :拦截对象属性的设置,比如
proxy.foo = v
或proxy['foo'] = v
,返回一个布尔值。 - has(target, propKey) :拦截
propKey in proxy
的操作,返回一个布尔值。 - deleteProperty(target, propKey) :拦截
delete proxy[propKey]
的操作,返回一个布尔值。 - ownKeys(target) :拦截
Object.getOwnPropertyNames(proxy)
、Object.getOwnPropertySymbols(proxy)
、Object.keys(proxy)
、for...in
循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而Object.keys()
的返回结果仅包括目标对象自身的可遍历属性。 - getOwnPropertyDescriptor(target, propKey) :拦截
Object.getOwnPropertyDescriptor(proxy, propKey)
,返回属性的描述对象。 - defineProperty(target, propKey, propDesc) :拦截
Object.defineProperty(proxy, propKey, propDesc)
、Object.defineProperties(proxy, propDescs)
,返回一个布尔值。 - preventExtensions(target) :拦截
Object.preventExtensions(proxy)
,返回一个布尔值。 - getPrototypeOf(target) :拦截
Object.getPrototypeOf(proxy)
,返回一个对象。 - isExtensible(target) :拦截
Object.isExtensible(proxy)
,返回一个布尔值。 - setPrototypeOf(target, proto) :拦截
Object.setPrototypeOf(proxy, proto)
,返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。 - apply(target, object, args) :拦截 Proxy 实例作为函数调用的操作,比如
proxy(...args)
、proxy.call(object, ...args)
、proxy.apply(...)
。 - construct(target, args) :拦截 Proxy 实例作为构造函数调用的操作,比如
new proxy(...args)
。
Proxy 深度监听对象
js
let target = {
name: '阳阳羊',
age:'18',
address:{
city:'南昌',
}
}
let handler = {
get(obj, key) {
console.log('访问了')
return key in obj ? obj[key] : null
},
set(obj, key, val) {
console.log('修改了')
obj[key] = val
return true
}
}
const p = new Proxy(target, handler)
console.log(p.address.city) //访问了 南昌
p.address.city = '北京' // 访问了
console.log(p.address.city) // 访问了 北京
这里我们修改对象中的对象属性,不会触发 set ,要想实现深度监听,也需要递归监听每个对象。
js
let target = {
name: '阳阳羊',
age:'18',
address:{
city:'南昌',
}
}
let handler = {
get(obj, key) {
console.log('访问了')
return key in obj ? obj[key] : null
},
set(obj, key, val) {
console.log('修改了')
obj[key] = val
return true
}
}
function Observer(target,handler){
// 递归代理对象的对象属性
for (const key of Object.keys(target)) {
if (target[key] && typeof target[key] === 'object') {
target[key] = Observer(target[key], handler);
}
}
return new Proxy(target, handler);
}
const p = Observer(target, handler);
console.log(p.address.city) //访问了 访问了 南昌
p.address.city = '北京' // 访问了 修改了
console.log(p.address.city) // 访问了 访问了 北京
这样,我们通过Observer
函数,遍历每个对象属性,并递归代理。
当我们访问内部对象属性时,会打印触发两次 get ,当修改时,触发一次 set ,完美!
Proxy 监听数组
js
let target = [1, 2, 3]
let handler = {
get(obj, key) {
console.log('访问了')
return key in obj ? obj[key] : null
},
set(obj, key, val) {
console.log('修改了')
obj[key] = val
return true
}
}
const p = new Proxy(target, handler)
console.log(p) // [ 1, 2, 3 ]
console.log(p[0]) // 访问了 1
console.log(p[3]) // 访问了 null
p[0] = 11 // 修改了
p.push(4) // 访问了 访问了 修改了 修改了
console.log(p) // [ 11, 2, 3, 4 ]
这里Proxy
成功监听到数组的push
方法,但是为什么触发了两次 get 和 set?
- 访问
push
方法 :Proxy
的get
方法被调用来获取push
方法。这是第一次触发。 - 执行
push
方法 :push
方法首先会读取数组的长度来确定从哪个位置开始插入新元素。这再次触发get
。 - 设置新元素 :将新元素插入到数组中。第一次
set
方法被触发,记录这个改变。 - 更新数组长度 :
push
方法完成后,数组的length
属性更新。set
方法被捕捉到,第二次的触发。
其他数组 API 拦截行为可以有不同的表现,这取决于每个方法如何访问和修改数组的内部状态。有兴趣的同学可以自行了解下。
兼容性
总结
Proxy
监听的是整个对象而不是单个属性,无需遍历每个属性Proxy
也需要递归监听对象的对象属性Proxy
能够监听数组的一系列方法Proxy
拥有共 13 种拦截方法,功能也更加齐全
参考
- Object.defineProperty() - JavaScript | MDN (mozilla.org)
- Proxy - JavaScript | MDN (mozilla.org)
- Proxy - ECMAScript 6入门 (ruanyifeng.com)
- 一文彻底弄懂defineProperty和Proxy - 掘金 (juejin.cn)
最后
码字不易,感谢点赞转发 ~
已将学习代码上传至 github,欢迎大家学习指正!
技术小白记录学习过程,有错误或不解的地方还请评论区留言,如果这篇文章对你有所帮助请 "点赞 收藏+关注" ,感谢支持!!