上一篇文章中,我们讲解了 Vue3 中对响应式系统的实现,本章节会更进一步的从数据层面分享 Vue3 中对响应式数据是如何进行代理的,本文主要从引用类型数据和基本类型数据两个方面进行讲解。
实现数据代理的基础
理解 Proxy 和 Reflect
首先,我们需要介绍一下实现代理的基础 API。
众所周知,Vue3 中的响应式数据是基于 Proxy 实现的,那什么是 Proxy 呢?
简单地说,使用 Proxy 可以创建一个代理对象。它能够实现对其他对象的代理,这里的关键词是其他对象,也就是说,Proxy 只能代理对象,无法代理非对象值,例如字符串、布尔值等。
那什么是对象的代理呢?
代理的含义: 对一个对象基本语义的代理,拦截并重新定义对一个对象的基本操作。
基本语义的操作: 读取、设置属性值的操作,就属于基本语义的操作。
const p = new Proxy(obj, {
// 拦截读取属性操作
get() { /*...*/ },
// 拦截设置属性操作
set() { /*...*/ }
})
obj.foo // 读取属性 foo 的值
obj.foo++ // 读取和设置属性 foo 的值
因为在 JavaScript 的世界里,万物皆对象。一个函数也是一个对象,所以调用函数也是对一个对象的基本操作,所以我们也可以用 Proxy 来拦截函数的调用操作:
const fn = (name) => {
console.log('我是:', name)
}
const p2 = new Proxy(fn, {
// 使用 apply 拦截函数调用
apply(target, thisArg, argArray) {
target.call(thisArg, ...argArray)}
})
p2('lc') // 输出:'我是:lc'
所以,Proxy 对一个对象的代理,也就是能够拦截对一个对象的基本操作。
但是对一个对象的复合操作是无法代理的,调用对象下的方法就是典型的复合操作:
obj.fn()
它是由两个基本语义组成的。第一个基本语义是get,即先通过 get 操作得到 obj.fn 属性。第二个基本语义是函数调用,即通过get 得到 obj.fn 的值后再调用它。
我们再来介绍一下在 Vue3 响应式数据的代理中会使用到的 Reflect。
Reflect 是一个全局对象,在其下面有很多方法:
Reflect.get()
Reflect.set()
Reflect.apply()
// ...
任何在 Proxy 的拦截器中能够找到的方法,都能够在 Reflect 中找到同名函数。
例如下面的两个操作是等价的:
const obj = { foo: 1 }
// 直接读取
console.log(obj.foo) // 1
// 使用 Reflect.get 读取
console.log(Reflect.get(obj, 'foo')) // 1
既然操作是等价的,那么 Vue3 中使用 Reflect 的意义是什么呢?
实际上 Reflect.get 函数还能接收第三个参数,即指定接收者 receiver,你可以把它理解为函数调用过程中的 this,例如:
const obj = { foo: 1 }
console.log(Reflect.get(obj, 'foo', { foo: 2 })) // 输出的是 2 而不是 1
Reflect 方法虽然还有很多其他方面的意义,但是在 Vue3 中唯一关心的只有这一点,因为它与响应式数据的实现密切相关。我们在使用 Proxy 进行代理的过程中,原对象和代理对象之间会出现 this 指向混乱的问题,这会导致收集依赖时出错,使用 Reflect 传递 this 可以帮助避免在响应式代理阶段产生的 this 指向问题。
下面举个例子帮助大家理解。
const obj = { foo: 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 对象。
接下来我们为 obj 添加一个 bar 属性:
const obj = {
foo: 1,
get bar() {
return this.foo}
}
bar 属性是一个访问器属性,它返回了 this.foo 属性的值。接着,我们在 effect 副作用函数中通过代理对象 p 访问 bar 属性:
effect(() => {
console.log(p.bar) // 1
})
我们来分析一下这个过程发生的事情。当 effect 注册的副作用函数执行时,会读取 p.bar 属性,它发现 p.bar 是一个访问器属性,因此执行 getter 函数。由于在 getter 函数中通过 this.foo 读取了 foo 属性值,因此我们认为副作用函数与属性 foo 之间也会建立联系。当我们修改 p.foo 的值时应该能够触发响应,使得副作用函数重新执行才对。
然而实际并非如此,当我们尝试修改 p.foo 的值时:
p.foo++
副作用函数并不会重新执行,问题出在哪里呢?实际上,问题就出在 bar 属性的访问器函数 getter 里:
const obj = {
foo: 1,
get bar() {
// 这里的 this 指向的是谁?
return this.foo}
}
当我们使用 this.foo 读取 foo 属性值时,这里的 this 指向的是谁呢?
我们回顾一下整个流程。首先,我们通过代理对象 p 访问 p.bar,这会触发代理对象的 get 拦截函数执行:
const p = new Proxy(obj, {
get(target, key) {
track(target, key)
// 注意,这里我们没有使用 Reflect.get 完成读取
return target[key]},
// 省略部分代码
})
在 get 拦截函数内,通过 target[key] 返回属性值。其中 target 是原始对象 obj,而 key 就是字符串 ‘bar’,所以 target[key] 相当于 obj.bar。因此,当我们使用p.bar 访问 bar 属性时,它的 getter 函数内的 this 指向的其实是原始对象 obj,这说明我们最终访问的其实是 obj.foo。
很显然,在副作用函数内通过原始对象访问它的某个属性是不会建立响应联系的,这等价于:
effect(() => {
// obj 是原始数据,不是代理对象,这样的访问不能够建立响应联系
obj.foo
})
因为这样做不会建立响应联系,所以出现了无法触发响应的问题。那么这个问题应该如何解决呢?这时 Reflect.get 函数就派上用场了:
const p = new Proxy(obj, {
// 拦截读取操作,接收第三个参数 receiver
get(target, key, receiver) {
track(target, key)
// 使用 Reflect.get 返回读取到的属性值
return Reflect.get(target, key, receiver)},
// 省略部分代码
})
代理对象的 get 拦截函数接收第三个参数 receiver,它代表谁在读取属性,例如:
p.bar // 代理对象 p 在读取 bar 属性
当我们使用代理对象 p 访问 bar 属性时,那么 receiver 就是 p,可以把它简单地理解为函数调用中的 this。接着关键的一步发生了,我们使用 Reflect.get(target, key, receiver)
代替之前的 target[key]
,这里的关键点就是第三个参数 receiver。我们已经知道它就是代理对象 p,所以访问器属性 bar 的 getter 函数内的 this 指向代理对象 p。
很显然,这样的 this 指向才会在副作用函数与响应式数据之间建立响应联系,从而达到依赖收集的效果。如果此时再对 p.foo 进行自增操作,会发现已经能够触发副作用函数重新执行了。
正是基于上述原因,Vue3 中统一使用了 Reflect.* 方法来进行对象的各类操作。