ES6 如何改变JS内置行为的代理与反射
代理(Proxy)可以拦截并改变JS引擎的底层操作,如数据读取、属性定义、函数构造等一系列操作。ES6通过对这些底层内置对象的代理陷阱和反射函数,让开发者能进一步接近JS引擎的能力。
一、代理与反射的基本概念
什么是代理和反射呢?
代理是用来替代另一个对象(target),JS通过newProxy()创建一个目标对象的代理,该代理与该目标对象表面上可以被当作同一个对象来对待。
当目标对象上的进行一些特定的底层操作时,代理允许你拦截这些操作并且覆写它,而这原本只是JS引擎的内部能力。
如果你对些代理&反射的概念比较困惑的话,可以直接看后面的应用示例,最后再重新看这些定义就会更清晰!
拦截行为使用了一个能够响应特定操作的函数(被称为陷阱),每个代理陷阱对应一个反射(Reflect)方法。
ES6的反射API以Reflect对象的形式出现,对象每个方法都与对应的陷阱函数同名,并且接收的参数也与之一致。以下是Reflect对象的一些方法:
方法
get
读取一个属性的值
Reflect.get()
set
写入一个属性
Reflect.set()
has
in运算符
Reflect.has()
deleteProperty
delete运算符
Reflect.deleteProperty()
getPrototypeOf
Object.getPrototypeOf()
Reflect.getPrototypeOf()
isExtensible
Object.isExtensible()
Reflect.isExtensible()
defineProperty
Object.defineProperty()
Reflect.defineProperty
apply
调用一个函数
Reflect.apply()
construct
使用new调用一个函数
Reflect.construct()
每个陷阱函数都可以重写JS对象的一个特定内置行为,允许你拦截并修改它。
综合来说,想要控制或改变JS的一些底层操作,可以先创建一个代理对象,在这个代理对象上挂载一些陷阱函数,陷阱函数里面有反射方法。通过接下来的应用示例可以更清晰的明白代理的过程。
二、开始一个简单的代理
当你使用Proxy构造器来创建一个代理时,需要传递两个参数:目标对象(target)以及一个处理器(handler),
先创建一个仅进行传递的代理如下:
//目标对象 lettarget={}; //代理对象 letproxy=newProxy(target,{}); proxy.name="hello"; console.log(proxy.name);//"hello" console.log(target.name);//"hello" target.name="world"; console.log(proxy.name);//"world" console.log(target.name);//"world
上例中的proxy代理对象将所有操作直接传递给target目标对象,代理对象proxy自身并没有存储该属性,它只是简单将值传递给target对象,proxy.name与target.name的属性值总是相等,因为它们都指向target.name。
此时代理陷阱的处理器为空对象,当然处理器可以定义了一个或多个陷阱函数。
2.1set验证对象属性的存储
假设你想要创建一个对象,并要求其属性值只能是数值,这就意味着该对象的每个新增属性
都要被验证,并且在属性值不为数值类型时应当抛出错误。
这时需要使用set陷阱函数来拦截传入的value,该陷阱函数能接受四个参数:
- trapTarget:将接收属性的对象(即代理的目标对象)
- key:需要写入的属性的键(字符串类型或符号类型)
- value:将被写入属性的值;
- receiver:操作发生的对象(通常是代理对象)
set陷阱对应的反射方法和默认特性是Reflect.set(),和陷阱函数一样接受这四个参数,并会基于操作是否成功而返回相应的结果:
lettargetObj={}; letproxyObj=newProxy(targetObj,{ set:set }); /*定义set陷阱函数*/ functionset(trapTarget,key,value,receiver){ if(isNaN(value)){ thrownewTypeError("Property"+key+"mustbeanumber."); } returnReflect.set(trapTarget,key,value,receiver); } /*测试*/ proxyObj.count=123; console.log(proxyObj.count);//123 console.log(targetObj.count);//123 proxyObj.anotherName="proxy"//TypeError:PropertyanotherNamemustbeanumber.
示例中set陷阱函数成功拦截传入的value值,你可以尝试一下,如果注释或不returnReflect.set()会发生什么?,答案是拦截陷阱就不会有反射响应。
需要注意的是,直接给targetObj目标对象赋值时是不会触发set代理陷阱的,需要通过给代理对象赋值才会触发set代理陷阱与反射。
2.2get验证对象属性的读取
JS非常有趣的特性之一,是读取不存在的属性时并不会抛出错误,而会把undefined当作该属性的值。
对于大型的代码库,当属性名称存在书写错误时(不会抛错)会导致严重的问题。这时使用get代理陷阱验证对象结构(ObjectShape),访问不存在的属性时就抛出错误,使对象结构验证变得简单。
get陷阱函数会在读取属性时被调用,即使该属性在对象中并不存在,它能接受三个参数:
- trapTarget:将会被读取属性的对象(即代理的目标对象)
- key:需要读取的属性的键(字符串类型或符号类型)
- receiver:操作发生的对象(通常是代理对象)
Reflect.get()方法接受与之相同的参数,并返回默认属性的默认值。
letproxyObj=newProxy(targetObj,{ set:set, get:get }); /*定义get陷阱函数*/ functionget(trapTarget,key,receiver){ if(!(keyinreceiver)){ thrownewTypeError("Property"+key+"doesn'texist."); } returnReflect.get(trapTarget,key,receiver); } console.log(proxyObj.count);//123 console.log(proxyObj.newcount)//TypeError:Propertynewcountdoesn'texist.
这段代码允许添加新的属性,并且此后可以正常读取该属性的值,但当读取的属性并
不存在时,程序抛出了一个错误,而不是将其默认为undefined。
还可以使用has陷阱验证in运算符,使用deleteProperty陷阱函数避免属性被delete删除。
注:in运算符用于判断对象中是否存在某个属性,如果自有属性或原型属性匹配这个名称字符串或Symbol,那么in运算符返回true。
targetObj={ name:'targetObject' }; console.log("name"intargetObj);//true console.log("toString"intargetObj);//true
其中name是对象自身的属性,而toString则是原型属性(从Object对象上继承而来),所以检测结果都为true。
has陷阱函数会在使用in运算符时被调用,并且会传入两个参数(同名反射Reflect.has()方法也一样):
- trapTarget:需要读取属性的对象(代理的目标对象)
- key:需要检查的属性的键(字符串类型或Symbol符号类型)
deleteProperty陷阱函数会在使用delete运算符去删除对象属性时下被调用,并且也会被传入两个参数(Reflect.deleteProperty()方法也接受这两个参数):
- trapTarget:需要删除属性的对象(即代理的目标对象);
- key:需要删除的属性的键(字符串类型或符号类型)。
一些思考:分析过Vue源码的都了解过,给一个Vue实例中挂载的data,是通过Object.defineProperty代理vm._data中的对象属性,实现双向绑定......同理可以考虑使用ES6的Proxy的get和set陷阱实现这个代理。
三、对象属性陷阱
3.1数据属性与访问器属性
ES5最重要的特征之一就是引入了Object.defineProperty()方法定义属性的特性。属性的特性是为了实现javascript引擎用的,属于内部值,因此不能直接访问他们。
属性分为数据属性和访问器属性。使用Object.defineProperty()方法修改数据属性的特性值的示例如下:
letobj1={ name:'myobj', } /*数据属性*/ Object.defineProperty(obj1,'name',{ configurable:false,//defaulttrue writable:false,//defaulttrue enumerable:true,//defaulttrue value:'jenny'//defaultundefined }) console.log(obj1.name)//'jenny'
其中[[Configurable]]表示能否通过delete删除属性从而重新定义为访问器属性;[[Enumerable]]表示能否通过for-in循环返回属性;[[Writable]]表示能否修改属性的值;[[Value]]包含这个属性的数据值。
对于访问器属性,该属性不包含数据值,包含一对getter和setter函数,定义访问器属性必须使用Object.defineProperty()方法:
letobj2={ age:18 } /*访问器属性*/ Object.defineProperty(obj2,'_age',{ configurable:false,//defaulttrue enumerable:false,//defaulttrue get(){//defaultundefined returnthis.age }, set(num){//defaultundefined this.age=num } }) /*修改访问器属性调用getter*/ obj2._age=20 console.log(obj2.age)//20 /*输出访问器属性*/ console.log(Object.getOwnPropertyDescriptor(obj2,'_age')) //{get:[Function:get], //set:[Function:set], //enumerable:false, //configurable:false}
[[Get]]在读取属性时调用的函数,[[Set]]再写入属性时调用的函数。使用访问器属性的常用方式,是设置一个属性的值导致其他属性发生变化。
3.2检查属性的修改
代理允许你使用defineProperty同名函数陷阱函数拦截Object.defineProperty()的调用,defineProperty陷阱函数接受下列三个参数:
- trapTarget:需要被定义属性的对象(即代理的目标对象);
- key:属性的键(字符串类型或符号类型);
- descriptor:为该属性准备的描述符对象。
defineProperty陷阱函数要求在操作后返回一个布尔值用于判断操作是否成功,如果返回了false则抛出错误,故可以使用该功能来限制哪些属性可以被Object.defineProperty()方法定义。
例如,如果想阻止定义Symbol符号类型的属性,你可以检查传入的属性值,若是则返回false:
/*定义代理*/ letproxy=newProxy({},{ defineProperty(trapTarget,key,descriptor){ if(typeofkey==="symbol"){ returnfalse; } returnReflect.defineProperty(trapTarget,key,descriptor); } }); Object.defineProperty(proxy,"name",{ value:"proxy" }); console.log(proxy.name);//"proxy" letnameSymbol=Symbol("name"); //抛出错误 Object.defineProperty(proxy,nameSymbol,{ value:"proxy" })
四、函数代理
4.1构造函数&立即执行
函数的两个内部方法:[[Call]]与[[Construct]]会在函数被调用时调用,通过代理函数来为这两个内部方法设置陷阱,从而控制函数的行为。
[[Construct]]会在函数被使用new运算符调用时执行,代理触发construct()陷阱函数,并和Reflect.construct()一样接收到下列两个参数:
- trapTarget:被执行的函数(即代理的目标对象);
- argumentsList:被传递给函数的参数数组。
[[Call]]会在函数被直接调用时执行,代理触发apply()陷阱函数,它和Reflect.apply()都接收三个参数:
- trapTarget:被执行的函数(代理的目标函数);
- thisArg:调用过程中函数内部的this值;
- argumentsList:被传递给函数的参数数组。
每个函数都包含call()和apply()方法,用于重置函数运行的作用域即this指向,区别只是接收参数的方式不同:call()的参数需要逐个列举、apply()是参数数组。
显然,apply与construct要求代理目标对象必须是一个函数,这两个代理陷阱在函数的执行方式上开启了很多的可能性,结合使用就可以完全控制任意的代理目标函数的行为。
4.2验证函数的参数
看到apply()和construct()陷阱的参数都有被传递给函数的参数数组argumentsList,所以可以用来验证函数的参数。
例如需要保证所有参数都是某个特定类型的,并且不能通过new构造使用,示例如下:
/*定义sum目标函数*/ functionsum(...values){ returnvalues.reduce((previous,current)=>previous+current,0); } /*定义apply陷阱函数*/ functionapplyRef(trapTarget,thisArg,argumentList){ argumentList.forEach((arg)=>{ if(typeofarg!=="number"){ thrownewTypeError("Allargumentsmustbenumbers."); } }); returnReflect.apply(trapTarget,thisArg,argumentList); } /*定义construct陷阱函数*/ functionconstructRef(){ thrownewTypeError("Thisfunctioncan'tbecalledwithnew."); } /*定义sumProxy代理函数*/ letsumProxy=newProxy(sum,{ apply:applyRef, construct:constructRef }); console.log(sumProxy(1,2,3,4));//10 //console.log(sumProxy(1,"2",3,4));//TypeError:Allargumentsmustbenumbers. //letresult=newsumProxy()//TypeError:Thisfunctioncan'tbecalledwithnew.
sum()函数会将所有传递进来的参数值相加,此代码通过将sum()函数封装在sumProxy()代理中,如果传入参数的值不是数值类型,该函数仍然会尝试加法操作,但在函数运行之前拦截了函数调用,触发apply陷阱函数以保证每个参数都是数值。
出于安全的考虑,这段代码使用construct陷阱抛出错误,以确保该函数不会被使用new运算符调用
实例对象instance对象会被同时判定为proxy与target对象的实例,是因为instanceof运算符使用了原型链来进行推断,而原型链查找并没有受到这个代理的影响,因此proxy对象与target对象对于JS引擎来说就有同一个原型。
4.3调用类的构造函数
ES6中新引入了class类的概念,类使用constructor构造函数封装数据,并规定必须始终使用new来调用,原因是类构造器的内部方法[[Call]]被明确要求抛出错误。
代理可以拦截对于[[Call]]方法的调用,你可以借助代理调用的类构造器。例如在缺少new的情况下创建一个新实例,就使用apply陷阱函数实现:
classPerson{ constructor(name){ this.name=name; } } letPersonProxy=newProxy(Person,{ apply:function(trapTarget,thisArg,argumentList){ returnnewtrapTarget(...argumentList); } }); letme=PersonProxy("Jenny"); console.log(me.name);//"Jenny" console.log(meinstanceofPerson);//true console.log(meinstanceofPersonProxy);//true
类构造器即类的构造函数,使用代理时它的行为就像函数一样,apply陷阱函数重写了默认的构造行为。
关于类的更多有趣的用法,可参考【ES6】更易于继承的类语法
总结来说,代理的用途非常广泛,因为它提供了修改JS内置对象的所有行为的入口。上述例子只是简单的一些应用入门,还有更多复杂的示例,推荐阅读《深入理解ES6》。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持毛票票。
声明:本文内容来源于网络,版权归原作者所有,内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:czq8825#qq.com(发邮件时,请将#更换为@)进行举报,并提供相关证据,一经查实,本站将立刻删除涉嫌侵权内容。