在之前的文章中,我们介绍了响应系统的简单实现:effect、track和trigger。但是我们的track和trigger只是考虑了对象get/set的情况,但是实际上Vue中对于非原始值的代理要更复杂,比如track如何追踪拦截到for..in..循环,如何对数组进行代理,因为数组的很多原型方法也会触发读取写入操作,还有如何代理Map、set,不过因为篇幅原因,我打算以后有机会再介绍set/map的代理。开始之前先看第一个问题,也就是Vue3中实现代理的API:proxy
Proxy的“陷阱”函数中的第三个参数:
如果有不熟悉proxy的小伙伴,我推荐你看这篇文章,在这里我就不过多介绍基础语法了。
我这里要说的时proxy的第二个参数,这个参数是一个对象,这个对象要包含一组“陷阱”函数,也就是我们要代理的目标对象的基本语义,比如我们之前文章提到的get、set等等,共有13个,部署这些“陷阱”拦截的是目标对象的内部行为,比如读取对象的值触发get这个陷阱,设置对象的值会触发set。不同的这些“陷阱”函数会接受不同的参数,而我这里要说的是get,它们的第一个参数就是目标对象target,第二个是key,也就是要目标对象的key,第三个值是receiver,也就是这里要说的内容,看下面的例子:
const obj = {
foo:1
get bar() {
return this.foo
}
}
const proxyObj = new Proxy(obj, {
get(target, key, receiver) {
// 跟踪
track(target, value)
return target[key]
},
})
effect(() => {
// 1
console.log(proxyObj.bar)
})
proxyObj.foo++
obj对象被代理为proxyObj,然后在副作用函数内访问bar,bar是一个访问器属性,所以会通过访问this.foo返回1, 理想情况下,在我们看来,副作用函数内即访问了bar又访问了foo,所以这两个响应式数据理应和副作用函数都建立关联才对。
但实际上运行代码就会发现,代理对象foo的变化并不会触发副作用函数执行,问题就处在bar中的this:我们在get这个“陷阱”函数中使用的target[key]来访问数据所以访问bar实际上是访问的obj.bar而非proxyObj.bar,对原始对象的访问当然不会建立响应式联系。
为了解决这个问题,我们必须要使用Reflect.get()这个API,不了解的小伙伴可以去看上面提到的文章。这个API接受的参数和get一样,target,key,receiver,在这里receiver这个参数就要派上用场了:我们使用Reflect.get()来访问bar,同时传入receiver:
// 略...
const proxyObj = new Proxy(obj, {
get(target, key, receiver) {
// 跟踪
track(target, value)
return Reflect.get(target, key, receiver)
},
})
// 略...
这时receiver就是代理对象proxyObj,通过receiver这个参数我们就可以知道真正读取bar的对象到底是谁,也就是起到类似于this的作用,而Reflect.get(target, key, receiver)会访问真正this上的key,也就是receiver,这样在bar这个getter中的this就成为了proxyObj,也就可以在getter中通过访问proxyObj.foo建立响应式联系了。
这里稍微有些难理解,可以多看几遍前面推荐的文章。
如何拦截for...in...和key in obj:
我们之前使用get拦截对于对象中key的读取,但是读取不仅是读取某一个key,对于key in obj和for..in..这两个原生JS语法,也属于读取的范畴,而从vue3源码中可以知道代理的mutableHandlers有五种类型,其中的“陷阱函数”has()就是拦截key in obj所需要的陷阱函数。
而“陷阱函数”ownKeys()是拦截for..in..需要的陷阱函数,在源码reactity/basehandler.ts第209行中可以看到代理for..in..时,track()跟踪依赖的key是‘ITERATE_KEY’,因为for..in..并非是针对某个key进行读取写入操作,所以target也就没办法针对某个key进行副作用函数收集,所以Vue就用一个特殊的ITERATE_KEY来作为key,收集for..in..对应的副作用。相应的,当代理对象改变引起for..in..的执行结果变化时,触发trigger()也会触发ITERATE_KEY对应的副作用函数
我们现在已经知道了使用has和ownKeys来拦截这两种行为,但是如何跟踪这两种行为,在他们变化时触发对应的副作用呢?
首先对于has来说,因为对它的代理是追踪target对应的某个key,所以它的依赖追踪和触发比较常规,所以正常的value变化后trigger就可以。
然后是对于for..in..,这个情况比较复杂,一个是我们要知道何时追踪,另一方面我们要知道何时触发。对于何时追踪我们刚才已经说过:for..in遍历的时候我们在ownKeys中使用ITERATE_KEY来保存for..in对应的副作用。
对于何时触发副作用函数重新执行,换句话说就是什么情况下应该把ITERATE_KEY对应的副作用拿出来执行,这里可以分析一下:当给代理对象添加新属性或者删除对象原有的属性时,对象上多出来的属性或少了的属性会让for...in的执行次数发生变化,因此我们可以认为,给对象添加新属性时和删除原有的属性会对for..in循环的结果发生变化,所以这时候应该触发对应的ITERATE_KEY对应的副作用重新执行。
先来看添加新属性时的情况,把原来的trigger拿出来改造一下:
const sethandler = function (target, key, newValue, receiver) {
// 根据对象有无该属性判断是修改还是添加
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newValue, receiver)
// 新增第三个参数
trigger(target, key, type)
return res
}
function trigger(target, key, type) {
// 寻找target
const depsMap = targetMap.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectToRun = new Set()
effects &&
effects.forEach((fn) => {
if (fn !== activateEffect) {
effectToRun.add(fn)
}
})
// 'DELETE'的情况后面会说
// 先看'ADD'
if (type === 'ADD' || type === 'DELETE') {
//取出
const iterateEffect = depsMap.get(ITERATOR_KEY)
// 把iterateEffect对应的副作用也添加到effectToRun中
iterateEffect &&
iterateEffect.forEach((fn) => {
if (fn !== activateEffect) {
effectToRun.add(fn)
}
})
}
//为了简化我没写scheduler的情况
effectToRun.forEach((fn) => fn())
}
在这里我抽离了set的“陷阱函数”为setHandler。在setHandler拦截到set操作时时,我们判断对象上是否已经存在该属性来判断操作类型时'ADD'是时'SET',同时在trigger内部,如果判断是'ADD',也就是说新添加了属性,那么就把ITERATE_KEY对应的副作用函数拿出来添加到effectToRun上,执行的时候一块执行。
对于删除对象的属性这种情况,就不能再使用拦截了,应该使用deletePeoperty()这个“陷阱函数”来拦截对对象属性的删除操做:它接受两个参数,target和key。
我们在删除某个属性的时候,一方面该属性产生了变化(类似于set的特殊情况),所以要把这个属性对应的副作用函数拿出来执行,另一方面对象的属性变少,也会影响for..in..,所以也要把对应的ITERATE_KEY副作用函数拿出来执行,所以给trigger传入另外一种type,也就是'DELETE'
const deletePropertyHandler = function (key, value) {
// 自身是否拥有key
const hasOwnProperty = Object.prototype.hasOwnProperty.call(target, key)
const res = Reflect.deleteProperty(target, key)
// 对应上面代码中type === 'DELETE'的情况
trigger(target, key, 'DELETE')
return res
}
封装为reactive:
我们知道Vue3中的reactive()会把对象转换为代理对象,从而实现响应式。我们前面花了很大篇幅讲述如何使用代理来实现响应式的原理,所以这里很自然的,我们把一个对象转化为响应式的功能封装为一个函数:
function reactive(obj) {
return new Proxy(obj, {
// 之前提到的“陷阱函数”
})
}
虽然现在的reacive只实现了对于对象的代理,但是它的原理就是返回一个proxy
所以总结一下:这篇文章我们介绍了一些前置知识,也就是receiver这个参数,然后又介绍了代理对象时的一些特殊情况,也就是for..in.. 和 key in obj,对于前者来说,需要一个特殊的ITERATE_KEY来为target保存对应的ownKeys拦截所收集的副作用函数,触发时根据是否新增加了属性,在set中对应做了判断处理,到底是'SET'操作还是'ADD'操作,但是这还不够,删除属性同样会影响for..in..的结果,所以我们又处理的deleteProperty的代理,这个代理起到了一箭双雕的作用,一方面它可以侦听到Vue中属性的删除,我们知道vue2在侦听对象属性删除是比较无力的,所以vue3的deleteProperty就直接解决了这个问题。另一方面,deleteProperty这个“陷阱函数”还帮助我们处理了for..in..的问题,也就是删除时把ITERATE_KEY对应的副作用函数也拿出来执行。
参考:Vue.js设计与实现