Vue3-dirty-check机制

357 阅读11分钟

本文主要根据vue3中的dirty机制做详细的介绍。

本文依据的vue版本是3.4.27。

这里需要对vue的响应式原理和vue的渲染器有一定的了解。如果不了解可以关注我后续的文章。

先说结论吧。vue3的dirty机制主要是用来解决computed在属性值没有发生变化的时候。对应的一些ReactiveEffect函数发现变化而做出的改变。简单来说就是增加一个标志位。是effect实例的一个属性。这个标志位目前主要是用来针对computed对应的effect函数(后续可能会针对其他)。这个标志位在effect初始化的时候是Dirty。当运行完副作用函数之后这个值会变成NotDirty。当在没有computed-effect的影响作用下。这个值会在 DirtyNotDirty的之间来回切换。当这个effecttrigger的时候就会设置成Dirty。运行完之后就会变成NotDirty

下面是dirty的具体取值

//
export enum DirtyLevels {
  //运行完成之后的值
  NotDirty = 0,
  //查询dirty。在运行之前需要根据这个值去判断是否重新执行
  QueryingDirty = 1,
  // 嵌套的computed
  MaybeDirty_ComputedSideEffect = 2,
  //目前主要用作computed-trigger
  MaybeDirty = 3,
  //默认值。和被除了computed-trigger触发之外的情况
  Dirty = 4,
}

本文我主要根据vue issue中的例子来进行介绍。链接地址:issues。 下面是这个例子的具体实现。

1:  const count = ref(0);
2:  const isEven = computed(() => count.value % 2 === 0);
3:  watchEffect(() => {
4:    console.log(isEven.value);
5:  });
6:  count.value = 2;

