Vue3硬核源码解析系列(7)有点难的computed源码解析

前言

写过vue的同学,写过computed的都知道,computed会在依赖属性发生变化的时候自动更新结果。

他有一个重要的特点:计算值是可缓存的,只有依赖项发生变化的时候,才会重新计算

而通过之前的文章,我们已经了解了reactiveref的实现原理,相信大家已经对vue3响应式机制有所了解,今天我们就来了解一下computed是如何实现的。

注:computed的源码难度相当大,我会尽力描述清楚其实现原理,如有不足之处,还请见谅

案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const obj = reactive({
name: '张三'
})

const showName = computed(() => {
return '我叫' + obj.name
})

effect(() => {
document.querySelector('#app').innerText = showName.value
})

setTimeout(() => {
obj.name = '李四'
}, 2000)

​ 以上代码运行后,我们可以看到如下现象

  • 页面显示:我叫张三
  • 2s后,页面显示我叫李四

按照我们之前源码分析的思路,我们依旧从以下三个角度入口

  • 初始化
  • 读取(依赖收集)
  • 赋值(依赖触发)

接下来就让我们走进computed的源码世界吧~

computed初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 入口函数
export function computed<T>(getterOrOptions) {
let getter;
let setter;
// 传入的是否是一个方法
const onlyGetter = isFunction(getterOrOptions)
if (onlyGetter) {
// 如果是方法, 则直接赋值到getter, 同时屏蔽setter行为
getter = getterOrOptions
// dev环境下 set函数给予提示
setter = __DEV__
? () => {
console.warn('Write operation failed: computed value is readonly')
}
: NOOP
} else {
// 如果不是方法,则认为是对象,将对象中的get set分别赋值到getter setter中
getter = getterOrOptions.get
setter = getterOrOptions.set
}
const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR)
return cRef
}

​ 入口函数的逻辑还是非常简单的,如果传入的是一个匿名函数,这处理为getter,如果传入的是对象,这赋值getter setter,这部分逻辑符合我们对这个API的使用习惯,也解释了computed为何是这样的传参方式。

​ 抹平两种传参方式的差异后,new ComputedRefImpl,并返回,所以computed = new ComputedRefImpl ,我们接下来就进入该Class中看看吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 计算属性的响应式也是通过class get set去实现的
export class ComputedRefImpl<T> {
public dep?: Dep = undefined // 依赖收集处(effect)

private _value!: T // 存储计算属性结果的值
public readonly effect: ReactiveEffect<T> // 存储依赖
public readonly __v_isRef = true // 所有的计算属性也会被识别为ref
public _dirty = true // 判断是否需要重新计算

constructor(
getter: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean, // 是否只读,如果存在setter,则为false
) {
// 将计算属性的识别为effect,初始化一个ReactiveEffect
// 初始化阶段仅仅声明 但是却没有触发
this.effect = new ReactiveEffect(getter, () => {
// 脏变量(_dirty)的本质就是判断什么时候去触发依赖
// 脏变量为false的时候才会触发
if (!this._dirty) {
this._dirty = true
// 触发依赖
triggerRefValue(this)
}
})
this.effect.computed = this // 赋值ReactiveEffect中的computed为当前this
}

get value() {}
set value(newValue: T) {}
}

​ 在ComputedRefImpl初始化阶段,我们看到了非常熟悉的api,ReactiveEffect,在我们的前面的reactive,ref源码分析中,我们使用这个api来完成关键步骤依赖收集,不过这里有些区别,传入了第二个参数,一个匿名函数,目前还无法体现其作用,我们后面再说

​ 总的来说,ComputedRefImpl初始化阶段,生成了一个ReactiveEffect并保存到当前类的effect变量中。

依赖收集

按照我们实例代码,首次访问effect初次执行的时候,我们会触发showName.valueget,也就是说,会触发ComputedRefImplget

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 被读取的时候触发
get value() {
// 依赖收集
trackRefValue(this)
// 判断是否需要更新,如果需要则进入函数
if (this._dirty) {
// 如果更新过,这下一次就不需要更新了,
this._dirty = false
// effect的run执行,也就是执行computed的fn,将会得到一次计算属性的结果
this._value = this.effect.run()!
}
// 返回computed的结果
return this._value
}

export function trackRefValue(ref: RefBase<any>) {
// 首次computed内部的dep是不存在的,会通过createDep生成一个Set
trackEffects(ref.dep || (ref.dep = createDep()))
}

export function trackEffects(dep: Dep) {
// 将activeEffect,此时是effect的fn,收集到computed的dep中
dep.add(activeEffect!)
}

​ 当我们触发computedget的时候,首先会触发trackRefValue,将当前activeEffect收集到ComputedRefImpl的dep中,这正是依赖收集,这里effect被收集到了computed的dep中,建立起了computed与其被依赖项(effect)的联系

​ 然后判断**_dirty是否为true,默认是true,所以进入判断中,首先将_dirty改为false,下一次则不会进入判断,直接返回computed**之前的结果,之后再执行computed初始化阶段声明的ReactiveEffect,也就是我们computed本身的effect。

computedeffect.run一旦触发,全局activeEffect将会被替换为当前computed的 effect的fn ,并且触发computed依赖项obj.nameget,进而触发proxy的依赖收集,于是obj.name成功收集到了computed内部的effectproxy与computed建立了联系,同时返回了最新的computed结果。

