我问大佬:"什么是computed的懒更新?"
大佬说:"贵在懒字,没事不更新,有事也少管。"
听后我醍醐灌顶,竖起大拇指:"大佬,我悟了!"
马上就写了个demo:
// step1
const c = computed(() => {
console.log('computed')
if (check.value) {
console.log('true')
return r1.value
} else {
console.log('false')
return r2.value
}
})
// step2
r1.value++
// step3
effect(() => c.value)
// step4
check.value = false
// step5
r1.value++
打印结果:
computed
true
computed
false
为什么?我们就按步骤来分析:
- step1: 声明了computed,fn不执行,无打印
- step2: r1.value更新,但
c
未被使用,无打印 - step3: 在effect中读取
c.value
,打印:computed
、true
- step4: check.value值变化,fn触发,打印:
computed
、true
- step5: r1.value值变化,但当前上下文c不依赖
r1.value
,无打印
总结起来:当computed结果未被使用,还没它什么事,即使fn中的依赖项有更新,fn也不会执行。当computed结果被使用,但fn中的依赖项未命中当前上下文,fn也不会被执行,例如执行step5时,当前上下文仅依赖check.value、r2.value,由于r1.value未被依赖,即使其值更新了,fn也不会再执行。
提出两个问题:
- 为什么computed(fn)结果没被使用,依赖项更新,fn也不会执行?
- 在computed(fn)如何动态收集依赖项?例如将check.value置为false,依赖项变为check、r2。
为了回答这两个问题,我们就从computed函数本身出发,琢磨琢磨Vue是如何实现的computed。
上一篇《Vue3: computed都懒更新了,version计数你还不知道?》部分同学反馈有些复杂,本篇内容可以作为其补充讲解。
为什么computed(fn)结果没被使用,fn拒绝执行?
computed函数签名:
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
...
): ComputedRefImpl
getterOrOptions可以为ComputedGetter<T>或者是WritableComputedOptions<T>:
- ComputedGetter<T>为我们经常使用的形式,如
computed(() => c.value)
; - WritableComputedOptions<T>类型支持设置值,例如:
const c = computed({
get: () => {
return r1.value
},
set: (newVal: number) => {
r1.value = newVal
}
})
computed函数体代码非常简单,就简单几行:
if (isFunction(getterOrOptions)) {
getter = getterOrOptions
} else {
getter = getterOrOptions.get
setter = getterOrOptions.set
}
const cRef = new ComputedRefImpl(getter, setter, isSSR)
return cRef as any
先判断入参类型,如果为函数,则fn即为回调类型。否则,分别获取getter和setter,接下来创建类型为ComputedRefImpl
的实体,并将其返回。
使用时,通过c.value
获取值,所以猜测ComputedRefImple包含value属性,如果setter
不为空,则还可以通过c.value = 10
形式对其赋值。
接下来就进入computed的核心ComputedRefImple
Class的定义,尝试找出问题答案,其构造函数如下:
export class ComputedRefImpl<T = any> implements Subscriber {
constructor(
public fn: ComputedGetter<T>,
private readonly setter: ComputedSetter<T> | undefined
) {
this[ReactiveFlags.IS_READONLY] = !setter
}
}
其实,构造函数啥也没做,就将fn
(getter)、setter
挂到class的属性上,后续可以通过this.fn
、this.setter
访问。
那到此,computed(fn)
的声明就结束,整个过程没看到调用fn,所以接下来即使我更新fn中的依赖项r1.value++
,fn也不会执行。 这也就不回答了"为什么computed(fn)结果没被使用,fn拒绝执行"。
什么时候fn第一次被执行?那就是c.value第一次被引用时。 例如demo中在effect(() => c.value)
会触发fn执行。
正如我们在上文的猜测,ComputedRefImpl
确实包含value属性,并支持get和set:
get value(): T {
...
return this._value
}
set value(newValue) {
if (this.setter) {
this.setter(newValue)
}
}
当执行effect(() => c.value)
时,会调用value的get函数,那get
函数包含什么逻辑?
get value(): T {
// step1
const link = this.dep.track()
// step2
refreshComputed(this)
// step3
if (link) {
link.version = this.dep.version
}
return this._value
}
假如现在执行effect(() => c.value)
, 第一次调用c.value, 那么get value()
的逻辑:
- step1: effect作为
c
的Sub(订阅者),this.dep.trace作用是将computed作为Dep,并将其添加到Sub的依赖链表,这样当computed值有变化,effect能接受到通知。 - step2: 执行refreshComputed(this),根据computed(fn)的fn函数计算最新值,并通知订阅者
- step3: 将版本和依赖的Dep(如r1.value)的保持一致
以下述代码为demo,接下来就step1、step2过程做深入分析。
const r1 = ref(0)
const r2 = ref(0)
const c1 = computed(() => r1.value + r2.value)
const c2 = computed(() => r2.value - r1.value)
effect(() => {
c1.value
c2.value
})
this.dep.track()
this.dep.trace作用是将computed作为Dep,并将其添加到Sub的依赖链表上。 上述demo的Sub(订阅者)、Dep依赖关系图如下:
假如现在调用的是c1对应的this.dep.track
, 首次获取c1.value
时,link肯定为空,如下代码,会初始化一个链表节点link,对应上图的Link11
。注意代码中的activeSub
,其实就是上图的Sub1
。
track(): Link | undefined {
let link = this.activeLink
if (link === undefined || link.sub !== activeSub) {
link = this.activeLink = {
dep: this,
sub: activeSub,
version: this.version,
nextDep: undefined,
prevDep: undefined,
nextSub: undefined,
prevSub: undefined,
prevActiveLink: undefined,
}
...
}
...
}
现在Dep1
对应链表Link11
已生成,那如何将其挂在到Sub1
对应的依赖链表节点上? 如下代码中,activeSub
的deps为链表head
,只不过初始化时head为空,因此直接将Sub1
的head
和tail
都指向Link11
,有head = tail = Link11
。
if (link === undefined || link.sub !== activeSub) {
...
// add the link to the activeEffect as a dep (as tail)
if (!activeSub.deps) {
// deps为空
activeSub.deps = activeSub.depsTail = link
} else {
// deps不为空
link.prevDep = activeSub.depsTail
activeSub.depsTail!.nextDep = link
activeSub.depsTail = link
}
}
head不为空链表的情况:如果Sub1
已经订阅了c1,其deps为Link11
, 此时要订阅c2的Link12
, 则将Sub1
的尾节点的nextDep指向Link12
,并做链表更新。此时Sub1
的deps变为Link11 -> Link12
。
由于是双向链表,从head到tail可通过nextDep遍历,从tail到head可通过prevDep遍历。
到此,computed就将自己的link挂到了所有订阅者的链表上。接下来就得考虑computed本身有哪些依赖,以及触发更新。
refreshComputed(this)
通过track
函数,成功将c1、c2添加到effect的依赖链表上了,接下来c1、c2本身就得考虑计算新结果,并通知effect更新,这些逻辑统统交给refreshComputed
函数处理。
在上一篇《Vue3: computed都懒更新了,version计数你还不知道?》 有对refreshComputed
详细介绍,可搜索关键字refreshComputed
查看相关部分。refreshComputed
要干的事就俩:作为订阅者判断依赖的r1或r2有没更新,执行fn重新计算new value并通知订阅者(Sub1)。
c1、c2在收集依赖时,类似于effect,此时c1、c2也作业订阅者,生成各自的依赖链表:
- Sub3指向Link33->Link34
- Sub2指向Link23->Link24
这样computed也就把依赖项都添加到自己的链表上,接下来等通知(r1、r2有更新)就行。
compute(fn)如何动态收集依赖项?
我们就以如下demo来讲解"compute(fn)如何动态收集依赖项"。
const c = computed(() => {
console.log('computed')
if (check.value) {
console.log('true')
return r1.value
} else {
console.log('false')
return r2.value
}
})
假如已经执行过c.value
,当前的链表结果如下,Sub1的link为: Link11 -> Link22,并且链表节点都对应和Dep一致的版本。
回到refreshComputed
函数,在函数体结束有这么一段代码:
try {
prepareDeps(computed)
const value = computed.fn()
if (dep.version === 0 || hasChanged(value, computed._value)) {
computed._value = value
dep.version++
}
} finally {
cleanupDeps(computed)
}
prepareDeps
和cleanupDeps
两函数成的作用是什么? 现在将demo中的check.value设置为false,refreshComputed
会重新执行。
当执行prepareDeps
时,会将Sub1
链表上每个节点的version重置为-1
,Sub1的link为: Link11(version:-1) -> Link22(version:-1)。
当执行computed.fn时,check.value为false,则r2
的Dep也会加入到链表中来,并且将依赖项的对应的link更新版本,此时链表图如下:
对比上一版link结构,Link22的version为-1,新增了Link33节点。为什么Link22的version为-1?因为r1不在被c
依赖,所以对应的版本就不再更新了。
现在,大家应该能猜到cleanupDeps
函数的作用:重新遍历Sub1
的链表,如果节点中有version为-1的,则直接从链表中移出,由于是双向链表,所以移出操作也是so easy。cleanupDeps
执行完后,链表最新结构如下:
到此,c
的依赖项就动态调整完毕,也就回答了compute(fn)如何动态收集依赖项
问题。
总结
通过对computed函数执行过程的分析,不仅了解到computed版本是如何执行懒更新的,也加深了双向链表在在Vue中运用的理解。相比于Vue3.5之前的通过两个集合来维护依赖关系,双向链表的结构更加简洁、直观, 更快,内存占用更少。
我是
前端下饭菜
,原创不易,各位看官动动手,帮忙关注、点赞、收藏、评轮!