Vue 3.4+ 重新认识 computed

2,199 阅读8分钟

Vue3.4+版本之前,计算属性只有在被读取时,才会触发评估检测(evaluate)逻辑,如果此时drity === true,则触发getter重新计算。而drity会在计算属性依赖项变化时,被更新为true,逻辑自洽,但并不完美,因为这里存在一个误区:依赖项改变并不意味着计算属性结果的改变

计算属性的惰性求值(lazy)

来看个简单的例子:

假设我们有一个项目列表,以及一个增加计数器的按钮。一旦计数器达到 100,我们想以相反的顺序显示列表(尽管它并不实际,但能说明问题)

注意: 此例子运行在vue3.4 版本之前

<template>
  <button @click="increase">
    Click me
  </button>
  <br>
  <h3>
    List
  </h3>
  <ul>
    <li v-for="item in sortedList">
      {{ item }}
    </li>
  </ul>
</template>

<script setup>
import { ref, reactive, computed, onUpdated } from 'vue'

const list = reactive([1,2,3,4,5])

const count = ref(0)
function increase() {
  count.value++
}

const isOver100 = computed(() => count.value > 100)

const sortedList = computed(() => {
  // 想象一下这里有非常昂贵的计算
  return isOver100.value ? [...list].reverse() : [...list]
})

onUpdated(() => {
  console.log('component re-rendered!')
})
</script>

不妨想想,当我们点击按钮101次后,我们的组件将重新渲染多少次?

答案:101次!

我们来拆解一下具体的执行过程:

  1. 点击按钮,count增加。此时组件不会更新,因为我们没有直接在template中使用count
  2. 由于count发生了改变,isOver100属性被标记为 dritytrue)。这意味着isOver100在下次被读取时,必须重新计算结果(惰性求值
  3. 由于惰性求值lazy),我们并不知道isOver100重新评估的结果是否仍会为false
  4. sortedList依赖isOver100的结果,所以它也必须被标记为 。同样由于sortedList也是计算属性,所以仍然会在下次读取时重新计算结果
  5. 由于template依赖了sortedList,且sortedList被标记为脏,所以组件需要重新渲染
  6. 重新渲染组件,触发sortedList的取值,这个过程运行了可能昂贵sortedList计算,尽管大部分情况下并不需要

这里真正的罪魁祸首是isOver100,它是一种经常性的计算,通常返回和上一次相同的值,最重要的是它是一种廉价操作,没有真正从缓存中受益。它在一种昂贵的计算(确实从缓存中受益)中使用时,触发了不必要的更新,可能会严重降低代码性能。

但这并不意味着,我们将isOver100作为计算属性,是一个错误的选择,很多时候我们确实需要计算属性自动基于其他状态派生出目标状态的能力,在这种场景(非昂贵计算)下,计算属性的惰性计算(lazy)能力似乎没有必要,反而可能会导致性能问题,那么你可能需要 computedEager

这里我们忽略了一个问题,计算属性的依赖方是如何得知该计算属性的依赖项发生了变化?或者说计算属性的依赖项改变时是如何通知依赖该计算属性的watcher/effect重新计算或渲染的?

Vue3.4 版本之前

vue3.4版本之前,计算属性的依赖会同步被依赖该计算属性的watcher/effct收集,以上述例子为例:

vue2.pngVue2版本中,Ref count同时被isOver100/sortedList/template render依赖,因此Ref Count改变时,会同时触发isOver100/sortedList/template render的更新逻辑。 vue3.png Vue3版本之后(vue3.4之前),依赖关系略有不同,但整体的执行过程一致

详细的执行过程如下:

  1. 点击按钮,count 增加,依次触发isOver100/sortedList/template render的更新逻辑
  2. isOver100为计算属性,触发更新时,将drity设置为true,等待下次取值时计算
  3. 同理,sortedList也为计算属性,也将drity设置为true,等待下次取值时计算
  4. template render为渲染函数,接收到依赖更新的消息后,会立即重新运行render函数更新视图, render函数,依赖sortedList,所以会触发sortedList取值
  5. sortedList重新计算,由于sortedList依赖isOver100,所以会递归触发isOver100的取值逻辑
  6. isOver100重新计算,获取最新的count值计算最新结果并返回
  7. sortedList拿到isOver100的最新结果,重新计算后返回
  8. template render拿到sortedList的最新结果,重新渲染

