在最新发布的Vue3.0中,尤大大果断放弃了Object.defineProperty,加入了Proxy来实现数据劫持,那么这两个函数有什么区别呢?本文深入的剖析一下两者的用法以及优缺点,相信看文本文你也会理解为什么Vue会选择Proxy。
本文首发于公众号【前端壹读】,更多精彩内容敬请关注公众号最新消息。
初识defineProperty
首先来看一下MDN对Object.defineProperty()的一个定义:
Object.defineProperty()方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
它的语法是传入三个参数:
Object.defineProperty(obj, prop, descriptor)
三个参数的作用分别是:
- obj:要定义属性的对象。
- prop:要定义或修改的属性的名称或 Symbol 。
- descriptor:要定义或修改的属性描述符。
我们先来看下这个函数的简单用法;既然它能够在对象上定义新的属性,那我们通过它来给对象添加新的属性:
var user = {}
Object.defineProperty(user, 'name', {
value: 'xyf'
})
console.log(user)
这里描述符中的value值即是需要在对象上定义或者修改的属性值(如果对象上本身有该属性,则会进行修改操作);除了字符串,还可以是JS的其他数据类型(数值,函数等)。
属性描述符是个对象,那么就有很多操作的地方了,它除了value这个属性,还有以下:
| 属性名 | 作用 | 默认值 |
|---|---|---|
| configurable | 只有该属性的configurable为true,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。 | false |
| enumerable | 只有该属性的enumerable为true,该属性才会出现在对象的枚举属性中。 | false |
| writable | 只有该属性的enumerable为true,才能被赋值运算符改变。 | false |
| value | 该属性对应的值 | undefined |
| get | 属性的getter函数,当访问该属性时,会调用此函数。 | undefined |
| set | 当属性值被修改时,会调用此函数。该方法接受一个参数,会传入赋值时的 this 对象。 | undefined |
configurable
我们一一来看每个属性的用法;首先configurable用来描述属性是否可配置(改变和删除),主要有两个作用:
- 属性第一次设置后是否可以被修改
- 属性是否可以被删除
在非严格模式下,属性配置configurable:false后进行删除操作会发现属性仍然存在。
var user = {}
Object.defineProperty(user, 'name', {
value: 'xyf',
configurable: false,
writable: true,
enumerable: true,
})
delete user.name
而在严格模式下会抛出错误:
"use strict";
var user = {}
Object.defineProperty(user, 'name', {
value: 'xyf',
configurable: false,
writable: true,
enumerable: true,
})
//TypeError: Cannot delete property 'name' of #<Object>
delete user.name
configurable:false配置后也不能重新修改:
var user = {}
Object.defineProperty(user, 'name', {
value: 'xyf',
configurable: false,
writable: true,
enumerable: true,
})
//TypeError: Cannot redefine property: name
Object.defineProperty(user, 'name', {
value: 'new',
})
enumerable
enumerable用来描述属性是否能出现在for in或者Object.keys()的遍历中:
var user = {
name: "xyf",
age: 0,
};
Object.defineProperty(user, "gender", {
value: "m",
enumerable: true,
configurable: false,
writable: false,
});
Object.defineProperty(user, "birth", {
value: "2020",
enumerable: false,
configurable: false,
writable: false,
});
for (let key in user) {
console.log(key, "key");
}
console.log(Object.keys(user));
很明显,enumerable为true的gender就会被遍历到,而birth则不会。
writable
writable用来描述属性的值是否可以被重写,值为false时属性只能读取:
var user = {};
Object.defineProperty(user, "name", {
value: "xyf",
writable: false,
enumerable: false,
configurable: false,
});
user.name = "new";
console.log(user);
在非严格模式下给name属性再次赋值会静默失败,不会抛出错误;而在严格模式下会抛出异常:
"use strict";
var user = {};
Object.defineProperty(user, "name", {
value: "xyf",
writable: false,
enumerable: false,
configurable: false,
});
//TypeError: Cannot assign to read only property 'name' of object '#<Object>'
user.name = "new";
get/set
当需要设置或者获取对象的属性时,可以通过getter/setter方法:
var user = {};
var initName = ''
Object.defineProperty(user, "name", {
get: function(){
console.log('get name')
return initName
},
set: function(val){
console.log('set name')
initName = val
}
});
// get name
console.log(user.name)
// set name
user.name = 'new'
当获取name时和赋值name时,都会分别调用一次get和set函数;看到这里,很多同学可能会有疑问,为什么这里要用一个initName,而不是在get和set函数中直接return user.name和user.name = val呢?
如果我们直接在get函数中return user.name的话,这里的user.name同时也会调用一次get函数,这样的话会陷入一个死循环;set函数也是同样的道理,因此我们通过一个第三方的变量initName来防止死循环。
但是如果我们需要代理更多的属性,不可能给每一个属性定义一个第三方的变量,可以通过闭包来解决
注:get和set函数不是必须成对出现,可以只出现一个;两个函数如果不设置,则默认值为undefined。
小结
在上面表格中可以看到,上述的三种描述符configurable、enumerable和writable的默认值都是false,因此我们一旦使用Object.defineProperty给对象添加属性,如果不设置属性的特性,那么这些值都是false:
var user = {};
Object.defineProperty(user, "name", {
value: "xyf",
});
// 等价于
Object.defineProperty(user, "name", {
value: "xyf",
configurable: false,
enumerable: false,
writable: false,
});
而我们通过点运算符给属性赋值时,则默认给三种描述符都赋值true:
var user = {};
user.name = "xyf"
// 等价于
Object.defineProperty(user, "name", {
value: "xyf",
configurable: true,
enumerable: true,
writable: true,
});
属性描述符分类
属性描述符主要有两种形式:数据描述符和存取描述符;数据描述符特有的两个属性:value和writable;存取描述符特有的两个属性:get和set;两种形式的属性描述符不能混合使用,否则会报错,下面是一个错误的示范:
var user = {};
var initName = ''
//TypeError: Invalid property descriptor.
//Cannot both specify accessors and a value or writable attribute, #<Object>
Object.defineProperty(user, "name", {
value: 'new',
writable: true,
get: function(){
console.log('get name')
return initName
},
set: function(val){
console.log('set name')
initName = val
}
});
我们简单想一下就能理解为什么两种描述不能混合使用;value用来定义属性的值,而get和set同样也是定义和修改属性的值,两种描述符在功能上有明显的相似性。
虽然数据描述符和存取描述符不能混着用,但是他们均能分别和configrable、enumerable一起搭配使用,下面表格表示了两种描述符可以同时拥有的健值:
| configurable | enumerable | value | writable | get | set | |
|---|---|---|---|---|---|---|
| 数据描述符 | Yes | Yes | Yes | Yes | No | No |
| 存取描述符 | Yes | Yes | No | No | Yes | Yes |
缺陷
通过上面的代码我们可以发现,虽然Object.defineProperty能够劫持对象的属性,但是需要对对象的每一个属性进行遍历劫持;如果对象上有新增的属性,则需要对新增的属性再次进行劫持;如果属性是对象,还需要深度遍历。这也是为什么Vue给对象新增属性需要通过$set的原因,其原理也是通过Object.defineProperty对新增的属性再次进行劫持。
Object.defineProperty除了能够劫持对象的属性,还可以劫持数组;虽然数组没有属性,但是我们可以把数组的索引看成是属性:
var list = [1,2,3]
list.map((elem, index) => {
Object.defineProperty(list, index, {
get: function () {
console.log("get index:" + index);
return elem;
},
set: function (val) {
console.log("set index:" + index);
elem = val;
}
});
});
// set index:2
list[2] = 6
// get index:1
console.log(list[1])
虽然我们监听到了数组中元素的变化,但是和监听对象属性面临着同样的问题,就是新增的元素并不会触发监听事件:
var list = [1, 2, 3];
list.map((elem, index) => {
Object.defineProperty(list, index, {
get: function () {
console.log("get index:" + index);
return elem;
},
set: function (val) {
console.log("set index:" + index);
elem = val;
}
});
});
// 没有输出
list.push(4)
list[3] = 5
为此,Vue的解决方案是劫持Array.property原型链上的7个函数,我们通过下面的函数简单进行劫持:
const arratMethods = [
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
];
const arrayProto = Object.create(Array.prototype);
arratMethods.forEach((method) => {
const origin = Array.prototype[method];
arrayProto[method] = function () {
console.log("run method", method);
return origin.apply(this, arguments);
};
});
const list = [];
list.__proto__ = arrayProto;
//run method push
list.push(2);
//run method shift
list.shift(3);
我们在一文读懂JS中类、原型和继承中讲过:
实例对象能够获取原型对象上的属性和方法
我们在数组上进行操作的push、shift等函数都是调用的原型对象上的函数,因此我们将改写后的原型对象重新给绑定到实例对象上的__proto__,这样就能进行劫持。
除此之外,直接修改数组的length属性也会导致Object.defineProperty的监听失败:
var list = [];
list.length = 10;
list.map((elem, index) => {
Object.defineProperty(list, index, {
get: function () {
console.log("get index:" + index);
return elem;
},
set: function (val) {
console.log("set index:" + index);
elem = val;
},
});
});
list[5] = 4;
// undefined
console.log(list[6]);
通过给length修改为10,数组中有10个undefied,虽然我们给每个元素都劫持了,但是没有触发get/set函数。
我们总结一下Object.defineProperty在劫持对象和数组时的缺陷:
- 无法检测到对象属性的添加或删除
- 无法检测数组元素的变化,需要进行数组方法的重写
- 无法检测数组的长度的修改
Proxy
相较于Object.defineProperty劫持某个属性,Proxy则更彻底,不在局限某个属性,而是直接对整个对象进行代理,我们看一下ES6文档对Proxy的描述:
Proxy可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。
首先还是来看一下Proxy的语法:
var proxy = new Proxy(target, handler);
Proxy本身是一个构造函数,通过new Proxy生成拦截的实例对象,让外界进行访问;构造函数中的target就是我们需要代理的目标对象,可以是对象或者数组;handler和Object.defineProperty中的descriptor描述符有些类似,也是一个对象,用来定制代理规则。
var target = {}
var proxyObj = new Proxy(
target,
{
get: function (target, propKey, receiver) {
console.log(`getting ${propKey}!`);
return Reflect.get(target, propKey, receiver);
},
set: function (target, propKey, value, receiver) {
console.log(`setting ${propKey}!`);
return Reflect.set(target, propKey, value, receiver);
},
deleteProperty: function (target, propKey) {
console.log(`delete ${propKey}!`);
delete target[propKey];
return true;
}
}
);
//setting count!
proxyObj.count = 1;
//getting count!
//1
console.log(proxyObj.count)
//delete count!
delete proxyObj.count
可以看到Proxy直接代理了target整个对象,并且返回了一个新的对象,通过监听代理对象上属性的变化来获取目标对象属性的变化;而且我们发现Proxy不仅能够监听到属性的增加,还能监听属性的删除,比Object.defineProperty的功能更为强大。
除了对象,我们来看一下Proxy面对数组时的表现如何:
var list = [1,2]
var proxyObj = new Proxy(list, {
get: function (target, propKey, receiver) {
console.log(`getting ${propKey}!`);
return Reflect.get(target, propKey, receiver);
},
set: function (target, propKey, value, receiver) {
console.log(`setting ${propKey}:${value}!`);
return Reflect.set(target, propKey, value, receiver);
},
})
//setting 1:3!
proxyObj[1] = 3
//getting push!
//getting length!
//setting 2:4!
//setting length:3!
proxyObj.push(4)
//setting length:5!
proxyObj.length = 5
不管是数组下标或者数组长度的变化,还是通过函数调用,Proxy都能很好的监听到变化;而且除了我们常用的get、set,Proxy更是支持13种拦截操作。
可以看到Proxy相较于Object.defineProperty在语法和功能上都有着明显的优势;而且Object.defineProperty存在的缺陷,Proxy也都很好地解决了。
更多前端资料请关注公众号【前端壹读】。