Vue3响应式原理以及依赖模型解析

926 阅读8分钟

这篇博客需要对响应式对象有一定的理解。如果不熟悉响应式对象,可以先看Vue3响应式对象-reactiveVue3响应式对象-refVue3响应式对象-计算属性和异步计算属性

一、什么是依赖?

在看一些关于Vue的资料时,经常都能看到依赖收集和依赖更新的字样,那么什么是依赖? 在Vue3中,关于依赖的定义如下:

export type Dep = Set<ReactiveEffect> & TrackedMarkers // 依赖定义
type TrackedMarkers = {
  w: number
  n: number
}

可以看出来,依赖本质上就是一个ReactiveEffectSet集合。关于TrackedMarkers参数,在介绍依赖收集优化时分析。

二、ReactiveEffect对象详解

上面提到,一个Dep依赖就是一个ReactiveEffect集合。那么ReactiveEffect对象到底是什么?接下来我将详细介绍这个对象以及响应式系统的实现原理。

1.主要对象简要关系图

在上面描述依赖时,完全没有提到响应式对象。而每次提及依赖更新和依赖收集,都是在读写响应式对象的时候。它们之间的关系如下图:

响应式系统对象关系.png
  • 关系A:一个响应式对象,总能通过某种方式与Dep依赖进行关联,讲解依赖收集时详解
  • 关系B:一个Dep依赖是一组ReactiveEffect的集合,注意这儿是双向关系,一个ReactiveEffect对象也保存了一个Dep依赖数组。

2.核心源码

export class ReactiveEffect<T = any> {
  active = true // 是否激活
  deps: Dep[] = [] // 保存Deps数组
  parent: ReactiveEffect | undefined = undefined // 父reactiveEffect节点
  computed?: ComputedRefImpl<T> // 是否是计算属性
  allowRecurse?: boolean // 是否允许递归依赖收集
  private deferStop?: boolean // 是否异步停止依赖收集
  onStop?: () => void // 停止依赖收集时调用的回调

  constructor(
    public fn: () => T,
    public scheduler: EffectScheduler | null = null,
    scope?: EffectScope // 所属响应式域
  ) {
    recordEffectScope(this, scope)
  }

  run() {
    if (!this.active) {
      // 未激活,直接调用fn回调,没有依赖收集
      return this.fn()
    }
    // 保存前一个激活的activeEffect和收集状态
    let parent: ReactiveEffect | undefined = activeEffect
    let lastShouldTrack = shouldTrack
    // 遍历激活的reactiveEffect链,如果自身已经存在于链中,则退出,避免无限递归
    while (parent) {
      if (parent === this) {
        return
      }
      parent = parent.parent
    }
    try {
      // 设置父activeEffect对象
      this.parent = activeEffect
      // 设置当前激活activeEffect对象
      activeEffect = this
      // 收集状态置为true
      shouldTrack = true
      // 收集轮次bit值-每一轮左移一位
      trackOpBit = 1 << ++effectTrackDepth

      if (effectTrackDepth <= maxMarkerBits) {
        // 标记当前依赖
        initDepMarkers(this)
      } else {
        // 直接清空当前依赖
        cleanupEffect(this)
      }
      // 调用回调fn
      return this.fn()
    } finally {
      if (effectTrackDepth <= maxMarkerBits) {
        // 处理最终依赖
        finalizeDepMarkers(this)
      }
      // bit值右移一位
      trackOpBit = 1 << --effectTrackDepth
      // 激活activeEffect还原为父对象
      activeEffect = this.parent
      // 还原父对象的收集状态
      shouldTrack = lastShouldTrack
      // 父对象值空
      this.parent = undefined

      if (this.deferStop) {
        // 是否异步停止
        this.stop()
      }
    }
  }