可以看到,如果在第7步时,能够发现isOver100的值没有改变,那么7、8两步的计算就不需要进行,尤其是减少template render的执行,会显著提高Vue性能。

幸运的是,Vue团队在3.4版本响应式系统做出了重大的升级,其中就包括这一点的优化,我们来看一下

Vue3.4+ 版本的改进

Vue 3.4.0版本为例,computed内部依赖的ReactiveEffect对象新增了_dirtyLevel这个内部属性,值类型如下:

// core-3.4.0/packages/reactivity/src/constants.ts
export enum DirtyLevels {
  NotDirty = 0,
  ComputedValueMaybeDirty = 1,
  ComputedValueDirty = 2,
  Dirty = 3,
}

分别代表影响effct是否重新执行的四种值状态,其中ComputedValueMaybeDirty/ComputedValueDirty这两个状态主要用在计算属性的场景中

计算属性的取值.value)这块使用了hasChanged方法来判断新值是否发生改变

// core-3.4.0/packages/reactivity/src/computed.ts
export class ComputedRefImpl<T> {
  private _value!: T
  // ...
  get value() {
    // the computed ref may get wrapped by other proxies e.g. readonly() #3376
    const self = toRaw(this)
    // 触发状态收集
    trackRefValue(self)
    if (!self._cacheable || self.effect.dirty) {
      if (hasChanged(self._value, (self._value = self.effect.run()!))) {
        triggerRefValue(self, DirtyLevels.ComputedValueDirty)
      }
    }
    return self._value
  }
  // ...
}

// core-3.4.0/packages/shared/src/general.ts
export const hasChanged = (value: any, oldValue: any): boolean =>
  !Object.is(value, oldValue)

这里有两处关键的逻辑,一个是self.effect.dirty属性的判断,另一个是triggerRefValue方法的触发

仍然以之前的例子作为切入点,分析一下:

  1. count值发生改变,触发isOver100的更新,此时isOver100_dirtyLevel更新为Dirty
// core-3.4.0/packages/reactivity/src/reactiveEffect.ts
export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>,
) {
  // ...
  // Ref count 的deps中包含了 isOver100 这个计算属性
  for (const dep of deps) {
    if (dep) {
      triggerEffects(
        dep,
        DirtyLevels.Dirty,
        __DEV__
          ? {
              target,
              type,
              key,
              newValue,
              oldValue,
              oldTarget,
            }
          : void 0,
      )
    }
  }
  // ...
}

// core-3.4.0/packages/reactivity/src/effect.ts
export function triggerEffects(
  dep: Dep,
  dirtyLevel: DirtyLevels,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo,
) {
  pauseScheduling()
  for (const effect of dep.keys()) {
    if (!effect.allowRecurse && effect._runnings) {
      continue
    }
    if (
      effect._dirtyLevel < dirtyLevel &&
      (!effect._runnings || dirtyLevel !== DirtyLevels.ComputedValueDirty)
    ) {
      const lastDirtyLevel = effect._dirtyLevel
      // 此处将 isOver100 的 _dirtyLevel 设置为 Dirty
      effect._dirtyLevel = dirtyLevel
      if (
        lastDirtyLevel === DirtyLevels.NotDirty &&
        (!effect._queryings || dirtyLevel !== DirtyLevels.ComputedValueDirty)
      ) {
        if (__DEV__) {
          effect.onTrigger?.(extend({ effect }, debuggerEventExtraInfo))
        }
        // 触发 isOver100 的 trigger 方法
        effect.trigger()
        if (effect.scheduler) {
          queueEffectSchedulers.push(effect.scheduler)
        }
      }
    }
  }
  resetScheduling()
}
  1. isOver100trigger方法中,会将依赖isOver100effct _dirtyLevel设置为ComputedValueMaybeDirty,即sortedList计算属性
// core-3.4.0/packages/reactivity/src/computed.ts
export class ComputedRefImpl<T> {
  public dep?: Dep = undefined

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

  public readonly __v_isRef = true
  public readonly [ReactiveFlags.IS_READONLY]: boolean = false

  public _cacheable: boolean

