Vue 3.4 响应式优化:减少计算变量引起的不必要渲染

208 阅读6分钟

前言

Vue 3.4 更新优化了响应式系统,解决了所依赖的响应式变量改变时,即使计算变量的变量值没有改变,也会触发后续的副作用/依赖(下称 effect)的问题,例如页面多次渲染、watch被多次触发、其他计算变量重新计算等等。

effect 回顾

refreactivecomputed等 API 可以创建响应式变量,当响应式变量被 effect 触发get方法时,effect 就会被响应式变量记录,例如记录在refImplcomputedImpldep属性。同时 effect 也会在自身的deps中记录响应式变量的dep

reactive的子属性、ref.value及其子属性被修改,触发set方法,effect 被触发,triggerschedule方法会被调用,引起后续的变化。

以模板渲染为例,画个图不太严谨地描述一下:

sequenceDiagram
    effect->>ref: ref.value
    Note left of effect: 假设在模板中使用了 ref.value
    Note right of ref: 触发 get value()
    ref->>effect: trace 收集依赖
    ref->>effect: trigger 触发依赖
    Note right of ref: ref.value 被修改,触发 set value()
    Note left of effect: 重新渲染页面

另外,计算变量所依赖的响应式变量发生修改时,它也会触发它所收集的 effect,同时触发依赖它的 effect。被触发get value时,如果计算变量依赖的响应式变量有改变,则对自身的值进行重新计算。

这里说的 effect,常见的一般是由computed(ComputedEffect)、watch或者watchEffect(WatchEffect)等 API 创建和创建虚拟 DOM 时创建的(RenderEffect)。它的定义如下:

export class ReactiveEffect<T = any> {
  // ...
  active = true
  deps: Dep[] = []
  this._dirtyLevel = DirtyLevels.Dirty
  
  // 如果是 computed 创建的,指向对应的 computed 变量
  computed?: ComputedRefImpl<T>

  constructor(
    public fn: () => T,
    public trigger: () => void,
    public scheduler?: EffectScheduler,
    scope?: EffectScope,
  ) {
    recordEffectScope(this, scope)
  }
   // ...
  run() {
    this._dirtyLevel = DirtyLevels.NotDirty
    if (!this.active) {
      return this.fn()
    }
    let lastShouldTrack = shouldTrack
    let lastEffect = activeEffect
    try {
      shouldTrack = true
      activeEffect = this
      this._runnings++
      preCleanupEffect(this)
      return this.fn()
    } finally {
      postCleanupEffect(this)
      this._runnings--
      activeEffect = lastEffect
      shouldTrack = lastShouldTrack
    }
  }
  // ...
}

我们可以注意到,_dirtyLevel,是用来标记 effect 是不是脏的,换句话说,就是有没有被响应式变量触发。当被响应式变量触发时,_dirtyLevel会变成DirtyLevels.Dirty(计算变量的情况见下文)。而run方法会在 effect 被触发后,以某种方式被调用,使得 effect 变成DirtyLevels.NotDirty。个人理解,换句话说,effect 被触发后就是脏的,对响应式变量改变做出处理后就不脏了。另外,effect 初始化的时候_dirtyLevel就是DirtyLevels.Dirty,初始化后,会调用一次run方法,把 effect 设置回DirtyLevels.NotDirty

这里的run方法,调用时可以时 effect 被响应式变量收集。fn通常会被 effect 的run方法调用,此时 effect 会被记录在全局变量activeEffect中,触发了get方法的响应式变量可以将之收集。同时也有其它用途。例如 RenderEffect 的fn用于挂载组件。WatchEffect 的话,在watch(valGetter, cb)中,就是第一个参数,而在watchEffect(cb)API 中即为其回调函数,也就是用来让监听的变量收集。ComputedEffect 的fn是传入的函数,可以让依赖的变量收集。

我们还可以看到两个入参:triggerschedule,它们通常在依赖被触发时调用,会以某种方式调用run方法回应响应式变量的更新。

trigger通常用于引起其它 effect 状态的改变。 ComputedEffect 的trigger将会触发其收集到的 effect。RenderEffect 和 WatchEffect,则为空。

scheduler通常包含异步的业务逻辑,例如 RenderEffect 的scheduler会执行run方法引起组件更新。WatchEffect 的会执行run方法,以及watch的回调函数。ComputedEffect 没有scheduler,但是它将会触发其收集的 effect 的triggerschedule,最终调用自身的get方法并执行run计算变量的新值。

语言逐渐混乱,总之,不太严谨地说,effect 的大体机制如下所示。effect 被计算变量触发的机制请继续看下一节。