  stop() {
    if (activeEffect === this) {
      // 异步停止收集依赖
      this.deferStop = true
    } else if (this.active) {
      // 清理依赖
      cleanupEffect(this)
      if (this.onStop) {
        // 调用设置的回调
        this.onStop()
      }
      // 激活状态置为false
      this.active = false
    }
  }
}

在介绍计算属性时,我说过计算属性是基于现有响应式对象而衍生出来的,它的实现代码中就有创建ReactiveEffect对象的流程,现在我就以一个计算属性来详解这个类。讲解代码如下:

const data = ref(1)
computed(() => {
  return data.value + 1
})

计算属性相关核心代码片段如下:

this.effect = new ReactiveEffect(getter, () => {
  // 当这个计算属性的依赖变更时,这个匿名方法被执行
  if (!this._dirty) {
    // 将数据标识为脏数据,下一次读取时重新计算
    this._dirty = true
    // 触发依赖更新,虽然计算属性在依赖数据变更时不主动计算,
    // 但需要通知依赖于当前计算属性的EffectReactive对象执行响应回调
    triggerRefValue(this)
  }
})
  • 构造函数

    ReactiveEffect的构造方法和计算属性创建ReactiveEffect对象源码可知,构造函数的fn回调方法就是getter方法,而schedule方法就是设置计算属性为脏数据的匿名方法

  • run方法

    run方法是重点,主要做了如下几件事。

    • 保存当前activeEffect对象和收集状态
    • 标记当前所有已经存在的依赖为已收集
    • 调用fn回调收集依赖,这些依赖被标记为新收集
    • 将被标记为已收集但不是新收集的依赖移除掉
    • 还原activeEffect对象和收集状态
  • stop方法

    stop方法很简单,就是停止依赖收集,将ReactiveEffect对象设置为未激活,如果对象是activeEffect对象,则表明当前正在收集依赖,则转换为异步停止。

3.单一ReactiveEffect简易流程图

针对计算属性而言,这个流程相对而言较为清晰了,主要流程如下:

计算属性响应式流程图.png

在计算属性这种简单场景下,只存在一个ReactiveEffect对象,因此流程是比较好整理的。但在Vue3中,ReactiveEffect对象往往是会存在多个的,但激活的只能有一个。

4.嵌套的ReactiveEffect场景

在Vue3中,一个组件在被编译完后,会有一个render函数,这个render函数本身就是ReactiveEffect方法的fn参数,所以组件才会响应式变化。如果执行render时使用了计算属性,则表明在执行一个回调fn的同时,又创建了一个新的ReactiveEffect对象,此时便形成了ReactiveEffect链。

我以如下示例举例:

const data1 = ref(1)
const data2 = ref(2)
effect(/*fn1*/() => {
  // ReactiveEffect1
  console.log('调用fn1',data1.value)
  effect(/*fn2*/() => {
    // ReactiveEffect2
    console.log('调用fn2',data2.value)
  })
})

在这个示例中,ReactiveEffect1调用fn1,data1对应的依赖与ReactiveEffect1进行一个双向收集,ReactiveEffect2调用fn2,data2对应的依赖与ReactiveEffect2进行一个双向收集。简单来说,哪个ReactiveEffect调用了回调方法,那么回调里的响应式对象就只与这个ReactiveEffect对象进行依赖收集。

那么为什么要保存父ReactiveEffect对象和相应的依赖收集状态呢?看看下面的示例:

const data1 = ref(1)
const data2 = ref(2)
effect(/*fn1*/() => {
  // ReactiveEffect1
  console.log('调用fn1',data1.value)
  effect(/*fn2*/() => {
    // ReactiveEffect2
    console.log('调用fn2',data2.value)
  })
  console.log('调用fn1',data2.value) // 只新加一行代码
})

ReactiveEffect2依赖收集结束后,又继续执行fn1,此时还是要继续进行依赖收集的,所以在执行完成ReactiveEffect2的依赖收集后,需要还原ReactiveEffect1的收集状态及激活的ReactiveEffect对象,此时ReactiveEffect1与data2所对应依赖进行双向依赖收集。

