coverPiccoverPic

Vue 3.4 响应式更新:计算变量优化

前言

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

effect 回顾

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

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

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

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

ts
  1. export class ReactiveEffect<T = any> {
  2. // ...
  3. active = true
  4. deps: Dep[] = []
  5. this._dirtyLevel = DirtyLevels.Dirty
  6. // 如果是 computed 创建的,指向对应的 computed 变量
  7. computed?: ComputedRefImpl<T>
  8. constructor(
  9. public fn: () => T,
  10. public trigger: () => void,
  11. public scheduler?: EffectScheduler,
  12. scope?: EffectScope,
  13. ) {
  14. recordEffectScope(this, scope)
  15. }
  16. // ...
  17. run() {
  18. this._dirtyLevel = DirtyLevels.NotDirty
  19. if (!this.active) {
  20. return this.fn()
  21. }
  22. let lastShouldTrack = shouldTrack
  23. let lastEffect = activeEffect
  24. try {
  25. shouldTrack = true
  26. activeEffect = this
  27. this._runnings++
  28. preCleanupEffect(this)
  29. return this.fn()
  30. } finally {
  31. postCleanupEffect(this)
  32. this._runnings--
  33. activeEffect = lastEffect
  34. shouldTrack = lastShouldTrack
  35. }
  36. }
  37. // ...
  38. }

effect 初始化_dirtyLevel就是DirtyLevels.Dirty,当run方法被调用时变成DirtyLevels.NotDirty。当被响应式变量触发时,如果不是计算变量,_dirtyLevel则为DirtyLevels.Dirty,计算变量的情况见下文。而run方法会在 effect 被触发后,以某种方式被调用,重新变成DirtyLevels.NotDirty。个人理解,换句话说,effect 被触发后就是脏的,对响应式变量改变做出处理后就不脏了。

这里有 3 个重要的属性:fntriggerscheduler,它们从构造函数传入。

  1. fn通常会被 effect 的run方法调用,此时 effect 会被记录在全局变量activeEffect中,触发了get方法的响应式变量可以将之收集。同时也有其它用途。例如 RenderEffect 的fn用于挂载组件。WatchEffect 的话,在watch(valGetter, cb)中,就是第一个参数,而在watchEffect(cb)API 中即为其回调函数。ComputedEffect 的fn也是传入的函数。
  2. triggerschedule通常在依赖被触发时调用,会以某种方式调用run方法回应响应式变量的更新。
  3. trigger通常用于引起其它 effect 状态的改变。 ComputedEffect 的trigger将会触发其收集到的 effect。RenderEffect 和 WatchEffect,则为空。
  4. 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 3.4 的更新给计算变量和 effect 加上了DirtyLevels的机制。先来看看定义:

ts
  1. export enum DirtyLevels {
  2. NotDirty = 0,
  3. QueryingDirty = 1,
  4. MaybeDirty_ComputedSideEffect = 2,
  5. MaybeDirty = 3,
  6. Dirty = 4,
  7. }

流程解析

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

ts
  1. export class ComputedRefImpl<T> {
  2. // ...
  3. constructor(
  4. getter: ComputedGetter<T>,
  5. private readonly _setter: ComputedSetter<T>,
  6. isReadonly: boolean,
  7. isSSR: boolean,
  8. ) {
  9. this.effect = new ReactiveEffect(
  10. // 它的 fn 就是 computed 传入的函数
  11. () => getter(this._value),
  12. // 这里是 trigger 方法
  13. () =>
  14. triggerRefValue(
  15. this,
  16. this.effect._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect
  17. ? DirtyLevels.MaybeDirty_ComputedSideEffect
  18. : DirtyLevels.MaybeDirty,
  19. ),
  20. )
  21. this.effect.computed = this
  22. this.effect.active = this._cacheable = !isSSR
  23. this[ReactiveFlags.IS_READONLY] = isReadonly
  24. }
  25. }

此时computed变量收集到的 effect 的_dirtyLecel被设置为DirtyLevels.MaybeDirty或者DirtyLevels.MaybeDirty_ComputedSideEffect

ts
  1. export function triggerRefValue(
  2. ref: RefBase<any>,
  3. dirtyLevel: DirtyLevels = DirtyLevels.Dirty,
  4. newVal?: any,
  5. ) {
  6. ref = toRaw(ref)
  7. const dep = ref.dep
  8. if (dep) {
  9. triggerEffects(
  10. dep,
  11. dirtyLevel,
  12. void 0,
  13. )
  14. }
  15. }
  16. export function triggerEffects(
  17. dep: Dep,
  18. dirtyLevel: DirtyLevels,
  19. debuggerEventExtraInfo?: DebuggerEventExtraInfo,
  20. ) {
  21. pauseScheduling()
  22. for (const effect of dep.keys()) {
  23. // dep.get(effect) is very expensive, we need to calculate it lazily and reuse the result
  24. let tracking: boolean | undefined
  25. if (
  26. effect._dirtyLevel < dirtyLevel &&
  27. (tracking ??= dep.get(effect) === effect._trackId)
  28. ) {
  29. // 只有 NotDirty 的才能被触发依赖,避免了多个响应式变量同时改变,多次触发依赖的情况
  30. effect._shouldSchedule ||= effect._dirtyLevel === DirtyLevels.NotDirty
  31. effect._dirtyLevel = dirtyLevel
  32. }
  33. if (
  34. effect._shouldSchedule &&
  35. (tracking ??= dep.get(effect) === effect._trackId)
  36. ) {
  37. effect.trigger()
  38. if (
  39. (!effect._runnings || effect.allowRecurse) &&
  40. effect._dirtyLevel !== DirtyLevels.MaybeDirty_ComputedSideEffect
  41. ) {
  42. effect._shouldSchedule = false
  43. if (effect.scheduler) {
  44. queueEffectSchedulers.push(effect.scheduler)
  45. }
  46. }
  47. }
  48. }
  49. resetScheduling()
  50. }

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

