深入学习Object.defineProperty和Proxy

  在最新发布的Vue3.0中,尤大大果断放弃了Object.defineProperty,加入了Proxy来实现数据劫持,那么这两个函数有什么区别呢?本文深入的剖析一下两者的用法以及优缺点,相信看文本文你也会理解为什么Vue会选择Proxy。

初识defineProperty

  首先来看一下MDN对Object.defineProperty()的一个定义:

Object.defineProperty()方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

  它的语法是传入三个参数:

Object.defineProperty(obj, prop, descriptor)

  三个参数的作用分别是:

  我们先来看下这个函数的简单用法;既然它能够在对象上定义新的属性,那我们通过它来给对象添加新的属性:

1
2
3
4
5
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后进行删除操作会发现属性仍然存在。

1
2
3
4
5
6
7
8
9
10
var user = {}

Object.defineProperty(user, 'name', {
value: 'xyf',
configurable: false,
writable: true,
enumerable: true,
})

delete user.name

  而在严格模式下会抛出错误:

1
2
3
4
5
6
7
8
9
10
11
12
"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配置后也不能重新修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
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()的遍历中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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时属性只能读取:

1
2
3
4
5
6
7
8
9
10
11
var user = {};

Object.defineProperty(user, "name", {
value: "xyf",
writable: false,
enumerable: false,
configurable: false,
});

user.name = "new";
console.log(user);

  在非严格模式下给name属性再次赋值会静默失败,不会抛出错误;而在严格模式下会抛出异常:

1
2
3
4
5
6
7
8
9
10
11
12
"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方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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:

1
2
3
4
5
6
7
8
9
10
11
12
var user = {};

Object.defineProperty(user, "name", {
value: "xyf",
});
// 等价于
Object.defineProperty(user, "name", {
value: "xyf",
configurable: false,
enumerable: false,
writable: false,
});

  而我们通过点运算符给属性赋值时,则默认给三种描述符都赋值true:

1
2
3
4
5
6
7
8
9
10
11
12
var user = {};

user.name = "xyf"

// 等价于

Object.defineProperty(user, "name", {
value: "xyf",
configurable: true,
enumerable: true,
writable: true,
});

属性描述符分类

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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除了能够劫持对象的属性,还可以劫持数组;虽然数组没有属性,但是我们可以把数组的索引看成是属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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])

  虽然我们监听到了数组中元素的变化,但是和监听对象属性面临着同样的问题,就是新增的元素并不会触发监听事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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个函数,我们通过下面的函数简单进行劫持:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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的监听失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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. 无法检测数组元素的变化,需要进行数组方法的重写
  4. 无法检测数组的长度的修改

Proxy

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

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

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

1
var proxy = new Proxy(target, handler);

  Proxy本身是一个构造函数,通过new Proxy生成拦截的实例对象,让外界进行访问;构造函数中的target就是我们需要代理的目标对象,可以是对象或者数组;handlerObject.defineProperty中的descriptor描述符有些类似,也是一个对象,用来定制代理规则。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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面对数组时的表现如何:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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也都很好地解决了。


谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

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