5. 如何避免无限递归调用

如果理解了上述嵌套ReactiveEffect对象的执行,那么可以得到如下的一个模型:

ReactiveEffect链.png

现在以D对象为例,当执行D对象的run方法时,进行依赖收集,则dD进行关联,d变更时,D需要执行对应回调。

那么考虑如下一个场景,读取d对象的同时,修改了d对象,是否会进行无限递归调用?比如如下场景:

const data1 = ref(1)
effect(/*fn1*/() => {
  // ReactiveEffect1
  console.log('调用fn1',data1.value)
  // 修改值
  data1.value++
})

答案是不会触发,因为在调用D对象的run方法时,会首先判断D对象是否在ReactiveEffect链中,在则直接退出。相关代码如下:

// 保存前一个激活的activeEffect和收集状态
let parent: ReactiveEffect | undefined = activeEffect  // 此时activeEffect是D
let lastShouldTrack = shouldTrack
// 遍历激活的reactiveEffect链,如果自身已经存在于链中,则退出,避免无限递归
while (parent) {
  if (parent === this) {
    判断满足,退出
    return
  }
  parent = parent.parent
}

这儿使用了循环判断,这是由于父ReactiveEffect的响应式调用,会导致子ReactiveEffect的响应式调用,所以当前activeEffect对象的所有祖先元素都不能触发依赖更新。参考如下模型:

ReactiveEffect复杂链.png

在执行Drun方法导致d修改时,原本应该触发D和A执行响应式回调,D在前文说了,不会触发,那么A如果执行响应式回调,最终会导致D执行响应式回调,所以activeEffect对象的所有递归父级ReactiveEffect都不能执行响应式回调。

三、依赖收集与依赖更新

在上文的介绍中,依赖收集与依赖更新本质上就是让响应式对象与ReactiveEffect对象进行关联,这样当响应式对象修改时,就能触发对应ReactiveEffect对象的响应回调方法。

1.依赖收集核心源码

收集依赖,简单来说就是针对响应式对象创建一个依赖并且存储起来。收集依赖分为2类,下面依次介绍。

  • reactive对象的依赖收集

    在介绍Vue3响应式对象-reactive时,我提到过reactive对象是使用代理实现的,它是一个普通对象的封装。在开发中,使用reactive对象时,只有在读写其对应属性才能被代理拦截到,因此本质是针对属性去创建依赖。

    接下来分析其核心源码:

    • 创建依赖或查找依赖

      // 查找或创建依赖
      export function track(target: object, type: TrackOpTypes, key: unknown) {
        // 是否可以收集以及是否存在activeEffect对象
        if (shouldTrack && activeEffect) {
          // 以target对象为key从targetMap中depsMap
          let depsMap = targetMap.get(target)
          if (!depsMap) {
            targetMap.set(target, (depsMap = new Map()))
          }
          // 以属性为key从depsMap中获取依赖Dep
          let dep = depsMap.get(key)
          if (!dep) {
            depsMap.set(key, (dep = createDep()))
          }
      
          const eventInfo = __DEV__
            ? { effect: activeEffect, target, type, key }
            : undefined
          // 收集依赖
          trackEffects(dep, eventInfo)
        }
      }
      

      如下示例代码阐述:

      let data = reactive({
        name:'demo'
      })
      data.name
      

      当访问data.name时,会被代理的get方法拦截,get方法能获取到data这个代理对象的原始对象,这个原始对象也就是入参的target对象,name属性也就是参数key。这样就构成了如下的数据存储结构:

      Dep存储-reactive.png

      通过这种结构,就可以轻松的通过对象及属性找到一个关联的Dep

    • 收集依赖

      // 收集依赖
      export function trackEffects(
        dep: Dep,
        debuggerEventExtraInfo?: DebuggerEventExtraInfo
      ) {
        let shouldTrack = false
        // 收集轮次bit值是否在最大值之下
        if (effectTrackDepth <= maxMarkerBits) {
          if (!newTracked(dep)) {
            // 当前依赖打上新增标识
            dep.n |= trackOpBit // set newly tracked
            // 判断是否还需要收集,因为之前可能已经收集过
            shouldTrack = !wasTracked(dep)
          }
        } else {
          // Full cleanup mode.
          shouldTrack = !dep.has(activeEffect!)
        }
        // 满足条件则双向收集
        if (shouldTrack) {
          dep.add(activeEffect!)
          activeEffect!.deps.push(dep)
          if (__DEV__ && activeEffect!.onTrack) {
            activeEffect!.onTrack({
              effect: activeEffect!,
              ...debuggerEventExtraInfo!
            })
          }
        }
      }
      

      当找到一个依赖后,便需要进行收集,核心便是activeEffectDep对象进行一个双向添加。理论上只需要Dep对象添加activeEffect便可,双向添加和上面获取shouldTrack标识有关,在后续的依赖收集优化做介绍。

  • ref系列对象的依赖收集

    和reactive对象不一致的地方是ref系列对象都直接把Dep依赖保存到对象内,省去了查找依赖的那一步,而收集依赖都是一致的。ref系列对象包括ref对象,计算属性和异步计算属性。

    那么为什么要这么设计呢?早期的设计其实并非如此,是和reactive对象的收集为同一套逻辑,将ref对象作为target,value属性作为key,这样从逻辑上讲也不存在问题,但现实是可能存在性能上的隐患。

    当一个ref对象是针对一个大对象的包装时,此时的value属性就会比较大。上面说过,reactive对象的收集通过DepsMap和key获取Dep,当key很大时,会导致DepsMap哈希表会特别大,因此可能存在浪费内存的隐患

