Proxy和defineProperty
前言
虽然我的主技术栈是 React 的,但是每当面试的时候,面试官几乎都会问你说一下 React 和 Vue 的区别,在说道双向数据绑定的时候,面试官会下意识的问一句,你说一下 Vue 的双向数据绑定的原理,这个时候 Object.defineProperty 就出场了,但是在 Vue3.0 中,Proxy 取代了 Object.defineProperty,成为双向绑定的底层原理,这个时候 Proxy 就显得尤为重要。
本篇文章先以 Object.defineProperty 作为引入,之后讲解 Proxy,最后比较两者之间的优劣。
1、Object.defineProperty 数据劫持
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
该方法接受三个参数,第一个参数是 obj:要定义属性的对象,第二个参数是 prop:要定义或修改的属性的名称或 Symbol,第三个参数是 descriptor:要定义或修改的属性描述符。
1 | const obj = {}; |
虽然我们可以直接添加属性和值,但是使用这种方式,我们能进行更多的配置。
函数的第三个参数 descriptor 所表示的属性描述符有两种形式:数据描述符和存取描述符。数据描述符是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由 getter 函数和 setter 函数所描述的属性。一个描述符只能是这两者其中之一;不能同时是两者。
这两种同时拥有下列两种键值:
- configurable:当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
- enumerable:当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false。
1 | const obj = { property: 24 }; |
1 | const obj = { |
数据描述符还具有以下可选键值:
- value:该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined。
- writable:当且仅当该属性的 writable 键值为 true 时,属性的值,也就是上面的 value,才能被赋值运算符改变。默认为 false。
1 | const obj = {}; |
1 | const obj = {}; |
存取描述符还具有以下可选键值:
- get:属性的 getter 函数,如果没有 getter,则为 undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的 this 并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。默认为 undefined。
- set:属性的 setter 函数,如果没有 setter,则为 undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。默认为 undefined。
1 | const obj = {}; |
2、Proxy 数据拦截
Object.defineProperty 只能重定义获取和设置的行为,而 Proxy 相当于一个升级,它重定义了更多的行为,接下来我们对其进行深入讲解。
首先,Proxy 是一个构造函数,可以通过 new 来创建它的实例,其接受两个参数,一个是 target:要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。另外一个是 handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理实例的行为。
1 | let p = new Proxy(target, handler); |
handler 对象的方法
handler 中的所有方法都是可选的,如果没有定义哪个方法,那就保留原对象的默认行为。
1、get()
用于拦截对象的读取操作。该方法接收三个参数,target:目标对象,property:被获取的属性名,receiver:Proxy 或者继承 Proxy 的对象。
1 | let person = { |
查看第三个属性
1 | let person = { |
上面代码中,p 对象的 name 属性是由 p 对象提供的,所以 receiver 指向 proxy 对象。
2、set()
用于设置属性值操作的捕获器。该方法接收四个参数,target:目标对象,property:将被设置的属性名或 Symbol,value:新属性值,receiver:最初被调用的对象。通常是 proxy 本身,但 handler 的 set 方法也有可能在原型链上,或以其他方式被间接地调用(因此不一定是 proxy 本身)。
1 | let person = {}; |
当给 Proxy 的实例添加属性的时候,就会调用 set()方法。
下面我们来看一下 set()方法的第四个参数,一般情况下都是指向 proxy 本身。
1 | let person = {}; |
但是也有其他情况,我们借用阮一峰:ECMAScript 6 入门-Proxy中谈到的例子:
1 | const handler = { |
上面代码中,设置 myObj.foo 属性的值时,myObj 并没有 foo 属性,因此引擎会到 myObj 的原型链去找 foo 属性。myObj 的原型对象 proxy 是一个 Proxy 实例,设置它的 foo 属性会触发 set 方法。这时,第四个参数 receiver 就指向原始赋值行为所在的对象 myObj。
值得注意一点的是,当该对象不可配置不可编写的时候,那么 set()方法将不起作用。
1 | const obj = {}; |
3、apply()
用于拦截函数的调用。该方法接受三个参数,target:目标对象(函数)。thisArg:被调用时的上下文对象。argumentsList:被调用时的参数数组。
当 Prxoy 的实例当做函数使用时,就会执行该方法。
1 | let p = new Proxy(function () {}, { |
4、has()
是针对 in 操作符的代理方法。该方法接受两个参数,target:目标对象。prop:需要检查是否存在的属性。
1 | let p = new Proxy( |
has()方法只对 in 运算符有效,对 for…in…运算没有实际作用。
1 | let p = new Proxy( |
从上述例子可以看出,has 方法拦截只对 in 运算符有效,对 for…in…来说没有拦截效果。
5、construct
用于拦截 new 操作符. 为了使 new 操作符在生成的 Proxy 对象上生效,用于初始化代理的目标对象自身必须具有[[Construct]]内部方法(即 new target 必须是有效的)。该方法接收三个参数,target:目标对象。argumentsList:constructor 的参数列表。newTarget:最初被调用的构造函数。
1 | let p = new Proxy(function () {}, { |
construct()方法必须返回一个对象,不然不报错。
1 | let p = new Proxy(function () {}, { |
6、deleteProperty
用于拦截对对象属性的 delete 操作。该方法接受两个参数。target:目标对象。property:待删除的属性名。
1 | let p = new Proxy( |
如果这个方法抛出错误或者返回 false,当前属性就无法被 delete 命令删除。注意,目标对象自身的不可配置(configurable)的属性,不能被 deleteProperty 方法删除,否则报错。
7、defineProperty()
用于拦截对对象的 Object.defineProperty() 操作。该方法接受三个参数,target:目标对象。property:待检索其描述的属性名。descriptor:待定义或修改的属性的描述符。
1 | let p = new Proxy( |
如果 defineProperty()方法内部没有任何操作,只返回 false,导致添加新属性总是无效。注意,这里的 false 只是用来提示操作失败,本身并不能阻止添加新属性。
注意,如果目标对象不可扩展(non-extensible),则 defineProperty()不能增加目标对象上不存在的属性,否则会报错。另外,如果目标对象的某个属性不可写(writable)或不可配置(configurable),则 defineProperty()方法不得改变这两个设置。
8、defineProperty()
用法是拦截 Object.getOwnPropertyDescriptor(),必须返回一个 object 或 undefined。该方法接受两个参数,target:目标对象。prop:返回属性名称的描述。
1 | let p = new Proxy( |
9、getPrototypeOf()
是一个代理(Proxy)方法,当读取代理对象的原型时,该方法就会被调用。该方法只接受一个参数,target:被代理的目标对象。返回值必须是一个对象或者 null。
触发该方法的条件总共有 5 种:
- Object.getPrototypeOf()
- Reflect.getPrototypeOf()
- __proto__
- Object.prototype.isPrototypeOf()
- instanceof
1 | let proto = {}; |
10、isExtensible()
用于拦截对对象的 Object.isExtensible()。该方法接受一个参数。target:目标对象。返回值必须返回一个 Boolean 值或可转换成 Boolean 的值。
1 | let p = new Proxy( |
注意,该方法有一个强制的约束,即 Object.isExtensible(proxy) 必须同 Object.isExtensible(target)返回相同值。也就是必须返回 true 或者为 true 的值,返回 false 和为 false 的值都会报错。
1 | let p = new Proxy( |
11、ownKeys()
用来拦截对象自身属性的读取操作。该方法接受一个参数,target:目标对象。
具体拦截如下:
- Object.getOwnPropertyNames()
- Object.getOwnPropertySymbols()
- Object.keys()
- for…in 循环
该方法有几个约束条件:
- ownKeys 的结果必须是一个数组
- 数组的元素类型要么是一个 String ,要么是一个 Symbol
- 结果列表必须包含目标对象的所有不可配置(non-configurable )、自有(own)属性的 key
- 如果目标对象不可扩展,那么结果列表必须包含目标对象的所有自有(own)属性的 key,不能有其它值
1 | let target = { |
12、preventExtensions()
用于设置对 Object.preventExtensions()的拦截。该方法接受一个参数,target:所要拦截的目标对象。该方法返回一个布尔值。该方法有个限制,即如果目标对象是可扩展的,那么只能返回 false。
1 | let p = new Proxy( |
上面代码中,proxy.preventExtensions()方法返回 true,但这时 Object.isExtensible(proxy)会返回 true,因此报错。
为了防止出现这个问题,通常要在 proxy.preventExtensions()方法里面,调用一次 Object.preventExtensions()。
1 | let p = new Proxy( |
13、setPrototypeOf()
用来拦截 Object.setPrototypeOf()。该方法接受两个参数,target:被拦截目标对象。prototype:对象新原型或为 null。如果成功修改了[[Prototype]], setPrototypeOf 方法返回 true,否则返回 false。
1 | var handler = { |
上面代码中,只要修改 target 的原型对象,就会报错。
注意,如果目标对象不可扩展(non-extensible),setPrototypeOf()方法不得改变目标对象的原型。
3、Object.defineProperty 和 Proxy 的区别
最主要的区别其实是应用到 Vue 的双向数据绑定中,其实也有由于 Vue 的双向数据绑定,才让这两个方法越来越多的进入到人们的视野中。
在此我们不深入讲解双向数据绑定,所以分别用这两个方法实现个简单版的双向数据绑定,来看看两者之间的区别。
3.1、Object.defineProperty 版
1 | const obj = {}; |
可以看出来这个简单版的透露出 Object.defineProperty 一个很明显的缺点,就是只能对对象的一个属性进行监听,如果想要对对象的所有属性监听的话,就要去遍历,并且还有一个问题就是无法去监听数组,但是还是有优点的,优点就是兼容性好,这也是为什么 Vue2.0 优先选择了 Object.defineProperty 的原因。
3.2、Proxy 版
1 | const input = document.getElementById("input"); |
从上述可以看出 Proxy 可以对整个对象进行拦截,并且其能返回一个新的对象,除此之外其能对数组进行拦截。而且最直观的就是其有 13 个拦截方式。但是其最致命的问题就是兼容性不好,而且无法用 polyfill 磨平,因此尤大大才声明需要等到下个大版本(3.0)才能用 Proxy 重写。