非原始值的响应式方案(一)

理解Proxy和Reflect

既然Vue.js3的响应式数据是基于Proxy实现的,那么我们就有必要了解Proxy和与之关联的Reflect。简单说,使用Proxy可以创建一个代理对象。它能够实现对其他对象的代理,这里的关键词是其他对象, 也就是说,它只能代理对象,无法代理非对象值,例如字符串、布尔值等。那么代理指的是什么呢?所谓代理,指的是对一个对象基本语义的代理。它允许我们拦截并重新定义对一个对象的基本操作。这句话的关键词比较多,我们逐一解释。

什么是基本语义?给出一个对象obj,可以对它进行一些操作,例如读取属性值、设置属性值:

1
2
obj.name // 读取属性name属性值
obj.name = 'lisi' // 读取和设置属性name的值

类似这种读取属性、设置属性值的操作,就属于基本语义的操作,即基本操作。既然是基本操作,那就可以使用Proxy进行拦截:

1
2
3
4
5
6
7
8
const p = new Proxy(obj, {
get() {
/*... */
},
set() {
/*... */
}
})

如上代码所示,Proxy构造函数接收两个参数。第一个参数是被代理的对象,第二个参数也是一个对象,这个对象是一对夹子(trap)。其中get函数用来拦截读取操作,set函数用来拦截设置操作。
JavaScript的世界里,万物皆对象。例如一个函数也是一个对象,所以调用函数也是对一个对象的基本操作:

1
2
3
4
5
const fn = (name) => {
console.log('我是:', name)
}
// 调用函数是对对象的基本操作
fn()

因此我们可以使用Proxy来拦截函数的基本操作,这里我们使用apply拦截函数的调用:

1
2
3
4
5
6
7
const p2 = new Proxy(fn, {
appy(target, thisArg, argArray) {
target.call(thisArg, ...argArray)
}
})

p2('guozhaoxi') // 输出:'我是:guozhaoxi'

上面两个例子说明了什么是基本操作。Proxy只能够拦截一个对象的基本操作。那么,什么是非基本操作呢?其实调用对象下的方法就是典型的非基本操作,我们叫它复合操作:

1
obj.fn()

实际上,调用一个对象下的方法,是由两个基本语义组成的。第一个基本语义是get,即先通过get操作得到obj.fn属性。第二个基本语义是函数调用,即通过get得到obj.fn的值后再调用它,也就是我们上面所说到的apply。理解Proxy只能够代理对象的基本语义很重要,后续我们讲解如何实现对数组或者Map、Set等数据类型的代理时,都利用了Proxy的这个特点。

理解了Proxy,我们再来讨论ReflectReflect是一个全局对象,其下有许多方法。例如:

1
2
3
4
Reflect.get()
Reflect.set()
Reflect.apply()
// ...

你可能已经注意到了,Reflect下的方法与Proxy的拦截器方法名字相同,其实这不是偶然。任何在Proxy的拦截器中能够找到的方法,都能够在Reflect中找到同名函数,那么这些函数的作用是什么呢?其实它们的作用一点都不神秘。拿Reflect.get函数来说,它的功能就是提供了访问一个对象属性的默认行为,例如下面这两个操作是等价的:

1
2
3
4
5
const obj = { count: 1 }
// 直接读取
console.log(obj.count) // 1
//使用Reflect.get读取
console.log(Reflect.get(obj, 'count')) // 1

既然操作等价,那么它存在的意义是什么呢?实际上Reflect.get函数还能接受第三个参数,即指定接收者receiver,你可以把它理解为函数调用过程中的this,例如:

1
2
const obj = { count: 1 }
console.log(Reflect.get('obj', 'count', { count: 2 })) // 输出2 而不是1

在这段代码中,我们指定第三个参数receiver为一个对象{count: 2},这是读取到的值是receiver对象的count属性值。为了说明问题,回顾一下前面实现响应式数据的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const obj = { count: 1 }

const p = new Proxy(obj, {
get(target, key) {
track(target, key)

// 这里没有使用Reflect.get完成读取
return target[key]
},
set(target, key, newVal) {
// 这里没有使用Reflect.set完成设置
target[key] = newVal
trigger(target, key)
}
})

这是之前用来实现响应式数据的基本代码。在get和set拦截函数中,我们都是直接使用原始对象target来完成对属性的读取和设置操作的,其中原始对象target就是上述代码中的obj对象。
那么这段代码有什么问题呢?我们借助effect让问题暴露出来。首先我们修改一下obj对象,为它添加一个bar属性:

1
2
3
4
5
6
const obj = {
foo: 1,
get bar() {
return this.foo
}
}

可以看到bar属性是一个访问器属性,它返回了this.foo属性的值。接着我们在effect副作用函数中通过代理对象p访问bar属性:

1
2
3
effect(() => {
console.log(bar) // 1
})

我们来分析一下这个过程发生了什么。当effect注册的副作用函数执行时,会读取p.bar属性,它发现p.bar是一个访问器属性,因此执行getter函数。由于在getter函数中通过this.foo读取了foo属性值,因此我们认为副作用函数与属性foo之间建立了联系。当我们修改p.foo的值时应该能够触发响应,使得副作用函数重新执行才对。然而实际并非如此,当我们尝试修改p.foo的值时:

1
p.foo++

副作用函数并没有重新执行,问题出在哪里呢?实际上,问题就出在bar属性的访问器函数getter里:

1
2
3
4
5
6
7
const obj = {
foo: 1,
get bar() {
// 这里的this指向的是谁
return this.foo
}
}

当我们使用this.foo读取foo属性值时,这里的this指向的是谁呢?我们回顾一下整个流程。首先,我们通过代理对象p访问p.bar,这会触发代理对象的get拦截函数执行:

1
2
3
4
5
6
const p = new Proxy(obj, {
get(target, key) {
track(target, key)
return target[key]
}
})

在get拦截函数内,通过target[key]返回属性值。其中target是原始对象obj,而key就是字符串’bar’,所以target[key]相当于obj.bar。因此,当我们使用p.bar访问bar属性时,它的target函数内的this指向的其实是原始对象obj,这说明我们最终访问的其实是obj.foo

很显然,在副作用函数内通过原始对象访问它的某个属性是不会建立响应联系的,这等价于:

1
2
3
4
effect(() => {
// obj是原始对象,不是代理对象,这样的访问不能建立响应联系
obj.foo
})

因为这样做不会建立响应联系,所以就出现了无法触发响应的问题。那么这个问题应该如何解决呢?这时Reflect.get函数就派上用场了。先给出解决问题的代码:

1
2
3
4
5
6
const p = new Proxy(obj,{
get(target, key, receiver) {
track(target, key)
return Reflect.get(obj, key, receiver)
}
})

如上面代码所示,代理对象的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
2
3
4
5
6
7
const obj = {
foo: 1,
get bar() {
// 这里的this指向的是代理对象p
return this.foo
}
}

可以看到,this由原始对象obj变成了代理对象p。很显然,这会在副作用函数与响应式数据之间建立响应联系,从而达到依赖收集的效果。如果此时再对p.foo进行自增操作,会发现已经能够触发副作用函数重新执行了。