2.依赖更新核心源码

依赖更新的本质就是当响应式对象变更后,找到对应的Dep对象,使得其中所有的ReactiveEffect对象触发响应回调。

核心代码如下:

export function triggerEffects(
  dep: Dep | ReactiveEffect[],
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  // spread into array for stabilization
  const effects = isArray(dep) ? dep : [...dep]
  for (const effect of effects) {
    if (effect.computed) {
      // 计算属性要提前置为脏数据
      triggerEffect(effect, debuggerEventExtraInfo)
    }
  }
  for (const effect of effects) {
    if (!effect.computed) {
      triggerEffect(effect, debuggerEventExtraInfo)
    }
  }
}

function triggerEffect(
  effect: ReactiveEffect,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  // 如果激活对象是当前对象,除非允许递归,否则不触发
  if (effect !== activeEffect || effect.allowRecurse) {
    if (__DEV__ && effect.onTrigger) {
      effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))
    }
    // 有scheduler则调用
    if (effect.scheduler) {
      effect.scheduler()
    } else {
      // 否则调用run
      effect.run()
    }
  }
}

触发依赖的代码省略掉了查找Dep依赖的过程,本质是保存依赖的逆查找过程,仅针对reactive对象。只不过新增一些额外依赖添加,比如一个数组的原长度是10,现在修改为5,不仅需要触发length属性的修改,还需要触发下标为5-9的数组元素的删除。当找到依赖后,就需要触发回调调用,计算属性要提前置为脏数据,保证数据的正确性,然后调用其余ReactiveEffect对象的schedule回调或者run回调

四、依赖收集优化

如果细心一点,可能会发现触发依赖更新时,可能调用run方法。我们知道,收集依赖在调用run方法之后,触发依赖更新可能又会调用run方法,此时会存在一个问题,依赖的重复收集,依赖收集的优化就在于如何去处理这个重复收集的问题。

在前文介绍过,ReactiveEffect对象的依赖收集是链式的,因此Vue使用位操作来标记链中ReactiveEffect对象的依赖,层次每深一层,左移一位。

