我们知道vue的响应式API有这么一个函数computed,它接收一个有返回值的回调函数作为参数,返回一个只读的响应式的ref对象,该ref对象通过.value暴露回调函数的返回值。而且它还具有缓存数据的能力。那么vue是通过什么操作赋予computed函数这些能力的呢?让我们通过实现一个简易的computed函数来解释隐藏在这些能力背后的逻辑吧。(部分已经完成的响应式API在我前面发布的文章中可以找到)
构建ComputedRefImpl实例对象
ts
export class ComputedRefImpl<T> {
// 存储传入的回调函数的返回值
private _value:<T>
// dep是执行函数的集合Set
dep?: Dep = undefined
// 给实例对象做标记
public readonly __v_isRef = true
// 通过构造器传入的
public readonly effect:ReactiveEffect<T>
constructor(getter){
// 将getter函数转化为ReactiveEffect实例
this.effect = new ReactiveEffect(getter)
// 通过给ReactiveEffect实例增加computed属性和RefImpl类进行区分
this.effect.computed = this
}
get value() {
// 收集依赖(使用的是和ref一样的方法):将执行函数收集到dep属性中,
trackRefValue(this.dep)
// 返回回调函数的返回值
this._value = this.effect.run()
return this._value
}
}
ComputedRefImpl这个类是computed函数对外暴露数据的关键。从这里就可以看得出来computed和ref很类似,都是通过返回一个类ref实例,通过get和set标记value方法,从而通过.value的形式暴露响应式数据。但是不同之处在ComputedRefImpl接收的是一个需要有返回值的getter函数。
computed函数
ts
export function computed(getterOrOptions) {
let getter
// 判断参数是否为函数
const onlyGetter = isFunction(getterOrOptions)
if (onlyGetter) {
// 只有在确认参数为函数的情况下才能赋值
getter = getterOrOptions
}
// 生成 ComputedRefImpl 实例并返回
const cRef = new ComputedRefImpl(getter)
return cRef
}
const isFunction = (val: unknown): val is Function => {
return typeof val === 'function'
}
在computed函数中只做两件事,一个是确保传入的参数是function,另一个就是生成ComputedRefImpl实例对象并返回实例对象。此时实例对象就可以读取到值了,接下来就是构建响应性的环节了。
构建computed的响应性
根据之前的reactive函数和ref函数的响应性原理可以知道,响应性的核心在于收集依赖 和触发依赖 ,现在我们已经在set标记的value方法中收集过依赖了,接下来就是触发依赖的环节了。
在ComputedRefImpl类中处理_dirty和scheduler
ts
export class ComputedRefImpl<T> {
...
// 当dirty为false时,表示需要触发依赖,为true时表示需要重新执行一次effect更新_value
public _dirty: boolean = true
constructor(getter) {
// effect是传入的回调函数生成一个ReactiveEffect实例
// 第二个参数传入的是代替原本的effect函数执行的scheduler
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
// 转换_dirty状态,表示数据需要更新了
this._dirty = true
// 触发依赖
triggerRefValue(this)
}
})
// 在生成的ReactiveEffect实例中添加新属性computed表示当前实例为ComputedRefImpl类型
this.effect.computed = this
}
get value() {
// 收集依赖,和ref函数同样的收集依赖方法
trackRefValue(this)
// 检查_dirty状态判断是否需要重新获取数据,默认是需要run一遍的
if (this._dirty) {
// 转换_dirty状态,表示数据更新完成了
this._dirty = false
// 重新运行一遍传入的回调函数更新数据
this._value = this.effect.run()
}
return this._value
}
}
这一步很关键,这里表明了computed与ref在收集和触发依赖上存在的不同之处。ref是在对实例对象value方法的get操作中收集依赖 ,set操作中触发依赖 。而computed因为传入的是一个函数,不能进行set操作,因此它的触发依赖 同样需要在get中进行,因此我们需要一个标记来判断什么时候应该触发依赖,什么时候不能。这个标记就是_dirty,可以这么理解:当_dirty为true的时候(这也是默认值),表示数据"有污点",需要更新,那么就会重新运行一遍回调函数并更新_value值,同时修改_dirty为false;this.effect.run()这里的运行会触发上面生成实例时传入的scheduler,当_dirty为false的时候,就可以触发依赖 了(这里的依赖是另一个effect,也就是使用到这个对象实例的值的effect函数)。
接下来就可以根据这里的思路去改造之前的ReactiveEffect类和triggerEffect函数了
修改ReactiveEffect
ts
export type EffectScheduler = (...args: any[]) => any
/**
* 响应性触发依赖时的执行类
*/
export class ReactiveEffect<T = any> {
/**
* 存在该属性,则表示当前的 effect 为计算属性的 effect
*/
computed?: ComputedRefImpl<T>
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null
) {}
...
}
修改triggerEffect函数
ts
/**
* 触发指定依赖
*/
export function triggerEffect(effect: ReactiveEffect) {
// 存在调度器就执行调度函数
if (effect.scheduler) {
effect.scheduler()
}
// 否则直接执行 run 函数即可
else {
effect.run()
}
}
到这里,computed的响应性就算完成了。但是我们还没有解释computed缓存能力的来源。接下来就开始实现computed的缓存性。
构建computed缓存能力
html
<script>
const { reactive, computed, effect } = Vue
const obj = reactive({
name: '张三'
})
const computedObj = computed(() => {
console.log('计算属性执行计算');
return '姓名:' + obj.name
})
effect(() => {
document.querySelector('#app').innerHTML = computedObj.value
document.querySelector('#app').innerHTML = computedObj.value
})
setTimeout(() => {
obj.name = '李四'
}, 2000);
</script>
我们希望在effect函数中的computed.value可以只触发一次内部的回调函数(也就是将第一次计算的数据缓存),加上声明计算属性时时自动触发的一次,在控制台应该只会打印两次"计算属性执行计算"。
现在试着将这段代码运行起来,我们会发现它会无限循环打印,说明计算属性一直在执行计算,这是什么原因呢?
追踪计算属性的触发依赖
-
在
track方法处增加断点,延迟两秒后进入断点,此时obj.name的赋值会触发计算属性依赖 ,在triggerEffects函数中(此时的effect为ComputedRefImpl实例对象的effect,也就是computed函数传入的回调函数)判断为ComputedRefImpl实例对象,则运行scheduler调度器函数。 -
进入到
scheduler中,如果_dirty为false(此处满足条件)则触发ComputedRefImpl实例对象的依赖 -
再次进入
triggerEffects函数(此时effect为ComputedRefImpl实例对象的dep,也就是触发实例对象get value方法的effect函数),由于effect函数内有多次调用ComputedRefImpl实例对象get value方法,导致收集依赖时将自身effect也收集到dep中。
-
依次触发依赖的第一个是非计算属性的
effect函数,所以会直接执行run方法,而在effect函数中存在两次调用ComputedRefImpl实例对象get value方法(computedObj.value)tsget value() { // 收集依赖,和ref函数同样的收集依赖方法 trackRefValue(this) // 检查_dirty状态判断是否需要重新获取数据,默认是需要run一遍的 if (this._dirty) { // 转换_dirty状态,表示数据更新完成了 this._dirty = false // 重新运行一遍传入的回调函数更新数据 this._value = this.effect.run() } return this._value }- 第一次调用:
- 进入
computed的get value - 收集依赖
- 检查
_dirty状态,可以执行this.effect.run()(运行原本的计算属性函数而非调度器) - 获取最新值,返回
- 进入
- 第二次调用:
- 进入
computed的get value - 收集依赖
- 检查
_dirty状态,此时上一次get value中将_dirty赋值为false,不能再执行this.effect.run() - 获取上一次的值,返回
- 进入
- 第一次调用:
-
从这里开始,进入
triggerEffects函数的第二次循环,第二次循环的依赖是computed的effect,因此会被triggerEffect识别出来,运行effect.scheduler()。还记得第一步吗?第一步的开头就是执行effect.scheduler(),死循环就开始了。
解决死循环的方法
ts
export function triggerEffects(dep: Dep) {
// 通过 dep 获取执行函数组成的数组
const effects = isArray(dep) ? dep : [...dep]
// 先触发所有的计算属性依赖,再触发所有非计算属性依赖
for (const effect of effects) {
if (effect.computed) {
triggerEffect(effect)
}
}
for (const effect of effects) {
if (!effect.computed) {
triggerEffect(effect)
}
}
}
参考vue的源码中,triggerEffects触发依赖是按照先触发计算属性依赖,再触发非计算属性依赖的顺序,尝试修改成上述代码再跟踪一遍执行过程。
-
和上面的第一到第三步相同,在第二次进入
triggerEffects之后才开始不同之处。 -
此时进入
triggerEffects中如预想的一样先触发了计算属性依赖(也就是effect.scheduler()),但是_dirty此时为true(原因是在第一步中已经运行过一次调度器),因此这一次的调度器函数直接跳过。 -
此时进入
triggerEffects的第二次循环,这一次触发非计算属性的effect函数,同样也是触发两次get value方法tsget value() { // 收集依赖,和ref函数同样的收集依赖方法 trackRefValue(this) // 检查_dirty状态判断是否需要重新获取数据,默认是需要run一遍的 if (this._dirty) { // 转换_dirty状态,表示数据更新完成了 this._dirty = false // 重新运行一遍传入的回调函数更新数据 this._value = this.effect.run() } return this._value }- 第一次调用:
- 进入
computed的get value - 收集依赖
- 检查
_dirty状态,可以执行this.effect.run()(运行原本的计算属性函数而非调度器) - 获取最新值,返回
- 进入
- 第二次调用:
- 进入
computed的get value - 收集依赖
- 检查
_dirty状态,此时上一次get value中将_dirty赋值为false,不能再执行this.effect.run() - 获取上一次的值,返回
- 进入
- 第一次调用:
-
至此代码逻辑结束,死循环不再发生,而"计算属性执行计算"也只被打印了两次,成功构建缓存能力。
从这里可以看出缓存能力是基于_dirty状态控制的。当计算属性关联的响应式数据发生变化时,触发effect.scheduler(),在调度器中触发的依赖会先触发计算属性依赖effect.scheduler(),此时触发的依赖就会被跳过(因为上一次的状态还没有调整过来,需要get value的调整)。然后触发effect函数,进入两次get value,而get value同样只能执行第一次的effect.run()。也就是说在不触发effect.scheduler()的情况下无论多少次的get value都只会执行第一次,而effect.scheduler()的执行取决于computed函数中涉及到的响应性数据,因为它们建立了和computed的依赖关系。
总结
- 计算属性的实例,本质上是一个
ComputedRefImpl的实例 ComputedRefImpl中通过_dirty属性来控制run的执行和scheduler的触发- 想要访问计算属性的值,必须通过
.value因为它内部和ref一样是通过get value来进行实现的 - 每次
.value时都会触发trackRefValue即:收集依赖 - 在依赖触发时,需要谨记,先触发
computed的effect,再触发非computed的effect