ts
  1. const effect = (instance.effect = new ReactiveEffect(
  2. // 这个函数也就是 effect 的 fn,将会触发组件挂载或者更新
  3. componentUpdateFn,
  4. NOOP,
  5. () => queueJob(update),
  6. instance.scope, // track it in component's effect scope
  7. ))
  8. const update: SchedulerJob = (instance.update = () => {
  9. if (effect.dirty) {
  10. effect.run()
  11. }
  12. })

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

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

ts
  1. export class ReactiveEffect<T = any> {
  2. // ...
  3. deps: Dep[] = []
  4. computed?: ComputedRefImpl<T>
  5. _dirtyLevel = DirtyLevels.Dirty
  6. _depsLength = 0
  7. //...
  8. public get dirty() {
  9. if (
  10. this._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect ||
  11. this._dirtyLevel === DirtyLevels.MaybeDirty
  12. ) {
  13. this._dirtyLevel = DirtyLevels.QueryingDirty
  14. pauseTracking()
  15. for (let i = 0; i < this._depsLength; i++) {
  16. const dep = this.deps[i]
  17. if (dep.computed) {
  18. triggerComputed(dep.computed)
  19. if (this._dirtyLevel >= DirtyLevels.Dirty) {
  20. break
  21. }
  22. }
  23. }
  24. if (this._dirtyLevel === DirtyLevels.QueryingDirty) {
  25. this._dirtyLevel = DirtyLevels.NotDirty
  26. }
  27. resetTracking()
  28. }
  29. return this._dirtyLevel >= DirtyLevels.Dirty
  30. }
  31. // ...
  32. }
  33. function triggerComputed(computed: ComputedRefImpl<any>) {
  34. return computed.value
  35. }

如果它被计算变量收集了的话,它会检查计算变量的值事实上有无变化,这里触发了计算变量computedget value方法。

ts
  1. export class ComputedRefImpl<T> {
  2. public dep?: Dep = undefined
  3. private _value!: T
  4. public readonly effect: ReactiveEffect<T>
  5. public _cacheable: boolean
  6. // ...
  7. get value() {
  8. const self = toRaw(this)
  9. if (
  10. (!self._cacheable || self.effect.dirty) &&
  11. hasChanged(self._value, (self._value = self.effect.run()!))
  12. ) {
  13. // 触发 triggerEffects 的时候,effect._dirtyLevel === DirtyLevels.NotDirty 为 false
  14. // 因为上文被触发的 effect get dirty 的时候将其设置为 DirtyLevels.QueryingDirty
  15. // 不会再次触发后续的 effect
  16. triggerRefValue(self, DirtyLevels.Dirty)
  17. }
  18. trackRefValue(self)
  19. // 这种情况只有在计算变量的函数中修改了计算变量依赖的响应式变量才会触发
  20. if (self.effect._dirtyLevel >= DirtyLevels.MaybeDirty_ComputedSideEffect) {
  21. triggerRefValue(self, DirtyLevels.MaybeDirty_ComputedSideEffect)
  22. }
  23. return self._value
  24. }
  25. // ...
  26. }

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

ts
  1. const update: SchedulerJob = (instance.update = () => {
  2. if (effect.dirty) {
  3. effect.run()
  4. }
  5. })

简单地总结一下:

graph LR
A(响应式变量 非计算变量)-->|"① 依赖触发 Dirty"|B(ComputedEffect effect0)-->|"② 依赖触发 MaybeDirty"|C(其他类型的依赖 effect1)
C-->|"⑥ if Dirty"|D("effect1.run()")
B-->|"⑤ 值改变了 Dirty 没有则 NotDirty"|C
F(计算变量)-->|"④ effect0.dirty &&\nself._value = effect0.run()"|B
C-->|"③ 执行 run 方法前\n检测值是否改变"|F

举个栗子🌰:

ts
  1. <script src="../../dist/vue.global.js"></script>
  2. <div id="demo">
  3. <h1 @click="handler">{{ data }}</h1>
  4. </div>
  5. <script>
  6. const { createApp, ref, computed, watch } = Vue
  7. createApp({
  8. setup() {
  9. const test = ref(0)
  10. const data = computed(() => {
  11. return Math.floor(test.value / 2)
  12. })
  13. const handler = () => {
  14. console.log('click')
  15. test.value++
  16. }
  17. watch(data, () => {
  18. console.log('watch');
  19. })
  20. return {
  21. handler, data
  22. }
  23. }
  24. }).mount('#demo')
  25. </script>

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

结语

本文对 effect 的机制进行了回顾,介绍了 Vue 3.4 的计算变量脏检查机制。在 Vue 3.4 中,计算变量值没有改变,不会重复触发后续的 effect。

1 条评论未登录用户
Ctrl or + Enter 评论
27Onion Nebell
👀
3 months agoReply to
🌸 Run