computed的get行为触发的时候,我们发现computed收集了effect,reactive收集了computed,三者之间建立起了联系。

关于_dirty

​ 现在我要个大家着重讲一下ComputedRefImpl中的这个参数,_dirty是实现计算属性缓存性的关键所在,

我们假设一下,没有缓存性的computed,是什么样的运行逻辑

计算属性依赖了变量abc,并返回abc的总和,每个获取计算属性的时候,我都需要计算一次abc的总和,即使abc这三个值没有发生任何变化,就是这样的

依赖触发

2s后,我们触发了obj.nameset,所以首先触发obj.name的依赖触发,此时我们将可以通过WeakMap会找到之前收集到computed,我们直接进入依赖触发的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
let deps: (Dep | undefined)[] = []
deps.push(depsMap.get(key))
triggerEffects(deps[0]) // 找到了之前收集到的computed中的effect
}
// 按照常理来说,我们找到指定依赖之后,就是触发依赖,但是计算属性有所不同,因为计算属性存在“调度器”
// 还记得computed初始化阶段,new ReactiveEffect传递的第二个参数吗?
// 该参数将会被保存到ReactiveEffect的scheduler(调度器)中
// 所以此时的ReactiveEffect中,fn是computed的匿名函数,scheduler是computed初始化阶段new ReactiveEffect的第二个参数

export function triggerEffects(dep: Dep | ReactiveEffect[]) {
const effects = isArray(dep) ? dep : [...dep]
for (const effect of effects) {
triggerEffect(effect, debuggerEventExtraInfo)
}
}

function triggerEffect(effect: ReactiveEffect) {
// 调度器的优先级大于run,所以此时会执行调度器逻辑
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}

// 调度器代码
this.effect = new ReactiveEffect(getter, () => {
// 还记得我们get之后将dirty改为false吗?
// 此时computed的依赖发生变化,将_dirty改为true,表示下次重新计算
if (!this._dirty) {
this._dirty = true
// 触发当前computed中收集了相关effect(依赖触发)
triggerRefValue(this)
}
})


export function triggerRefValue(ref: RefBase<any>, newVal?: any) {
// 公共依赖触发逻辑
triggerEffects(ref.dep)
}

// computed的dep中收集的effect触发,再次触发computed的get
get value() {
// 依赖项发生变化的时候activeEffect不存在,所以此处收集不到任何依赖
trackRefValue(this)
// 刚才依赖项发生了变化,所以dirty为true,表示本次需要更新计算属性的结果
if (this._dirty) {
// 计算后dirty改为false 除非依赖项发生变化,否则将不会再重新计算。
this._dirty = false
// 重新计算 computed的结果
this._value = this.effect.run()!
}
return this._value
}

​ 计算属性的触发逻辑还是非常复杂的,首先proxy的set,触发computedscheduler(调度器)scheduler通过computeddep找到相关effecteffect的fn执行又会触发computedget并与首次完成computed的计算,同时缓存最新的computed的结果,进而再完成effect的全部逻辑。

代码执行流程

依赖收集阶段

  1. computed初始化阶段,通过ReactiveEffect进行初始化,并且生成scheduler(调度器)
  2. effect初始化,触发computedget,将当前activeEffect(effect)收集到computeddep(computed将effect收集)
  3. 执行computed自身逻辑,刷新全局activeEffect
  4. 进而触发proxyget事件触发,将当前activeEffect(computed)收集到WeakMap(proxy将computed收集)
  5. proxy的返回值返回computed,完成computed的计算逻辑
  6. 获取到computed结果,完成effect

依赖触发阶段

  1. 触发proxysetset行为中触发依赖,触发之前保存的computed调度器scheduler(proxy找到computed)
  2. 调度器scheduler触发,dirty改为true,同时触发computed中保存的依赖,其中都是相关effecfn。(computed找到effect)
  3. effect触发,fn执行,触发computedget行为
  4. dirtytrue,首次进行计算属性的重新计算(除非依赖项改变,否则下次不会重新计算),返回最新的computed结果,
  5. effect执行完成

回答一些问题

computed如何实现高性能缓存的?

​ 通过调度器scheduler + 脏值检查_dirty,实现依赖项不变化,不进行重新计算,依赖项变化后仅执行一次的逻辑,进而实现高性能缓存。

为什么访问computed需要.value

​ 因为我们访问computed实际上是访问ComputedRefImpl这个Class的实例,他的内部通过get value返回被访问值,所以我们必须通过**.value**来访问

简述computed的实现原理?

vue的响应式api都可以从依赖收集 依赖触发2个角度出发阐述其原理实现

依赖收集阶段:computed通过首次get的完成相关effect的依赖收集,首次计算的时候proxy完成computed的依赖收集。

依赖触发阶段:computed的依赖项发生变化后,会通过proxy找到computed的调度器 scheduler,触发所有effect,effct中再出发computed的get,首次get将进行一次结果运算(后续不在运算,除非computed依赖项发生变化),effect触发完成

总结

​ 到此为止,我们computed的核心源码就解读完毕了,虽然总体依旧可以从依赖收集依赖触发两个角度去理解实现原理,但是新增加的scheduler(调度器)与**_dirty(脏值检查)**机制,让逻辑复杂了很多。

​ 大家在理解computed源码的时候,一定要多走几遍流程,多捋几遍逻辑。