CommanderXL / Biu-blog

个人博客
431 stars 39 forks source link

Vue3.4 effect-drity-check 机制 #60

Open CommanderXL opened 9 months ago

CommanderXL commented 9 months ago

框架现状

在2023.12.28日刚发布的 Vue3.4 版本当中重构了部分响应式系统的功能。博客当中举了一个例子:

vue3 4-blog

在之前的版本当中,count.value 发生变化的话,但是 isEven.value 不一定真正的发生了变化,但是仍然会再次触发 watchEffect 的执行。主要的原因还是在于之前的 computed effect 的设计,computed 依赖的响应式数据发生了变化之后,computed effect scheduler 会立即触发对其产生依赖的 effect。所以在这个例子当中,count.value 发生了变化,触发 computed effect 进而也就触发了 watch effect 的执行。

由这个简单的例子可以继续推导下,在 Vue 框架内部基于 ReactiveEffect 封装了更加上层的响应式 api 的使用场景,包括:

在不同的使用场景下,这些 effect 都可以和 computed 数据建立起依赖关系。

computed-value-effects

那么不管是以上哪种依赖关系, computed 数据在 re-computed 的过程当中都是可能会出现上述例子当中出现的:computed 数据的值实际没有变化,但是 effect 会重新执行的情况,从而导致了一些不必要的性能损耗。

那么为了优化这种场景,Vue3.4 引入了 effect dirty check 机制:

ReactiveEffect 重构

首先来看下 ReactiveEffect 重构后几个大的变化:

export class ReactiveEffect {
  ...
  _dirtyLevel = DirtyLevels.Dirty
  ...

  constructor(
    public fn: () => T,
    public trigger: () => void,
    public scheduler?: EffectScheduler,
    scope?: EffectScope
  ) {
    recordEffectScope(this, scope)
  }

  public get dirty() {
    if (this._dirtyLevel === DirtyLevels.ComputedValueMaybeDirty) {
      this._dirtyLevel = DirtyLevels.NotDirty // computed 数据访问过一次后,置为 NotDirty
      this._queryings++ // 针对查询 computed 数据的设置(不涉及依赖关系的建立)
      pauseTracking() // 暂停依赖的收集
      for (const dep of this.deps) {
        if (dep.computed) {
          triggerComputed(dep.computed) // 访问存在依赖关系的 computed 数据,调用 computed value getter,看是否发生了变化,如果发生了变化,动态的改变当前依赖的 effect dirty 值,进而最终会执行 effect scheduler 
          if (this._dirtyLevel >= DirtyLevels.ComputedValueDirty) {
            break
          }
        }
      }
      resetTracking()
      this._queryings--
    }
    return this._dirtyLevel >= DirtyLevels.ComputedValueDirty
  }
}

computed 重构

export class ComputedRefImpl<T> {
  ...
  constructor() {
    this.effect = new ReactiveEffect(
      () => getter(this._value),
      () => triggerRefValue(this, DirtyLevels.ComputedValueMaybeDirty)
    )
  }
  get value() {
    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
  }
  ...
}

triggerEffects 重构

export function triggerEffects(
  dep: Dep,
  dirtyLevel: DirtyLevels
  ...
) {
  pauseScheduling() // 只能保证在当前 triggerEffects 的嵌套 triggerEffects 当中不会触发 effect scheduler 函数
  for (const effect of dep.keys()) {
    if (!effect.allowRecurse && effect._runnings) {
      continue
    }
    if (
      effect._dirtyLevel < dirtyLevel &&
      (!effect._runnings || dirtyLevel !== DirtyLevels.ComputedValueDirty) // runnings 当前 effect 是否正在执行
    ) {
      const lastDirtyLevel = effect._dirtyLevel
      effect._dirtyLevel = dirtyLevel
      if (
        lastDirtyLevel === DirtyLevels.NotDirty &&
        (!effect._queryings || dirtyLevel !== DirtyLevels.ComputedValueDirty)
      ) {
        if (__DEV__) {
          effect.onTrigger?.(extend({ effect }, debuggerEventExtraInfo))
        }
        effect.trigger() // 确保 computed trigger 先执行
        if (effect.scheduler) {
          queueEffectSchedulers.push(effect.scheduler)
        }
      }
    }
  }
  resetScheduling()
}

再回到在 Blog 当中的例子,看下响应式数据发生变化后整个 effect 依赖关系触发流程重构前后的工作流程:

refactor

在优化后的流程当中依据依赖关系触发 effect scheduler 的流程没太大变化,不过在触发 effect 的过程当中新增了对于 effect dirty 状态的更新,尤其是 computed 触发其依赖 effect 会将对应的 dirty 状态更新为 ComputedValueMaybeDirty,进入到 effect scheduler 调用的流程当中通过对 effect dirty check 来决定是否进行 scheduler 后续的流程(开发者需要手动调用),也就是 effect scheduler 后续的调用。

那么对于 effect dirty check 的流程来说,实际也就是看和当前 effect 有依赖关系的 computed 数据是否真的发生了变化(触发 computed value getter 的过程),一旦有一个 computed 数据发生了变化也就会更新 effect dirty 的状态为 ComputedValueDirty

基于 ReactiveEffect 的上层封装

ReactiveEffect 是 @vue/reactivity 所暴露出最重要最底层的用以搭建整个响应式系统的 api,那么如果要基于 3.4 版本后的 ReactiveEffect 去封装上层的响应式 api 有两点需要注意:

  1. ReactiveEffect 接受的 trigger 函数为必传;
  2. 可以在 effect scheduler 函数当中可以进行 effect dirty check 来决定 effect 是否需要执行用以提升性能;
jaskang commented 9 months ago

到位。

我这么理解对不对 ,就是先 pauseTracking 跑一遍 getter 看数据变没变,没变的话后边的 effect 就不用跑了。

CommanderXL commented 9 months ago

@JasKang 嗯你的理解是对的。dirty check 如果没有发生变化的话,后续 effect 的代码就不需要执行了。

const effect = new ReactiveEffect(fn, () => {}, () => {
   if (effect.dirty) {
      effect.run()
   }
})
githubxiaowen commented 7 months ago

这个逻辑在响应式系统里还挺常见的,思路是在执行具体的effect时前置判断一下依赖是否真的发生了变化

angular的signal和mobx是通过维护值版本来搞的,推荐下面两个文档,对响应式系统的常见问题和解法做了详细介绍。

https://github.com/angular/angular/blob/main/packages/core/primitives/signals/README.md#equality-semantics

https://en.wikipedia.org/wiki/Reactive_programming

对于一个表格里的公式单元格,其实也应用到类似思路,A -> B -> C。当C的内容发生变化时,A和B被标脏,在计算A时,可以前置判断B是否真的发生了变化,如果没有变化,是不需要重新计算值/执行副作用的。

CommanderXL commented 7 months ago

@githubxiaowen 感谢分享