Vue3版本计数实现依赖关系的增量更新

79 阅读5分钟

什么是依赖关系的增量更新?考虑下面的代码

const isValid = ref(true)
const count1 = ref(0)
const count2 = ref(0)
watchEffect(() => {
    if(isValid.value) {
        console.log(count1.value)
    }else {
        console.log(count2.value)
    }
})

从上面的代码可以看到,当isValid === true时,此时有两个link

  • link1: isValid-watch
  • link2: count1-watch

当isValid变更为false时,此时还是有两个link

  • link1: isValid-watch
  • link3: count2-watch

可以看到,当isValid变更时,需要将link2从原来的链表中删除,并且将link3加到链表中。版本计数的主要作用就是实现这个变更链表的逻辑。

为什么要删除link2?当count1不再与watch有关联,那么count1变更时就不要再执行watch,减少不必要的副作用函数执行。

整个版本计数的逻辑中涉及到的version主要有四个:

  • Dep.version
  • Link.version
  • 全局的globalVersion
  • Computed.globalVersion

前置知识:new Link时,构造函数初始化link.version = dep.version。

export class Link {
    constructor(
        public sub: Subscriber,
        public dep: Dep,
    ) {
        this.version = dep.version
    }
}

从实际代码出发,当isValid被变更为false时,触发响应式数据的setter拦截:

// packages/reactivity/src/ref.ts
class RefImpl {
    set value(newValue) {
        this.dep.trigger()
    }
}

// packages/reactivity/src/dep.ts
trigger(debugInfo?: DebuggerEventExtraInfo): void {
    this.version++
    globalVersion++
    this.notify(debugInfo)
}

此时dep.version+1,globalVersion+1,接着调用notify。

// packages/reactivity/src/dep.ts
notify(debugInfo?: DebuggerEventExtraInfo): void {
    startBatch()
    try {
      for (let link = this.subs; link; link = link.prevSub) {
        if (link.sub.notify()) {
          ;(link.sub as ComputedRefImpl).dep.notify()
        }
      }
    } finally {
      endBatch()
    }
}

上述代码包裹在startBatch和endBatch之间,这里面使用了一个batchDepth的概念,主要作用是考虑下面的场景:

statBatch() // batchDepth = 1
    statBatch() // batchDepth = 2
        statBatch() // batchDepth = 3
        endBatch() // batchDepth = 2
        statBatch() // batchDepth = 3
        endBatch() // batchDepth = 2
    endBatch() // batchDepth = 1
endBatch() // batchDepth = 0

当出现上面的场景时,延迟endBatch的逻辑,只有最外围的endBatch才开始正式的逻辑,点进endBatch可以看到 if (--batchDepth > 0) { return }

目前出现嵌套batchDepth的场景主要是嵌套computed,考虑以下场景:

const count1 = ref(0)
const c1 = computed(() => {
    return count1.value + 1
})
const c2 = computed(() => {
    return c1.value + 1
})
const c3 = computed(() => {
    return c2.value + 1
})
const c4 = computed(() => {
    return c3.value + 1
})
function increase() {
    count1.value++
}

出现这样的场景就会将c1,c2,c3,c4都添加到batchedSub后才会执行endBatch逻辑。

startbatch后循环当前的所有sub并执行其notify函数,notify函数的作用是将当前subs链表上的内容添加到batchedSub这个链表中,batchedSub是全局的,并且考虑上述batchDepth的情况,batchedSub接入的不止一个响应式数据的subs。

如果notify返回true,说明执行的是ComputedRefImpl.notify,也就是当前sub是一个computed。执行ComputedRefImpl.notify会将当前ComputedRefImpl添加到全局的batchedComputed链中。进入if执行ComputedRefImpl.dep的notify函数,将ComputedRefImpl的dep链中的副作用函数添加到batchedSub链表中。考虑以下场景:

const count = ref(0)
const computed1 = computed(() => {
    return count + 1
})
watchEffect(() => {
    console.log(count)
})
watchEffect(() => {
    console.log(computed1)
})

当count变更时,computed1作为count对应的sub会执行,此时computed1的结果被更改,那么computed1对应的watchEffect也需要被执行。

endBatch遍历batchedSub执行Subscriber的trigger函数,trigger函数执行runIfDirty。

// packages/reactivity/src/effect.ts
runIfDirty(): void {
    if (isDirty(this)) {
      this.run()
    }
}