  constructor(
    getter: ComputedGetter<T>,
    private readonly _setter: ComputedSetter<T>,
    isReadonly: boolean,
    isSSR: boolean,
  ) {
    this.effect = new ReactiveEffect(
      () => getter(this._value),
      // computed effct 计算属性的 trigger 方法
      () => triggerRefValue(this, DirtyLevels.ComputedValueMaybeDirty),
    )
    this.effect.computed = this
    this.effect.active = this._cacheable = !isSSR
    this[ReactiveFlags.IS_READONLY] = isReadonly
  }
  
  // ...
}

// core-3.4.0/packages/reactivity/src/ref.ts
export function triggerRefValue(
  ref: RefBase<any>,
  dirtyLevel: DirtyLevels = DirtyLevels.Dirty,
  newVal?: any,
) {
  ref = toRaw(ref)
  const dep = ref.dep
  if (dep) {
    triggerEffects(
      dep,
      dirtyLevel, // 此处依赖isOver100的 effct 设置为 ComputedValueMaybeDirty 
      __DEV__
        ? {
            target: ref,
            type: TriggerOpTypes.SET,
            key: 'value',
            newValue: newVal,
          }
        : void 0,
    )
  }
}
  1. 此时,sortedList被设置为ComputedValueMaybeDirty,同时也会将template/render设置为ComputedValueMaybeDirty
// core-3.4.0/packages/runtime-core/src/renderer.ts 1572
// create reactive effect for rendering
const effect = (instance.effect = new ReactiveEffect(
  componentUpdateFn,
  NOOP,
  // render函数内部的ReactiveEffect采用了scheduler调度的方式
  () => queueJob(update),
  instance.scope, // track it in component's effect scope
))

const update: SchedulerJob = (instance.update = () => {
  // 此处调用的dirty方法 
  if (effect.dirty) {
    effect.run()
  }
})

effect.dirty方法是一个getter函数,内部逻辑如下:

export class ReactiveEffect<T = any> {
  // ...
  public get dirty() {
    if (this._dirtyLevel === DirtyLevels.ComputedValueMaybeDirty) {
      this._dirtyLevel = DirtyLevels.NotDirty
      this._queryings++
      pauseTracking()
      for (const dep of this.deps) {
        if (dep.computed) {
          triggerComputed(dep.computed)
          if (this._dirtyLevel >= DirtyLevels.ComputedValueDirty) {
            break
          }
        }
      }
      resetTracking()
      this._queryings--
    }
    return this._dirtyLevel >= DirtyLevels.ComputedValueDirty
  }
  // ...
}

function triggerComputed(computed: ComputedRefImpl<any>) {
  return computed.value
}
  1. 由于render函数中的effct deps依赖了sortedList这个计算属性,所以会触发计算属性的取值逻辑(代码见上),在取值逻辑中会递归的触发 sortedList drity -> isOver100 drity
  2. isOver100 drity逻辑触发时,由于_dirtyLevelDirty,且大于ComputedValueDirty,返回ture, 触发后续的hasChanged逻辑
  3. 在这个场景中,大部分情况下,hasChanged会返回false,不会触发triggerRefValue(self, DirtyLevels.ComputedValueDirty)这段逻辑,那么后续sortedList drity的判断中,ComputedValueMaybeDirty 小于 ComputedValueDirty,返回false,后续取值函数直接返回原值,不重新计算,render函数同理,不会触发effect.run()
  4. 如果isOver100hasChanged返回true,触发triggerRefValue,在triggerRefValue中会将sortedListefftc _dirtyLevel设置为ComputedValueDirty,后续sortedList drity的判断会返回true,继而递归导致render函数的effect.drity这段逻辑返回true,导致render函数重新运行

总的来说,Vue3.4之后,在保留计算属性已有特性(lazy)的基础上,通过_dirtyLevel这一机制,大大优化了Vue组件状态更新时的开销

补充

vue3.4版本之后,计算属性的getter方法触发时,可以通过参数获得之前的旧值,这在计算属性计算一些引用类型时比较有用,可以避免一些不必要的计算,文档地址

<script setup>
import { ref, computed } from 'vue'

const count = ref(2)

// 这个计算属性在 count 的值小于或等于 3 时,将返回 count 的值。
// 当 count 的值大于等于 4 时,将会返回满足我们条件的最后一个值
// 直到 count 的值再次小于或等于 3 为止。
const alwaysSmall = computed((previous) => {
  if (count.value <= 3) {
    return count.value
  }

  return previous
})
</script>

注意此方式在部分vue 3.5可能不可用(3.5.0/3.5.1)