在介绍之前首先介绍几个函数的具体实现(删去不重要的逻辑,####表示)。

RefImpl

//ref函数的定义
export function ref(value?: unknown) {
   //value就是传递初始值0。false是是否是shallow这里先不用关注
  return createRef(value, false)
}

function createRef(rawValue: unknown, shallow: boolean) {
  if (isRef(rawValue)) {
    return rawValue
  }
  //通过RefImplclass去实例化一个ref对象。
  return new RefImpl(rawValue, shallow)
}

//响应式对应的key或者ref对应的effect依赖组成的类型。是一个map结构。key是对应的effect。value是对应的effect_id。
export type Dep = Map<ReactiveEffect, number> & {
  cleanup: () => void
  computed?: ComputedRefImpl<any>
}

//初始化key的effect依赖项的方法。
export const createDep = (
  cleanup: () => void,
  computed?: ComputedRefImpl<any>,
): Dep => {
  const dep = new Map() as Dep
  dep.cleanup = cleanup
  dep.computed = computed
  return dep
}

//ref的构造器函数
class RefImpl<T> {
  private _value: T
  private _rawValue: T

  //当前ref函数对应的依赖项。
  public dep?: Dep = undefined

  constructor(
    value: T,
    public readonly __v_isShallow: boolean,
  ) {
   //constructor函数
  }

  //获取
  get value() {
    trackRefValue(this)
    return this._value
  }

  set value(newVal) {
     //#####
     
     //判断value是都放生变化。发生变化的时候才去trigger
    if (hasChanged(newVal, this._rawValue)) {
      const oldVal = this._rawValue
      this._rawValue = newVal
      this._value = useDirectValue ? newVal : toReactive(newVal)
      //如果发生变化就去触发当前ref所依赖的effect。注意这个触发是Dirty等级
      triggerRefValue(this, DirtyLevels.Dirty, newVal, oldVal)
    }
  }
}

triggerRefValue

//ref函数对应的trigger依赖项的函数。可以看到这个函数的作用就是获取当前ref的dep属性。然后调用triggerEffects函数。如果有的话。
export function triggerRefValue(
  ref: RefBase<any>,
  //dirtyLevel等级。默认都是Dirty
  dirtyLevel: DirtyLevels = DirtyLevels.Dirty,
  newVal?: any,
  oldVal?: any,
) {
  ref = toRaw(ref)
  const dep = ref.dep
  if (dep) {
    triggerEffects(
      dep,
      dirtyLevel,
      __DEV__
        ? {
            target: ref,
            type: TriggerOpTypes.SET,
            key: 'value',
            newValue: newVal,
            oldValue: oldVal,
          }
        : void 0,
    )
  }
}

triggerEffects


export function triggerEffects(
  dep: Dep,
  dirtyLevel: DirtyLevels,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo,
) {
  pauseScheduling()
  for (const effect of dep.keys()) {
    // dep.get(effect) is very expensive, we need to calculate it lazily and reuse the result
    let tracking: boolean | undefined
    if (
      effect._dirtyLevel < dirtyLevel &&
      (tracking ??= dep.get(effect) === effect._trackId)
    ) {
    //当前effect需要执行的前提是当前的effect的_dirtyLevel是NotDirty
      effect._shouldSchedule ||= effect._dirtyLevel === DirtyLevels.NotDirty
      effect._dirtyLevel = dirtyLevel
    }
    if (
      effect._shouldSchedule &&
      (tracking ??= dep.get(effect) === effect._trackId)
    ) {
      if (__DEV__) {
        // eslint-disable-next-line no-restricted-syntax
        effect.onTrigger?.(extend({ effect }, debuggerEventExtraInfo))
      }
      //从执行顺序可以看到trigger和scheduler的执行顺序
      effect.trigger()
      if (
        (!effect._runnings || effect.allowRecurse) &&
        effect._dirtyLevel !== DirtyLevels.MaybeDirty_ComputedSideEffect
      ) {
        effect._shouldSchedule = false
        if (effect.scheduler) {
          queueEffectSchedulers.push(effect.scheduler)
        }
      }
    }
  }
  resetScheduling()
}

这个函数的作用就是循环dep得到每一个effect。然后给循环的effect设置为参数传递的dirtyLevel。并判断是否需要更新。如果需要更新的话就会先触发effecttrigger方法。在把effectscheduler放在执行队列中执行。从triggerscheduler的执行时机可以得出。trigger要比scheduler要提前执行。主要是要派发一些信号。比如设置effect的dirty等级。

watchEffect

这个函数并不在reactivity这个包里面,而是在runtime-core->apiWatch。这里只看下他创建的Effect语句就行

  export const NOOP = () => {}
  scheduler = () => queueJob(job)
  const effect = new ReactiveEffect(getter, NOOP, scheduler)
  const job: SchedulerJob = () => {
    if (!effect.active || !effect.dirty) {
      return
    }
   //#####
   effect.run()
   //#####
  }
 

这里的triggerNOOP,是一个空函数,表示不需要triggerscheduler就是给当前的更新函数放入一个队列。这个队列具体怎么执行这里不做赘述。反正就是按照添加顺序依次执行。job就是watchEffect事件触发的函数。这里面的很多细节已经去掉。主要看函数刚开始执行的时候会判断effect.dirty的值是否是false。如果是的话就返回不需要重新执行。effectdirty是一个get value属性。每次获取的时候都需要执行一部分逻辑。

ComputedRefImpl

//comput函数的具体实现
export class ComputedRefImpl<T> {
  public dep?: Dep = undefined

  private _value!: T
  public readonly effect: ReactiveEffect<T>

  constructor(
    private getter: ComputedGetter<T>,
    private readonly _setter: ComputedSetter<T>,
    isReadonly: boolean,
    isSSR: boolean,
  ) {
  //在初始化的时候会初始化一个Effect
    this.effect = new ReactiveEffect(
      () => getter(this._value),
      () =>
        triggerRefValue(
          this,
          this.effect._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect
            ? DirtyLevels.MaybeDirty_ComputedSideEffect
            : DirtyLevels.MaybeDirty,
        ),
    )
    this.effect.computed = this
  }

  get value() {
    // the computed ref may get wrapped by other proxies e.g. readonly() #3376
    const self = toRaw(this)
    if (
      (!self._cacheable || self.effect.dirty) &&
      hasChanged(self._value, (self._value = self.effect.run()!))
    ) {
      triggerRefValue(self, DirtyLevels.Dirty)
    }
    trackRefValue(self)
    if (self.effect._dirtyLevel >= DirtyLevels.MaybeDirty_ComputedSideEffect) {
      if (__DEV__ && (__TEST__ || this._warnRecursive)) {
        warn(COMPUTED_SIDE_EFFECT_WARN, `\n\ngetter: `, this.getter)
      }
      triggerRefValue(self, DirtyLevels.MaybeDirty_ComputedSideEffect)
    }
    return self._value
  }

  set value(newValue: T) {
    this._setter(newValue)
  }
}

这个函数是computed函数的具体实现细节。和ref不太一样。这个函数有自己的effect,因为需要监控计算属性的回调函数中的数据是否发生变化,如果发生变化需要把对应的发生变化的ref数据添加当前的effect到对应的dep。同时还有自己的dep,因为计算属性的值会被其他的effect依赖。 初始化的时候就是实例化一个ReactiveEffect。其中可以看到第二个参数。第二个参数是trigger。他的作用就是如果一个effect依赖的deps数组中有computed属性值的依赖的时候,需要比scheduler先触发。去派发信号。需要提前给依赖的computed值的effect打上一个标记。这个标记就是把对应的effect函数的dirty设置为MaybeDirty_ComputedSideEffect或者是MaybeDirtyMaybeDirty_ComputedSideEffect主要是用来解决嵌套,可以先不管。当打了这个标记之后。在运行对应的effect之前就先判断对应的dirty等级是否是dirty。小于dirty都是falseMaybeDirty小于false。在获取这个dirty属性的同时。会重新去获取当前的effect是否有computed属性。如果有的话就获取下对应的value。重新触发一次对应的get逻辑。如果重新触发的时候两次两次的值没有发生变化,就不需要更改。但是如果有变化的时候就重新设置对应effectdirty等级是dirty。所以这个effectdirty属性就大于等于dirty。重新触发。

以上只是希望有一个大致的理解。后续会发布这些函数中每一个属性的具体含义。

具体过程

为了解释起来方便。我这里给不同的effect做一个命名。isEven对应的computed函数的effect称为effect1watchEffect称为effect2

初始化

从这个watchEffect中打印日志开始。也就是初始化收集effect2的依赖

截屏2024-06-15 22.18.51.png 当执行到console.log的语句的时候就会触发computedget value函数。从截图可以看到。self2.effect也就是是effect1对应的dirty等级是4。所以获取到的dirty属性就是true。然后在运行self2.fn。并且根据最新的值和旧的值进行比较。如果相同就不会触发isEven对应的effect依赖。这里只有一个effect2。如果相等就会触发。并且对应的等级是dirty等级。因为是初始化。所以他的dep属性是undefined。执行完成trackRefValue之后。会有一个effect2的依赖。如下

截屏2024-06-15 22.29.35.png 从上图可以看出如果重新运行对应的effect。他的dirty等级就是0。

更新过程

没有变化

更新过程触发的语句是从count.value = 2;开始。因为给count赋值。所以会触发count->ref对应的set value方法。如下图。

截屏2024-06-15 22.37.39.png 从第一个打印的true也可以看出来。watchEffect已经收集完成依赖。并且执行过。 接着是触发count->ref收集到的effect依赖数组。见下图。

截屏2024-06-15 22.40.50.png

count对应的依赖只有一个effect1。也就是computed对应的effecttriggerRefValue的作用就是根据传递的dirty等级去触发对应的effect。这里因为是ref触发。所以他的等级是4

接下来是triggerEffects。他的主要作用是循环deps。并且设置对应的dirty等级。并且需要在更新的时候。就触发对应的triggerscheduler

截屏2024-06-15 22.48.18.png 上图1的标记就是当前的effect是否更新。更新的逻辑就是当前的dirty等级是否是not_dirty。因为一个effect执行完成之后正常情况都是not_dirty

2是一些关键日志信息。运行到此处当前的effect2就是对应的参数dirty等级4。从0到4。表示需要更新了。如果需要更新。就会依次触发triger。和对应的schedulertriger是直接触发。scheduler需要放在任务队列依次执行。 截屏2024-06-15 22.55.50.png 可以看到。这个triggereffect1在实例化的时候传递的。作用就是需要触发isEven对应的依依赖effects。并且dirty等级是3。因为这次更新是computed触发的。接着又是triggerRefValue。只不过这时候对应的depisEven的。里面只有effect2。并且对应的dirty等级是3。见下图。

截屏2024-06-15 22.59.36.png 接着又来到对应的triggerEffects。可以看下对应的执行栈。他是从count->depcomputed->dep

截屏2024-06-15 23.02.24.png 接下来就是正常执行到对应的triggereffect2对应trigger是空函数。所以什么都没执行。然后把scheduler加入到对应的队列

截屏2024-06-15 23.06.59.png isEvent对应的dep执行完成。count对应的dep执行完成。此时其实还没执行对应的scheduler。因为他们还在对应中排队。执行的其实是effect对应的trigeer。如下: 截屏2024-06-15 23.10.34.png 注意下右边的执行栈。已经回到了count对应的dep。并且执行完成。测试对应的queueEffectSchedulers里面只有一个scheduler。是watchEffect的。接下来是这个scheduler的执行如下。 截屏2024-06-15 23.13.32.png 可以看到需要获取当前effect对应的dirty。这个dirty的获取是一个get属性。如下:

截屏2024-06-17 11.12.20.png 可以看到当前effect2对应的dirty等级已经是3。说明收集的依赖中有computed属性的变化。如果是4正常的ref触发。这个判断不会进去。直接返回true。因为是3所以进入。需要进行dirty-checkcheck的过程就是循环effect2对应的deps。如果有computed属性就触发一次他的get方法。没有就一直进行。注意。在check之前会把当前的effcetdirty等级变成1也就是QueryingDirty。这样主要是为了如果在触发value的时候。两次的值不一样。需要重新触发isEeventriggerRefValue。但是这次很明显不需要重新触发。只是去查询。去修改下当前的effectdirty等级。所以在triggerEffects函数中的_shouldSchedule判断逻辑是当前的dirty等级是0。如果是其他的表示不应该触发。本次触发只是为了check-dirty。

截屏2024-06-15 23.24.04.png 可以看到当前的dep是有一个computed属性的。 需要重新运行的他给get方法。triggerComputed函数很简单就是获取对应的value。从而触发他的get方法。

function triggerComputed(computed: ComputedRefImpl<any>) {
  return computed.value
}

截屏2024-06-15 23.29.09.png 上图所示。本次变化没有发生变化。所以就不会触发对应的triggerRefValue。所以就不会更改effect2对应dirty等级。

截屏2024-06-15 23.33.53.png 可以看到他的经过查询之后他的dirty的等级还是1.所以也就是没有发生变化。最终的结果就是job函数返回。不需要重新执行了。

截屏2024-06-15 23.35.18.png 以上就是没有变化的情况。

有变化

当有变化的情况和上面的差不多。但是在check-dirty的时候有变化。

截屏2024-06-15 23.39.41.png 可以看到如果是变成修改count.value =3。isEvent运行之后的值是false。发生了变化。就进入下面的triggerRefValue。把effect2对应的dirty的等级又变成4。这边直接到get dirty那一步。中间忽略更新成4的过程。如下。 截屏2024-06-15 23.44.17.png effect2dirty已经变成4。最终的结果就是返回true了。在job那一层在取一个反就会重新执行watch Effect对应的回调了

截屏2024-06-15 23.47.45.png 以上就是整体的过程了。

如果是Effct收集里面有ref。有computed。其实也是一样的。因为不管ref的收集在computed前面还是后面。只要有ref对应的dirty就一定是4。所以就一定会重新触发。

总结

这里还是解释下issus对应的图。

截屏2024-06-16 13.58.47.png

优化之前:其实就是在dataChange的时候先trigger Reactive Data对应的dep。在trigger computed datadep。最后在重新sheduler wachEffect对应的fn。这之间其实没有什么两次触发的值是否一样。都是直接触发。

优化之后:前面都是一样。只是在trigger computed datadep的时候。先trigerr。去派发一个信号。表示这次的更新里面有computed。然后在sheduler。在执行对应的fn的时候。需要判断当前的effectdirty属性。这个时候应该是当前effectdirty等级不是4。所以会进入check dirty的逻辑。check之后如果还不是dirty就不需要重新触发。check之后如果是dirty就重新触发。