5

深入学习Object.defineProperty和Proxy

 3 years ago
source link: https://xieyufei.com/2020/12/16/DefineProperty-Proxy.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

  在最新发布的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.nameuser.name = val呢?

  如果我们直接在get函数中return user.name的话,这里的user.name同时也会调用一次get函数,这样的话会陷入一个死循环;set函数也是同样的道理,因此我们通过一个第三方的变量initName来防止死循环。

  但是如果我们需要代理更多的属性,不可能给每一个属性定义一个第三方的变量,可以通过闭包来解决

:get和set函数不是必须成对出现,可以只出现一个;两个函数如果不设置,则默认值为undefined。

  在上面表格中可以看到,上述的三种描述符configurableenumerablewritable的默认值都是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,
});

属性描述符分类

  属性描述符主要有两种形式:数据描述符和存取描述符;数据描述符特有的两个属性:valuewritable;存取描述符特有的两个属性:getset;两种形式的属性描述符不能混合使用,否则会报错,下面是一个错误的示范:

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同样也是定义和修改属性的值,两种描述符在功能上有明显的相似性。

  虽然数据描述符和存取描述符不能混着用,但是他们均能分别和configrableenumerable一起搭配使用,下面表格表示了两种描述符可以同时拥有的健值:

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在劫持对象和数组时的缺陷:

  1. 无法检测到对象属性的添加或删除
  2. 无法检测数组元素的变化,需要进行数组方法的重写
  3. 无法检测数组的长度的修改

Proxy

  相较于Object.defineProperty劫持某个属性,Proxy则更彻底,不在局限某个属性,而是直接对整个对象进行代理,我们看一下ES6文档对Proxy的描述:

Proxy可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

  首先还是来看一下Proxy的语法:

var proxy = new Proxy(target, handler);

  Proxy本身是一个构造函数,通过new Proxy生成拦截的实例对象,让外界进行访问;构造函数中的target就是我们需要代理的目标对象,可以是对象或者数组;handlerObject.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也都很好地解决了。

更多前端资料请关注作者公众号``【前端壹读】``。
PS:公众号接入了图灵机器人小壹,欢迎各位老铁来撩。

follow.png

本文地址: http://xieyufei.com/2020/12/16/DefineProperty-Proxy.html

@谢小飞的网站

本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号后回复【转载】。

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK