1. 理解Proxy和Reflect
使用Proxy可以创建一个代理对象,允许对被代理对象的基本操作进行拦截并重新定义。
const proxyData = new Proxy(data, {
get(target, key, receiver) {
…
},
set(target, key, newVal, receiver) {
…
}
})
如上代码所示,Proxy对象接受两个参数,一个是data对象,第二个是参数是个对象,这个对象中包含了各种函数,比如其中的get用来拦截数据的读取操作,set用来拦截数据的赋值操作。
在js中函数也是一种对象,函数的调用也可以使用Proxy拦截器中apply函数来拦截。
const proxyFn = new Proxy(fn, {
apply(target, that, arg) {
…
return target.call(that, ...arg)
}
})
理解了Proxy,再来看下Reflect,它是一个全局对象,Proxy拦截器中的方法都能在Reflect中找到同名的方法,其实它就是提供了一个对象操作属性的默认行为。
const d = {
a: 1
}
d.a
Reflect.get(d, 'a')
上述两种读取数据的方式是等价的,那Reflect存在的意义又是什么呢?
其实Reflect.get还可以接收第三个参数receiver,可以理解为函数调用过程中的this,以下这行事例代码读取到的值不是1而是2。
Reflect.get(d, 'a', { a: 2 })
其实Reflect还有更多方面的意义,我们只讨论这个特点,是因为它与响应式数据的实现密切相关。
2. 响应式数据的实现与Reflect的关系
在之前的代码基础上运行以下代码
const origin = {
a: 1,
b: 2,
c: 3,
get d() {
return this.a
}
}
const data = reactive(origin)
effect(() => {
console.log(data.d)
})
data.a++
当data.a改变时副作用函数会响应吗?答案是不会,原因就在于这个this,当Proxy拦截到data.d的get操作时访问了this.a,这个this指向的其实是原始对象origin的a属性,而origin并不是响应式的对象。原因找到了,再来考虑如何解决问题,如果让this.a最终访问到data.a问题不就解决了嘛。Proxy拦截器可接收的参数中有一个receiver,它代表的就是当前的代理对象,再配合Reflect对象的对应方法,将reactive方法稍加改造就ok啦。
const reactive = (data) => {
return new Proxy(data, {
get (target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set (target, key, newValue, receiver) {
const res = Reflect.set(target, key, newValue, receiver)
trigger(target, key)
return res
}
})
}
3. 如何代理object
目前为止我们知道了当响应式对象的属性被读取的时候,会将依赖收集起来,但在js世界中“读取”是个非常宽泛的操作,仅用get函数拦截不到全部。
effect(() => {
console.log('a' in data)
})
比如in操作符,它是不会被get函数拦截住的。但通过对ECMA规范的解读,得知它最终是依赖对象的内部方法HasProperty来实现的,而内部方法HasProperty对应的拦截器函数叫做has,那么就可以通过has方法来拦截。
const reactive = (data) => {
return new Proxy(data, {
get (target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set (target, key, newValue, receiver) {
const res = Reflect.set(target, key, newValue, receiver)
trigger(target, key)
return res
},
has(target, key) {
track(target, key)
return Reflect.has(target, key)
}
})
}
那for … in遍历对象读取其属性的时候应该用什么来拦截呢?根据ECMA规范分析得到结论拦截器的ownKeys方法可以拦截for in遍历读取。但有个问题,ownKeys函数只接收一个target函数,并没有key,而track方法是需要一个key来收集依赖的,那么就需要我们来手动定一个ITERATE_KEY来协助依赖的收集。除此以外,我们还需要trigger(target, ITERATE_KEY),target.ITERATE_KEY永远都不会被重置所以set函数拦截的方式是无法完成的,因此我们可以对trigger函数本身下手。
const ITERATE_KEY = Symbol()
const trigger = (target, key) => {
const targetMap = bucketMap.get(target)
const deps = targetMap.get(key)
const iterate_deps = targetMap.get(ITERATE_KEY)
const functionRun = new Set([...(deps || []), ...(iterate_deps || [])])
functionRun.forEach(f => {
if (f !== activeFuction) {
f.options?.scheduler ? f.options.scheduler(f) : f()
}
});
}
const reactive = (data) => {
return new Proxy(data, {
get (target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set (target, key, newValue, receiver) {
const res = Reflect.set(target, key, newValue, receiver)
trigger(target, key)
return res
},
has(target, key) {
track(target, key)
return Reflect.has(target, key)
},
ownKeys(target) {
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
}
})
}
我们知道track(target, ITERATE_KEY)只会在for in的时候执行,那么只需要在影响for in的操作的时候来执行trigger(target, ITERATE_KEY)就可以了,即属性的增删。
增加的属性实质上触发的就是set函数,我们需要在set函数中使用Object.prototype.hasOwnProperty.call(target, key)来确定当前变动的属性是否为新增的属性就可以了。属性的删除操作delete target.key,用到的是delete操作符,根据EMCA规范的分析我们可以了解到它依赖的是object的内部函数Delete,对应拦截器里的函数也就是deleteProperty。那么我们的reactive和trigger方法就可以稍加改造,变成以下代码:
const ITERATE_KEY = Symbol()
const trigger = (target, key, type) => {
const targetMap = bucketMap.get(target)
const deps = targetMap.get(key)
const functionRun = new Set(deps || [])
if (type === 'ADD' || type === 'DELETE') {
const iterate_deps = targetMap.get(ITERATE_KEY) || []
iterate_deps.forEach(f => {
functionRun.add(f)
})
}
functionRun.forEach(f => {
if (f !== activeFuction) {
f.options?.scheduler ? f.options.scheduler(f) : f()
}
});
}
const reactive = (data) => {
return new Proxy(data, {
get (target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set (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
},
has(target, key) {
track(target, key)
return Reflect.has(target, key)
},
ownKeys(target) {
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
},
deleteProperty(target, key, receiver) {
const res = Reflect.deleteProperty(target, key, receiver)
trigger(target, key, 'DELETE')
return res
}
})
}
4. 合理的触发响应
基于目前的响应式逻辑,只要拦截器set函数被触发就会执行依赖,也就是说只要进行赋值操作就会执行依赖即使值没有改变
const data = reactive({
a: 1
})
effect(() => {
console.log(data.a)
})
data.a = 1
首先能想到的第一步,就是在set中做新旧值的比较,若新旧值不相等则执行trigger方法
const reactive = (data) => {
return new Proxy(data, {
…
set (target, key, newValue, receiver) {
const oldVal = target[key]
const TYPE = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newValue, receiver)
if (oldVal !== newValue) {
trigger(target, key, TYPE)
}
return res
},
…
})
}
NaN比较特殊,若新旧值都为NaN,则oldVal !== newVal的结果是true就会造成不必要的trigger,那么我们需要再加上一个条件oldVal === oldVal || newValue === newValue来过滤新旧值都为NaN的情况。
const originParent = {
a: 1
}
const parent = reactive(originParent)
const originChild = {}
const child = reactive(originChild)
Object.setPrototypeOf(child, parent)
effect(() => {
console.log(child.a)
})
child.a++
但只做这种程度的过滤还远远不够,如果如下代码:
const originParent = {
a: 1
}
const parent = reactive(originParent)
const originChild = {}
const child = reactive(originChild)
Object.setPrototypeOf(child, parent)
effect(() => {
console.log(child.a)
})
child.a++
我们把child的原型设为parant,那么child.a应该会继承parent.a的值即为1,那么child.a++应该会触发副作用函数,实际上也触发了而且触发两次,猜都猜的出child.a的set触发了一次parent.a的set又触发了一次,如何解释这个现象呢?
当要访问的对象没有该属性的时候,会从对象的原型上查找,如果查找到就会调用原型对象get函数从而获得值,而child的原型对象也是一个响应式对象,访问child.a的时候child和parent都建立了依赖关系。而这还是不能解释为啥改动child.a会触发parent.a的set,这其实就是因为Reflect.set(x, x, x)执行了child对象更改a属性的默认行为 即 child无a属性去获得其原型parent对象并调用它的set方法。
问题找到,前边说set方法中接收的参数target和receiver分别代表了key的原始对象和代理对象,当我们访问child.a的时候,child的拦截器中target和receiver分别是originChild和child而parent的拦截器中target和recerver分别是originParent和child。如果能把原始对象和代理对象之间建立某种联系,然后在set方法中验证target和receiver是否对应,若不对应则不触发trigger方法,理论上就能解决了这个问题。
const reactive = (data) => {
return new Proxy(data, {
get (target, key, receiver) {
if (key === 'raw') return target
track(target, key)
return Reflect.get(target, key, receiver)
},
set (target, key, newValue, receiver) {
const oldVal = target[key]
const TYPE = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
const res = Reflect.set(target, key, newValue, receiver)
if (receiver.raw === target) {
if (oldVal !== newValue && (oldVal === oldVal || newValue === newValue)) {
trigger(target, key, TYPE)
}
}
return res
},
has(target, key) {
track(target, key)
return Reflect.has(target, key)
},
ownKeys(target) {
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
},
deleteProperty(target, key, receiver) {
const res = Reflect.deleteProperty(target, key, receiver)
trigger(target, key, 'DELETE')
return res
}
})
}
在拦截器get方法内,给receiver手动加一个属性,比如raw,当访问data.raw的时候得到的是原始对象target。然后在set方法内验证receiver的原始对象与target是否一致,如果一致才去触发trigger函数。
5. 深度响应
基于目前的代码,我们也只是做到了浅响应,如果有以下的代码,则副作用函数将不会按照预期的执行。
const data = reactive({
a: {
b: 1
}
})
effect(() => {
console.log(data.a.b)
})
data.a.b = 2
先来看目前的拦截器get方法代码:
get (target, key, receiver) {
if (key === 'raw') return target
track(target, key)
return Reflect.get(target, key, receiver)
}
当我们访问data.a的时候,得到的是{b: 1}而它并不是一个响应式对象,那么我们把它也变成响应式对象不就可以了嘛。
get (target, key, receiver) {
if (key === 'raw') return target
track(target, key)
if (typeof target[key] === 'object' && target[key] !== null) return reactive(target[key])
return Reflect.get(target, key, receiver)
},