Vue3: 什么是computed的懒更新?不就一个问题的事!

1,428 阅读7分钟

我问大佬:"什么是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,打印:computedtrue
  • step4: check.value值变化,fn触发,打印: computedtrue
  • step5: r1.value值变化,但当前上下文c不依赖r1.value,无打印

总结起来:当computed结果未被使用,还没它什么事,即使fn中的依赖项有更新,fn也不会执行。当computed结果被使用,但fn中的依赖项未命中当前上下文,fn也不会被执行,例如执行step5时,当前上下文仅依赖check.value、r2.value,由于r1.value未被依赖,即使其值更新了,fn也不会再执行。

提出两个问题:

  1. 为什么computed(fn)结果没被使用,依赖项更新,fn也不会执行
  2. 在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.fnthis.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依赖关系图如下:

image.png

假如现在调用的是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为空,因此直接将Sub1headtail都指向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函数处理。

image.png

在上一篇《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一致的版本。

image.png

回到refreshComputed函数,在函数体结束有这么一段代码:

  try {
    prepareDeps(computed)
    const value = computed.fn()
    if (dep.version === 0 || hasChanged(value, computed._value)) {
      computed._value = value
      dep.version++
    }
  } finally {
    cleanupDeps(computed)
  }

prepareDepscleanupDeps两函数成的作用是什么? 现在将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更新版本,此时链表图如下: image.png

对比上一版link结构,Link22的version为-1,新增了Link33节点。为什么Link22的version为-1?因为r1不在被c依赖,所以对应的版本就不再更新了。

现在,大家应该能猜到cleanupDeps函数的作用:重新遍历Sub1的链表,如果节点中有version为-1的,则直接从链表中移出,由于是双向链表,所以移出操作也是so easy。cleanupDeps执行完后,链表最新结构如下:

image.png

到此,c的依赖项就动态调整完毕,也就回答了compute(fn)如何动态收集依赖项问题。

总结

通过对computed函数执行过程的分析,不仅了解到computed版本是如何执行懒更新的,也加深了双向链表在在Vue中运用的理解。相比于Vue3.5之前的通过两个集合来维护依赖关系,双向链表的结构更加简洁、直观, 更快,内存占用更少。

我是前端下饭菜,原创不易,各位看官动动手,帮忙关注、点赞、收藏、评轮!