graph TB
A(非计算变量的响应式变量被修改)-->H("triggerRefValue(self, DirtyLevels.Dirty)")-->|"①"|G(收集的 effect._dirtyLevel = DirtyLevels.Dirty)
H-->|"②"|B("收集的 effect 的 trigger()")-->|"WatchEffect 和 RanderEffect"|C(无事发生)
B-->|ComputedEffect|D("triggerRefValue(self, DirtyLevels.MaybeDirty 或者 \nMaybeDirty_ComputedSideEffect)")-.->M(Vue 3.4 的新机制)-.->|"后续 effect 的 run() 获取计算变量的值,\n触发 get value 方法"|N("effect.run()\neffect._dirtyLevel = DirtyLevels.NotDirty\neffect.fn()")

H-->|"③"|E("收集的 effect 的 scheduler()")-->|"RanderEffect if effect.dirty"|F("effect.run()\neffect._dirtyLevel = DirtyLevels.NotDirty\neffect.fn()")-->I("更新 VNode 渲染页面")
E-->|"WatchEffect if effect.dirty"|J("effect.run()\neffect._dirtyLevel = DirtyLevels.NotDirty\neffect.fn()")-->K("执行 watch 回调函数")
E-->|"ComputedEffect"|L(无事发生)

Vue 3.4 的计算变量脏检查

稍微回顾了一下 Vue 的响应式系统,Vue 3.4 减少了计算变量的不必要更新,从而减少因此引发的watch系列 API 回调函数不必要的调用和不必要的页面更新。这次更新给计算变量和 effect 加上了DirtyLevels的机制。先来看看枚举值定义:

export enum DirtyLevels {
  NotDirty = 0,
  QueryingDirty = 1,
  MaybeDirty_ComputedSideEffect = 2,
  MaybeDirty = 3,
  Dirty = 4,
}

再来看看computed的实现:ComputedRefImpl

export class ComputedRefImpl<T> {
  // ...
  constructor(
    getter: ComputedGetter<T>,
    private readonly _setter: ComputedSetter<T>,
    isReadonly: boolean,
    isSSR: boolean,
  ) {
    this.effect = new ReactiveEffect(
      // 它的 fn 就是 computed 传入的函数
      () => getter(this._value),
      // 这里是 trigger 方法,triggerRefValue 函数触发后续的依赖
      () =>
        triggerRefValue(
          this,
          this.effect._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect
            ? DirtyLevels.MaybeDirty_ComputedSideEffect
            : DirtyLevels.MaybeDirty,
        ),
    )
    this.effect.computed = this
    this.effect.active = this._cacheable = !isSSR
    this[ReactiveFlags.IS_READONLY] = isReadonly
  }
}

我们假设,计算变量所依赖的响应式变量更新,它收集到的 effect 被触发。ComputedEffect 的trigger被执行。

() =>
  triggerRefValue(
    this,
    this.effect._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect
      ? DirtyLevels.MaybeDirty_ComputedSideEffect
      : DirtyLevels.MaybeDirty,
  )

进入triggerRefValuetriggerEffects方法,computed变量收集到的 effect 的_dirtyLecel被设置为DirtyLevels.MaybeDirty或者DirtyLevels.MaybeDirty_ComputedSideEffect

export function triggerRefValue(
  ref: RefBase<any>,
  dirtyLevel: DirtyLevels = DirtyLevels.Dirty,
  newVal?: any,
) {
  ref = toRaw(ref)
  const dep = ref.dep
  if (dep) {
    triggerEffects(
      dep,
      dirtyLevel,
      void 0,
    )
  }
}
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)
    ) {
      // 只有 NotDirty 的才能被触发依赖,避免了多个响应式变量同时改变,多次触发依赖的情况
      effect._shouldSchedule ||= effect._dirtyLevel === DirtyLevels.NotDirty
      effect._dirtyLevel = dirtyLevel
    }
    if (
      effect._shouldSchedule &&
      (tracking ??= dep.get(effect) === effect._trackId)
    ) {
      effect.trigger()
      if (
        (!effect._runnings || effect.allowRecurse) &&
        effect._dirtyLevel !== DirtyLevels.MaybeDirty_ComputedSideEffect
      ) {
        effect._shouldSchedule = false
        if (effect.scheduler) {
          queueEffectSchedulers.push(effect.scheduler)
        }
      }
    }
  }
  resetScheduling()
}

当后面的 effect 触发scheduler时,这里以 RenderEffect 为例,在 packages/runtime-core/src/renderer.ts 中:

const effect = (instance.effect = new ReactiveEffect(
  // 这个函数也就是 effect 的 fn,将会触发组件挂载或者更新
  componentUpdateFn,
  NOOP,
  () => queueJob(update),
  instance.scope, // track it in component's effect scope
))

const update: SchedulerJob = (instance.update = () => {
  if (effect.dirty) {
    effect.run()
  }
})

effect 的scheduler将判断自身的 effect 是否dirty。然后,触发ActiveEffectdirty get方法。

WatchEffect 对是否触发scheduler的处理也类似。但是 ComputedEffect 的情况有所不同。在计算变量被其他 effect 的fn执行时使用,触发get value方法后才根据计算变量自身的 ComputedEffect 是否dirty来决定是否重新计算。