打上标记的地方在于run方法内。核心代码如下:

// 收集轮次bit值-每一轮左移一位
trackOpBit = 1 << ++effectTrackDepth
if (effectTrackDepth <= maxMarkerBits) {
    // 标记当前依赖
    initDepMarkers(this)
    } else {
    // 直接清空当前依赖
    cleanupEffect(this)
}
// 标记代码
export const initDepMarkers = ({ deps }: ReactiveEffect) => {
    if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].w |= trackOpBit // set was tracked
    }
}
// 依赖定义
export type Dep = Set<ReactiveEffect> & TrackedMarkers
type TrackedMarkers = {
  w: number
  n: number
}

在上述代码中,maxMarkerBits等于30,这是由于js在处理位操作时比较特殊,当1 << 31时便是负数,因此最大只能是30。initDepMarkers本质上就是给当前所有的依赖打上一个标记,表明这些依赖是已经被收集的。在依赖收集时,有一段代码如下:

// 收集轮次bit值是否在最大值之下
if (effectTrackDepth <= maxMarkerBits) {
    // 是否已经被新收集
    if (!newTracked(dep)) {
      // 当前依赖打上新增标识
      dep.n |= trackOpBit // set newly tracked
      // 判断是否还需要收集,因为之前可能已经收集过
      shouldTrack = !wasTracked(dep)
    }
} else {
    // Full cleanup mode.
    shouldTrack = !dep.has(activeEffect!)
}

这段代码首先判断依赖是否已经被新收集,这是防止重复访问同一个响应式对象导致依赖重复收集。如果没有则打上新收集的标识,然后判断是否是已经被收集的依赖,如果不是则表示需要收集。在依赖收集结束后,还有最后的清理无用依赖操作,在run方法的finally块内,如下:

if (effectTrackDepth <= maxMarkerBits) {
    // 处理无用依赖
    finalizeDepMarkers(this)
}
// bit值右移一位,还原
trackOpBit = 1 << --effectTrackDepth
// 处理方法
export const finalizeDepMarkers = (effect: ReactiveEffect) => {
  const { deps } = effect
  if (deps.length) {
    let ptr = 0
    for (let i = 0; i < deps.length; i++) {
      const dep = deps[i]
      // 被打上已收集,但不是新增收集的,则需要删除
      if (wasTracked(dep) && !newTracked(dep)) {
        dep.delete(effect)
      } else {
        deps[ptr++] = dep
      }
      // clear bits
      dep.w &= ~trackOpBit
      dep.n &= ~trackOpBit
    }
    deps.length = ptr
  }
}

这段代码主要就是将上一次收集的依赖,但这一次没有收集的给移除掉。

接下来我以如下示例来解释上述依赖的优化:

let dep1 = ref(1)
let dep2 = ref(2)
let dep3 = ref(3)
let data = 0
let status = ref(false)
effect(() => {
  data = status.value ? dep2.value + dep3.value : dep1.value
})
expect(data).toBe(1)
status.value = true
expect(data).toBe(5)

status是false时,此时ReactiveEffect读取了dep1,则dep1对应的Dep依赖被置为新收集,当收集结束后,由于初始依赖列表为空,所以没有需要移除的依赖。

status是true时,此时dep1对应的Dep依赖被置为已收集,dep2和dep3对应的Dep依赖被置为新收集,收集结束后由于dep1对应的Dep依赖不是新收集,则需要移除。

简单来说就是ReactiveEffect对象内保存的Dep列表只和最新一次收集的依赖有对应关系,历史的Dep数据需要被清理掉。

effectTrackDepth <= maxMarkerBits不满足时,则直接把现有的依赖列表清空,这样每次收集到的数据都是最新数据,且不用做移除处理,但这种方式不是推荐方式,因为每次清空收集会浪费性能。

五、总结

Vue3响应式系统的总结到此结束,后续会逐步更新关于Vue3的其他系统模块。