在最新发布的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
用来描述属性是否可配置(改变和删除),主要有两个作用:
- 属性第一次设置后是否可以被修改
- 属性是否可以被删除
在非严格模式下,属性配置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, })
delete user.name
|
configurable:false
配置后也不能重新修改:
谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里
| var user = {}
Object.defineProperty(user, 'name', { value: 'xyf', configurable: false, writable: true, enumerable: true, })
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时属性只能读取:
| 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, });
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 } });
console.log(user.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
;两种形式的属性描述符不能混合使用,否则会报错,下面是一个错误的示范:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| var user = {};
var initName = ''
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
一起搭配使用,下面表格表示了两种描述符可以同时拥有的健值:
缺陷
通过上面的代码我们可以发现,虽然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; } }); });
list[2] = 6
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;
list.push(2);
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;
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描述符有些类似,也是一个对象,用来定制代理规则。
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; } } );
proxyObj.count = 1;
console.log(proxyObj.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); }, })
proxyObj[1] = 3
proxyObj.push(4)
proxyObj.length = 5
|
不管是数组下标或者数组长度的变化,还是通过函数调用,Proxy都能很好的监听到变化;而且除了我们常用的get、set,Proxy更是支持13种拦截操作。
可以看到Proxy相较于Object.defineProperty在语法和功能上都有着明显的优势;而且Object.defineProperty存在的缺陷,Proxy也都很好地解决了。