理解Proxy和Reflect
既然Vue.js
3的响应式数据是基于Proxy
实现的,那么我们就有必要了解Proxy
和与之关联的Reflect
。简单说,使用Proxy
可以创建一个代理对象。它能够实现对其他对象的代理,这里的关键词是其他对象, 也就是说,它只能代理对象,无法代理非对象值,例如字符串、布尔值等。那么代理指的是什么呢?所谓代理,指的是对一个对象基本语义的代理。它允许我们拦截并重新定义对一个对象的基本操作。这句话的关键词比较多,我们逐一解释。
什么是基本语义?给出一个对象obj,可以对它进行一些操作,例如读取属性值、设置属性值:
1 | obj.name // 读取属性name属性值 |
类似这种读取属性、设置属性值的操作,就属于基本语义的操作,即基本操作。既然是基本操作,那就可以使用Proxy
进行拦截:
1 | const p = new Proxy(obj, { |
如上代码所示,Proxy
构造函数接收两个参数。第一个参数是被代理的对象,第二个参数也是一个对象,这个对象是一对夹子(trap)。其中get
函数用来拦截读取操作,set
函数用来拦截设置操作。
在JavaScript
的世界里,万物皆对象。例如一个函数也是一个对象,所以调用函数也是对一个对象的基本操作:
1 | const fn = (name) => { |
因此我们可以使用Proxy
来拦截函数的基本操作,这里我们使用apply
拦截函数的调用:
1 | const p2 = new Proxy(fn, { |
上面两个例子说明了什么是基本操作。Proxy
只能够拦截一个对象的基本操作。那么,什么是非基本操作呢?其实调用对象下的方法就是典型的非基本操作,我们叫它复合操作:
1 | obj.fn() |
实际上,调用一个对象下的方法,是由两个基本语义组成的。第一个基本语义是get
,即先通过get
操作得到obj.fn
属性。第二个基本语义是函数调用,即通过get
得到obj.fn
的值后再调用它,也就是我们上面所说到的apply
。理解Proxy
只能够代理对象的基本语义很重要,后续我们讲解如何实现对数组或者Map、Set等数据类型的代理时,都利用了Proxy
的这个特点。
理解了Proxy
,我们再来讨论Reflect
。Reflect
是一个全局对象,其下有许多方法。例如:
1 | Reflect.get() |
你可能已经注意到了,Reflect
下的方法与Proxy
的拦截器方法名字相同,其实这不是偶然。任何在Proxy
的拦截器中能够找到的方法,都能够在Reflect
中找到同名函数,那么这些函数的作用是什么呢?其实它们的作用一点都不神秘。拿Reflect.get
函数来说,它的功能就是提供了访问一个对象属性的默认行为,例如下面这两个操作是等价的:
1 | const obj = { count: 1 } |
既然操作等价,那么它存在的意义是什么呢?实际上Reflect.get
函数还能接受第三个参数,即指定接收者receiver
,你可以把它理解为函数调用过程中的this
,例如:
1 | const obj = { count: 1 } |
在这段代码中,我们指定第三个参数receiver
为一个对象{count: 2}
,这是读取到的值是receiver
对象的count
属性值。为了说明问题,回顾一下前面实现响应式数据的代码:
1 | const obj = { count: 1 } |
这是之前用来实现响应式数据的基本代码。在get和set拦截函数中,我们都是直接使用原始对象target
来完成对属性的读取和设置操作的,其中原始对象target
就是上述代码中的obj
对象。
那么这段代码有什么问题呢?我们借助effect
让问题暴露出来。首先我们修改一下obj
对象,为它添加一个bar
属性:
1 | const obj = { |
可以看到bar
属性是一个访问器属性,它返回了this.foo
属性的值。接着我们在effect
副作用函数中通过代理对象p
访问bar
属性:
1 | effect(() => { |
我们来分析一下这个过程发生了什么。当effect
注册的副作用函数执行时,会读取p.bar
属性,它发现p.bar
是一个访问器属性,因此执行getter
函数。由于在getter
函数中通过this.foo
读取了foo
属性值,因此我们认为副作用函数与属性foo
之间建立了联系。当我们修改p.foo
的值时应该能够触发响应,使得副作用函数重新执行才对。然而实际并非如此,当我们尝试修改p.foo的值时:
1 | p.foo++ |
副作用函数并没有重新执行,问题出在哪里呢?实际上,问题就出在bar属性的访问器函数getter里:
1 | const obj = { |
当我们使用this.foo
读取foo
属性值时,这里的this指向的是谁呢?我们回顾一下整个流程。首先,我们通过代理对象p访问p.bar,这会触发代理对象的get拦截函数执行:
1 | const p = new Proxy(obj, { |
在get拦截函数内,通过target[key]
返回属性值。其中target是原始对象obj,而key就是字符串’bar’,所以target[key]
相当于obj.bar
。因此,当我们使用p.bar访问bar属性时,它的target函数内的this指向的其实是原始对象obj,这说明我们最终访问的其实是obj.foo
。
很显然,在副作用函数内通过原始对象访问它的某个属性是不会建立响应联系的,这等价于:
1 | effect(() => { |
因为这样做不会建立响应联系,所以就出现了无法触发响应的问题。那么这个问题应该如何解决呢?这时Reflect.get
函数就派上用场了。先给出解决问题的代码:
1 | const p = new Proxy(obj,{ |
如上面代码所示,代理对象的get拦截函数接收第三个参数receiver
,它代表谁在读取属性,例如:
1 | p.bar // 代理对象p在读取bar属性 |
当我们使用代理对象p访问bar属性时,那么receiver就是p。你可以把它理解为函数调用中的this。接着关键的一步发生了,我们使用Reflect.get(target, key, receiver)
代替target[key]
,这里的关键点就是第三个参数receiver
。我们已经知道它就是代理对象p,所以访问器属性bar的getter函数内的this指向代理对象p:
1 | const obj = { |
可以看到,this由原始对象obj变成了代理对象p。很显然,这会在副作用函数与响应式数据之间建立响应联系,从而达到依赖收集的效果。如果此时再对p.foo进行自增操作,会发现已经能够触发副作用函数重新执行了。