当前位置: 首页 > 科技观察

前车之鉴:上面写着你可能不知道的Proxy_0

时间:2023-03-16 21:00:22 科技观察

我们都知道Vue2的响应式系统是通过使用Object.defineProperty进行数据劫持来实现的,但是其自身的语法存在以下缺陷:对于普通对象,监听需要遍历每一个属性,无法监听到数组的变化,无法监听变化Map/Set数据结构,无法监控对象的新增/删除属性。@vue/反应模块。因此,要了解和学习Vue3的响应式系统,掌握Proxy就显得尤为重要。看完本文,我们可以了解到:Proxy对象的基本用法Proxy可以实现代理对象的工作原理实现基本操作(如属性查找、赋值、枚举、函数调用等)的拦截和定制。它的基本语法如下:constp=newProxy(target,handler);参数说明:target:我们要代理的对象。我们都知道JS中“万物皆对象”,所以这个target可以是任何类型的对象,包括原生数组、函数,甚至是另一个Proxy对象。同时请注意定义中的关键字“usedtocreateaproxyforanobject”,所以Proxy只能代理对象,不能代理任何原始值类型。比如原值类型为number和boolean的proxy会得到“Cannotcreateproxywithanon-objectastargetorhandler”的错误:handler:itisanobjectwhoseattributesareallfunctiontypes。这些函数类型的属性也称为陷阱,它们的作用是实现定义中提到的“基本操作(如属性查找、赋值、枚举、函数调用等)的拦截和定制”。注意,这里的拦截实际上是对代理对象p的基本操作的拦截,而不是对被代理对象target的拦截(至于为什么会在下一个工作原理章节中解释)。handler对象一共有13个属性方法(trap)如下截图:基本用法如下:constobj={foo:'bar',fn(){console.log('fncalled');}};consthandler={get(target,key){console.log(`我被读取了${key}属性`);返回目标[键];},set(target,key,val){console.log(`我读到${key}属性已设置,val:${val}`);目标[键]=val;},apply(target,thisArg,argumentsList){console.log('fn调用被拦截');回归目标。调用(thisArg,...argumentsList);}};constp=newProxy(obj,handler);p.foo;//输出:我读取了foo属性p.foo='bar1';//输出:我设置了foo属性,val:bar1p.fn();//Output:Iwasreadfnpropertyfncalled在上面的代码中,我们只是实现了13个方法中的get/set/apply,这三个trap的含义分别是:属性读取操作的捕获器,属性设置的捕获器操作,以及函数调用操作的捕获器。其他10个方法(captures)的含义在此不再赘述。有兴趣的同学可以去MDN了解更多。值得注意的是,上述代码中并没有拦截obj.fn()函数调用操作,只是输出了“我已经读取了fn属性”。究其原因,我们又可以从Proxy定义中的关键字“基本操作”中找到答案。那么什么是基本操作呢?上面的代码表明对象属性的读取(p.foo)和设置(p.foo='xxx')是基本操作,对应的非基本操作,我们可以称之为复合操作。而obj.fn()是一个典型的复合操作,由两个基本操作组成:读操作(obj.fn),和函数调用操作(获取obj.fn的值然后调用),以及对象我们的代理是obj,而不是obj.fn。因此,我们只能拦截fn属性的读操作。这也说明Proxy只能代理对象的基本操作,这一点尤为重要。下面的代码表明函数调用也是一个基本操作,可以被apply拦截:consthandler={apply(target,thisArg,argumentsList){console.log('函数调用被拦截');返回target.call(thisArg,...argumentsList);}};newProxy(()=>{},handler)();//输出:函数调用被拦截Reflex和Proxy首先我们看一下MDN中Reflex的定义:Reflect是一个内置对象,提供拦截JavaScript操作的方法。这些方法与代理处理程序的方法相同。不难发现,Reflex对象的方法和代理拦截器(第二个入参handler)的方法完全一样,也是13个方法:那么,Reflex对象的作用是什么,取以Reflect.get为例,它的功能之一是提供访问对象属性的默认行为,例如下面的代码:constobj={foo:'foo'};对象.foo;//相当于Reflect.get(obj,'foo');既然功能一致,那么用Reflect.get还有什么意义呢?在回答这个问题之前,我们先看看下面这段代码:constobj={foo:'foo',getbar(){returnthis.foo;}};consthandler={get(target,key,receiver){console.log(`我被读取了${key}属性`);返回目标[键];},set(target,key,val,receiver){console.log(`我已经设置了${key}属性,val:${val}`);目标[键]=val;}};constp=newProxy(obj,handler);p.bar;//输出:我被读取了bar属性//问:为什么读取foo属性没有被拦截?上面代码中我们定义了一个foo属性和一个bar属性,其中bar属性是一个access属性,是通过get函数returnthis.foo获取的,所以按理说当我们读取bar属性的时候,就会触发foo属性的读取,也会被get的trap拦截,但是实际代码运行结果并没有拦截到foo属性。为什么是这样?答案的关键是bar访问器中的this指针。整理一下代码运行过程:p.bar实际上会被handler的get和returntarget['bar']捕获,而这里的target其实是obj,所以bar访问器中的this指向obj,this.foo,它实际上是obj.foo。而obj不是代理对象p,所以访问它的foo属性不会被拦截。那么如何才能同时触发对foo属性的拦截呢?这时候Reflect就派上用场了,代码如下:constobj={foo:'foo',getbar(){returnthis.foo;}};consthandler={get(target,key,receiver){console.log(`我被读取了${key}属性`);returnReflect.get(目标,密钥,接收者);},set(target,key,val,receiver){console.log(`我设置了${key}属性,val:${val}`);返回Reflect.set(目标、键、值、接收器);}};constp=newProxy(obj,handler)p.bar;//输出:读取了bar属性,读取了foo属性如上代码所示,我们可以正确触发对foo属性的拦截,其实现的关键在于Reflect.get的第三个参数,receiver,用于改变this的方向。MDN中是这样描述的:如果在目标对象中指定了一个getter,则receiver就是调用getter时this的值。而我们这里的receiver是p对象,this.foo就相当于p.foo,所以访问bar属性的时候也可以拦截。也正是因为this所指出的问题,建议通过Reflex.*来操作代理对象拦截器中的属性方法。Proxy的工作原理内部方法和内部槽我们在Proxy介绍章节中提到:“Proxy只能代理对象”。所以不知道大家有没有想过这样一个问题,JS中对象的定义是什么?关于这个问题的答案,我们需要从ECMAScript规范中寻找答案:在ecma262规范的6.1.7.2节开头,给出了这样的定义:对象的实际语义,在ECMAScript中,是通过称为算法指定的内部方法。ECMAScript引擎中的每个对象都与一组定义其运行时行为的内部方法相关联。这些内部方法不是ECMAScript语言的一部分。它们在本规范中的定义纯粹是为了说明目的。但是,每个对象都必须作为与它关联的内部方法指定的ECMAScript的实现来实现。实现这一点的确切方式由实现决定。那么,什么是内部方法呢?读完这一章,我们不难发现,对象不仅有内部方法,还有内部槽。在ECMAScript规范中,[[xxx]]用于表示内部方法或内部槽:内部方法和内部槽在本规范中使用双方括号[[]]括起来的名称来标识。内部方法对于JavaScript开发者来说是不可见的,但是当我们对一个对象进行操作时,JS引擎会调用它的内部方法。例如:当我们访问一个对象的属性时:constobj={foo:'foo'};对象.foo;引擎会调用obj内部方法[[Get]]获取foo属性的值;下面作为一个对象,它所必需的11个基本内部方法,也就是说,任何一个对象都必须部署以下11个内部方法:当然,不同的对象可能部署不同的内部方法。比如函数也是对象,那么怎么区分函数和普通的对象,或者对象怎么才能像函数一样被调用呢?答案是只要部署了内部方法[[Call]],该对象就是一个函数对象,而如果这个函数对象也部署了[[Construct]]内部方法,那么这个函数对象也是一个构造函数对象,这意味着它可以使用new操作符:同时,内部方法是多态的,也就是说不同对象的内部方法名是多态的。这意味着当对它们调用公共内部方法名称时,不同的对象值可能会执行不同的算法。调用内部方法的实际对象是调用的“目标”。如果在运行时,算法的实现尝试使用对象不支持的对象的内部方法,则会抛出TypeError异常。例如:Proxy对象和Ordinary对象都有内部方法[[Get]],但是它们的[[Get]]实现逻辑是不同的。Proxy对象的[[Get]]实现逻辑在ecma262规范的第10.5.8节中定义。普通对象的[[Get]]实现逻辑定义在ecma262规范的第10.1.8章中。普通对象与异构对象在上一节我们了解到对象有内部方法和内部槽,不同的对象可能有不同的内部方法或内部槽,即使有相同的内部方法,内部的内部实现逻辑方法可能不同。其实通过阅读ECMAScript规范,我们可以将JS对象分为两类:普通对象和奇异对象。区分一个对象是普通对象还是异构对象的标准是:内部方法或内部槽的差异。那么什么是普通对象呢?根据定义,它满足以下要求:也就是说,一个普通的对象需要满足以下三点:其内部方法的定义符合ECMAScript规范10.1.x章节的定义,如下图10内部方法:如果这个对象有一个内部方法[[Call]]那么它应该由ECMAScript规范的10.2.1节定义如果这个对象有一个内部方法[[Construct]]那么它应该由ECMAScript规范的10.2.2节定义综上所述,就是一个普通对象的定义。异构对象的定义比较简单,只要一个对象不是普通对象,那么就是异构对象。异国情调的对象是不是普通对象的对象。让我们谈谈代理。通过上两节,我们了解了普通对象和异构对象的定义。我们在阅读规范的时候不难发现,Proxy对象其实是一个异构对象。object,因为10.5.x章节定义了Proxy对象的内部方法,不满足普通对象的定义:Proxy如何实现代理对象与其内部方法实现逻辑密切相关。还是以代码为例:constobj={foo:'foo',};常量处理程序={};constp=newProxy(obj,{});p.foo;//输出:上面代码中的foo,我们的handler是一个空对象,但是具体是如何实现代理的,但是代理对象p还是可以实现对象obj的代理。具体来说,为什么p.foo的值等于obj.foo的值。通过前面两节的学习,我们知道对象属性的读取操作会触发引擎内部这个对象的内部方法[[Get]]的调用,那么我们来看一下[[Get]]Proxy的内部方法:这里我们重点关注步骤5-7,结合我们的代码,总之,我们在读取p.foo的时候,首先选择的是检查p对象是否有get陷阱。如果没有,则代理对象obj(target)的[[Get]]内部方法,如果有,它将调用处理程序的get方法并返回调用结果。因此,我们可以得出一个结论:创建代理对象p时指定的拦截器handler,实际上是用来自定义代理对象p本身的操作行为,而不是拦截自定义代理对象obj的操作行为。这体现了代理的透明性,也解释了我们在Proxy介绍中提到的问题:拦截实际上是对代理对象p的基本操作的拦截,而不是对被代理对象target的拦截。小结本文主要介绍了Proxy和Reflect的简单使用,然后从ECMAScript规范讲了内部方法、内部槽、通用对象、异构对象的定义,进而了解了Proxy可以实现代理的内部实现逻辑。参考代理-JavaScript|MDN(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)Reflect-JavaScript|MDN(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect)ECMAScript?2023语言规范(https://tc39.es/ecma262/)Vue.js设计和实现(https://www.ituring.com.cn/book/2953)作者:张宇航,微医前端技术部,文盲处女座程序员。