我们知道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
类和triggerEffec
t函数了
修改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