Vue 3 响应式核心

依赖收集、批量处理与异步调度

当你修改一个 ref 的值时,Vue 3 的响应式系统会高效且精准地更新 DOM,这背后是一系列精心设计的机制,包括依赖收集批量处理异步调度惰性求值。本文将把这些概念串联起来,深入剖析一个响应式数据从被修改到最终更新界面的完整生命周期。

1. 依赖收集:数据与副作用的绑定

一切的起点是依赖收集。当你使用 ref 定义一个数据时,Vue 会为这个数据创建一个 Dep 实例,其中包含一个双向链表,用于存储所有依赖它的副作用effect)。

// ref.ts
class RefImpl<T> {
    // ...
    public dep?: Dep = undefined
    // ...
}

当你首次在 effect(例如 watchcomputed)中访问这个 ref 的值时,就会触发其 get 方法。get 方法会调用 track(),将当前正在执行的 effect 作为一个订阅者 (sub),与 ref 自身的 dep 实例关联起来。这个订阅者会被添加到 dep 的双向链表中。

// effect.ts
export function track(dep: Dep, ref: RefImpl) {
    // ...
    dep.track(effect)
}

简而言之,track 方法建立了一个至关重要的联系:它告诉 ref,当你的值发生变化时,请通知我这个 effect。

2. 通知触发:从 ref.setdep.trigger

当你修改 ref 的值时,会触发其 set 方法。如果新值与旧值不同,set 方法就会调用 dep.trigger(),这标志着通知流程的开始

// ref.ts
public set value(newVal: T) {
    // ...
    this.dep.trigger()
}

dep.trigger() 方法做了两件关键的事:

  1. 它会增加 dep 的 version,用于后续的优化。
  2. 它会开始遍历 dep 中存储的所有订阅者(sub)。

但请注意,trigger 并不会立即执行副作用。相反,它会启动一个更复杂的批量处理异步调度流程。

3. 批量处理与异步调度:性能优化的核心

dep.trigger() 遍历其订阅者链表时,会为每个订阅者调用 sub.notify() 方法。这个 notify() 方法才是整个调度流程的关键。

// dep.ts
for (let link = this.subs; link; link = link.prevSub) {
    if (link.sub.notify()) {
    // 处理 computed 属性
    ;(link.sub as ComputedRefImpl).dep.notify()
}}

这里的 if 条件区分了两种不同的订阅者:

  • watch 等普通 effect: notify() 方法会返回 false。它会将自身添加到名为 batchedSub 的调度队列中。
  • computed 属性: notify() 方法会返回 true。它不仅会将自身添加到调度队列,还会继续调用它自己内部 dep 的 notify() 方法,从而通知所有依赖此 computed 的其他副作用,将它们也加入队列。

在遍历完所有订阅者后,dep.trigger() 会调用 endBatch()endBatch() 会从 batchedSub 队列的头部开始,依次取出每个副作用,并调用它们的 trigger() 方法。

// effect.ts
export function endBatch() {
    while (batchedSub) {
        let e: Subscriber | undefined = batchedSub
        // ...
        if (e.flags & EffectFlags.ACTIVE) {
              // 执行 effect 的 trigger 方法
              ;(e as ReactiveEffect).trigger()
        }
    }
}

这个 endBatchwhile 循环,正是 Vue 3 调度器队列的实现。它通过将多个状态变更产生的副作用收集到同一个队列中,再统一执行,从而实现了批量处理,避免了不必要的重复渲染。这个队列会在下一个微任务 (microtask) 中被执行,这也是为什么 DOM 更新总是异步发生的。

4. 副作用执行的三个关键分支:effect.trigger()

最后,我们来看每个副作用自身的 trigger() 方法。它根据不同的 flags 采取了三种不同的执行路径:

// effect.ts
trigger(): void {
    if (this.flags & EffectFlags.PAUSED) {
        // 情况一:副作用暂停  
    } else if (this.scheduler) {
        // 情况二:通过调度器执行
        this.scheduler()
    } else {
        // 情况三:惰性求值
        this.runIfDirty()
    }
}
  1. EffectFlags.PAUSED (副作用暂停): 当 effect 处于暂停状态时,它不会立即执行,而是被添加到 pausedQueueEffects 队列。这通常发生在 run 方法内部,为了防止在重新获取依赖值时,意外地再次触发依赖收集。
  2. this.scheduler (调度器执行): 这是最常见的路径,特别是在 watch 中。当你创建一个 watch 时,Vue 会为其注入一个调度器函数。这个函数会将 watch 的回调封装成一个微任务,并放入任务队列。endBatch 中的 e.trigger() 最终会调用这个 scheduler(),从而在下一个事件循环中执行你的 watch 回调。
  3. this.runIfDirty() (惰性求值): 这种情况专属于 computed 属性。computed 的 effect 没有调度器,所以它会进入这条路径。runIfDirty() 会检查 computed 的 dirty 标志。如果 computed 的依赖已经发生改变(即 dirty 为 true),它才会重新执行 computed 的 getter 函数来计算新值。这正是 computed 属性惰性求值特性的实现,它避免了不必要的重复计算,只有在真正需要新值时才进行更新。

本文是《Vue 3 响应式源码》内容的总结,原文:  Vue 3 响应式源码 - Codingmway