function isDirty(sub: Subscriber): boolean {
  for (let link = sub.deps; link; link = link.nextDep) {
    if (
      link.dep.version !== link.version ||
      (link.dep.computed &&
        (refreshComputed(link.dep.computed) ||
          link.dep.version !== link.version))
    ) {
      return true
    }
  }
  if (sub._dirty) {
    return true
  }
  return false
}

isDirty判断当前副作用函数是否应该执行。判断方式是遍历sub.deps,判断link.versin和link.dep.version是否相同,只要有一个不同,就说明有一个响应式数据是有变更的,那么就需要执行。上面dep.trigger函数已经把dep.version+1,那么此时对于ref创建的响应式数据来说,link.dep.version !== link.version

副作用函数中不仅有使用ref,还可能使用computed,对于判断computed数据是否有改变,需要额外的逻辑:

export function refreshComputed() {
    if (computed.globalVersion === globalVersion) {
        return
    }
    computed.globalVersion = globalVersion
    try {
        prepareDeps(computed)
        const value = computed.fn(computed._value)
        if (dep.version === 0 || hasChanged(value, computed._value)) {
          computed._value = value
          dep.version++
        }
      } catch (err) {
        dep.version++
        throw err
      } finally {
        cleanupDeps(computed)
      }
    }

首先,computed.globalVersion一开始被赋值为globalVersion-1,所以没进if,如果computed.globalVersion === globalVersion说明没有响应式数据发生变化,那么computed肯定也没有变化。

  1. prepareDeps(computed)将所有关联的link都置为-1;
  2. 然后执行computed函数,将触发了getter的响应式数据对应的link.version重置;
  3. 如果返回值与旧值不一致,那么computed.dep.version++;
  4. 执行cleanupDeps,将等于-1的link从链表中移除。

(refreshComputed(link.dep.computed) || link.dep.version !== link.version

如果computed.dep.version++,那么外层的link.dep.version !== link.version成立。

isDirty返回true,然后执行this.run()

run(): T {
    if (!(this.flags & EffectFlags.ACTIVE)) {
      return this.fn()
    }

    this.flags |= EffectFlags.RUNNING
    cleanupEffect(this)
    prepareDeps(this)
    const prevEffect = activeSub
    const prevShouldTrack = shouldTrack
    activeSub = this
    shouldTrack = true

    try {
      return this.fn()
    } finally {
      cleanupDeps(this)
      activeSub = prevEffect
      shouldTrack = prevShouldTrack
      this.flags &= ~EffectFlags.RUNNING
    }
  }

这个函数是实现依赖关系的增量更新的关键,主要代码在于

  • prepareDeps
  • this.fn
  • cleanupDeps
function prepareDeps(sub: Subscriber) {
  for (let link = sub.deps; link; link = link.nextDep) {
    link.version = -1
    link.prevActiveLink = link.dep.activeLink
    link.dep.activeLink = link
  }
}

首先prepareDeps将sub.deps链表中的所有link.version置为-1。

紧接着执行this.fn:

watchEffect(() => {
    if(isValid.value) {
        console.log(count1.value)
    }else {
        console.log(count2.value)
    }
})

当isValid = false,上述代码link1,link2的version被置为-1,紧接着再次执行watchEffct内的副作用函数,此时isValid,count2的响应式getter被触发:

// packages/reactivity/src/dep.ts
track() {
    if (link === undefined || link.sub !== activeSub) {}
    else if(link.version === -1) {
        link.version = this.version
    }
}

此时isValid对应的link1.version === -1,link1.version再被重置为dep.version。link2.version === -1,没有触发getter,还是-1,link3之前没有创建,此时会新建一个Link。

最后执行cleanupDeps

function cleanupDeps(sub: Subscriber) {
    let tail = sub.depsTail
    let link = tail
    while (link) {
        const prev = link.prevDep
        if (link.version === -1) {
            removeSub(link)
            removeDep(link)
        }
    }
}

遍历sub.deps,将所有link.version === -1的找出来,这些就是在新一轮watchEffect执行后,没有触发getter的响应式数据,也就是此时这些响应式数据是没有作用的,那么就需要将关联到这些dep的link从链表中移除,之前了解过,link是一个双链表的结构,所以这里也需要处理两条链表的删除:

  • removeSub(link)
  • removeDep(link)

这么做的好处不言而喻,比如上面的例子,count1与watchEffect的关系link被解除,那么当count1发生变更时就不会再次执行watchEffect的副作用函数,减少了不必要的函数执行。