export class ReactiveEffect<T = any> {
  // ...
  deps: Dep[] = []
  computed?: ComputedRefImpl<T>
  _dirtyLevel = DirtyLevels.Dirty
  _depsLength = 0
  //...
  public get dirty() {
    if (
      this._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect ||
      this._dirtyLevel === DirtyLevels.MaybeDirty
    ) {
      this._dirtyLevel = DirtyLevels.QueryingDirty
      pauseTracking()
      for (let i = 0; i < this._depsLength; i++) {
        const dep = this.deps[i]
        if (dep.computed) {
          triggerComputed(dep.computed)
          if (this._dirtyLevel >= DirtyLevels.Dirty) {
            break
          }
        }
      }
      if (this._dirtyLevel === DirtyLevels.QueryingDirty) {
        this._dirtyLevel = DirtyLevels.NotDirty
      }
      resetTracking()
    }
    return this._dirtyLevel >= DirtyLevels.Dirty
  }
  // ...
}
function triggerComputed(computed: ComputedRefImpl<any>) {
  return computed.value
}

这里算是 Vue 3.4 计算变量更新的关键内容,当 effect 判断是否脏了的时候,如果它被计算变量收集了的话,它会检查计算变量的值事实上有无变化,triggerComputed触发了计算变量computedget value方法。

  get value() {
    const self = toRaw(this)
    if (
      (!self._cacheable || self.effect.dirty) &&
      hasChanged(self._value, (self._value = self.effect.run()!))
    ) {
      // 触发 triggerEffects 的时候,effect._dirtyLevel === DirtyLevels.NotDirty 为 false
      // 因为上文被触发的 effect get dirty 的时候将其设置为 DirtyLevels.QueryingDirty
      // 不会再次触发后续的 effect
      triggerRefValue(self, DirtyLevels.Dirty)
    }
    trackRefValue(self)
    // 这种情况只有在计算变量的函数中修改了计算变量依赖的响应式变量才会触发
    if (self.effect._dirtyLevel >= DirtyLevels.MaybeDirty_ComputedSideEffect) {
      triggerRefValue(self, DirtyLevels.MaybeDirty_ComputedSideEffect)
    }
    return self._value
  }

如果这个计算变量的 effect 被非计算变量触发,则它的self.effect.dirtyDirtyLevels.Dirty,如果是被计算变量触发,重复上述流程。如果计算变量的值有变化则调用函数triggerRefValue(self, DirtyLevels.Dirty),把后续的 effect 的_dirtyLecel设置为DirtyLevels.Dirty。在上面的 RenderEffect 中,get dirty返回 true,继续scheduler的逻辑。

const update: SchedulerJob = (instance.update = () => {
  if (effect.dirty) {
    effect.run()
  }
})

不严谨地简单地总结一下:

sequenceDiagram
  计算变量-->>ComputedEffect: this.effect 指针
  ComputedEffect-->>计算变量: this.computed 指针
  Note left of ComputedEffect: 依赖的非计算变量<br>响应式变量改变<br>trigger 被调用
  ComputedEffect->>其他 effect: triggerEffects
  ComputedEffect->>其他 effect: _dirtyLevel = MaybeDirty
  ComputedEffect->>其他 effect: 调用 scheduer
  其他 effect ->> 其他 effect: if (this.dirty)
  其他 effect ->> 其他 effect: get dirty
  其他 effect ->> 计算变量: triggerComputed 也就是 get value
  计算变量 ->> 计算变量: run 重新计算计算变量的值
  alt 计算变量改变了
      计算变量->>其他 effect: triggerEffects
      计算变量->>其他 effect: _dirtyLevel = Dirty
      其他 effect ->> 其他 effect: this.dirty 为 true,执行 run 方法
  else 计算变量没有变
      其他 effect ->> 其他 effect: this.dirty 为 false,无事发生
  end

举个栗子🌰:

<script src="../../dist/vue.global.js"></script>

<div id="demo">
  <h1 @click="handler">{{ data }}</h1>
</div>

<script>
const { createApp, ref, computed, watch } = Vue

createApp({
  setup() {
    const test = ref(0)
    const data = computed(() => {
      return Math.floor(test.value / 2)
    })
    const handler = () => {
      console.log('click')
      test.value++
    }
    watch(data, () => {
      console.log('watch');
    })
    return {
      handler, data
    }
  }
}).mount('#demo')
</script>

在点击了按钮后,第一次点击不会触发watch,因为计算变量data的值没有改变,第二次点击才会触发watch。我们在组件的 RenderEffect log 一下也会发现第一次点击不会触发组件更新。

结语

本文对 effect 的机制进行了回顾,介绍了 Vue 3.4 的计算变量脏检查机制。在 Vue 3.4 中,计算变量值没有改变,不会重复触发后续的 effect,减少了没有必要的渲染和计算,提高了响应式系统的性能。

有什么不足请批评指正...... 大家的阅读是我发帖的动力。
另外,这是我的个人博客:deerblog.gu-nami.com/