第三章-Vue3的EffectScope的实现原理

224 阅读6分钟

前情提要:上一章介绍了effect函数的优化部分——使用trackOpBit记录响应式副作用(ReactiveEffect)的嵌套深度,并记录wasTrack和newTrack状态代替了cleanup函数,以"打Tag"的方式优化了cleanup函数必要的O(n)循环(虽然在超过阈值时也是采用的cleanup的方式),大大提高了响应式副作用的收集效率。而本章节将介绍EffectScope,该对象用于给ReactiveEffect进行划区。

1. EffectScope的存在背景

effectScope的介绍参考 vue3的effectScope

我们在使用Vue3开发时,会频繁的使用watch,computed等API,而这些API内部会隐式的创建ReactiveEffect对象,该对象的作用就是当我们监听的数据发生改变时,用于重新触发我们的回调函数。

watch(obj.msg, () => console.log(obj.msg))

例如上述代码所示,当obj.msg发生改变时我们会重新打印它的值。我们在日常开发中是配合Vue的组件一起使用的,所以当Vue的组件被卸载时,Vue框架会帮助我们销毁这些响应式副作用。(简单理解为Reactive实例创建后会被Set,Queue等数据结构所引用,所以即使不触发也一直占用内存,Vue框架在组件卸载时会帮助我们清理这些没用的内存)。但是,如果我们不适用Vue的组件的前提下,使用watch,computed等API时,我们就需要手动的去销毁这些响应式副作用。

const disposables = []

const counter = ref(0)
const doubled = computed(() => counter.value * 2)

disposables.push(() => stop(doubled.effect))

const stopWatch1 = watchEffect(() => {
  console.log(`counter: ${counter.value}`)
})

disposables.push(stopWatch1)

const stopWatch2 = watch(doubled, () => {
  console.log(doubled.value)
})

disposables.push(stopWatch2)

类似上述代码(我们不需要关注stop函数的实现,而只需要关注这一思想),由此可见每次ReactiveEffect生成时我们都需要收集它,这非常的影响使用,一旦遗漏了就会产生内存占用。

2. EffectScope解决的问题

function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function handler(e) {
    x.value = e.x
    y.value = e.y
  }

  window.addEventListener('mousemove', handler)

  onUnmounted(() => {
    window.removeEventListener('mousemove', handler)
  })

  return { x, y }
}

以上是官网上关于effectScope解决问题的一个实例,可见示例是想实现一个对于鼠标位置的监听,在对象实例化时绑定了mousemove事件,在组件卸载时移除监听。该函数每次调用都会产生新实例,并且重复监听,这并不合理,应该将监听和refs提到函数外部,但是不能这样做,应该onUnmounted是和Vue的组件一对一绑定的,可以想象到如果同时有两个组件需要鼠标位置坐标,而其中一个组件先卸载了,那时监听便会移除,会导致另一个组件出错。

- onUnmounted(() => {
+ onScopeDispose(() => {
  window.removeEventListener('mousemove', handler)
})

采用上述修改则使得移除监听的行为与组件完成了解耦,然后我们需要一个单元来集中管理这些effectScope。

function createSharedComposable(composable) {
  let subscribers = 0
  let state, scope

  const dispose = () => {
    if (scope && --subscribers <= 0) {
      scope.stop()
      state = scope = null
    }
  }

  return (...args) => {
    subscribers++
    if (!state) {
      scope = effectScope(true)
      state = scope.run(() => composable(...args))
    }
    onScopeDispose(dispose)
    return state
  }
}

const useSharedMouse = createSharedComposable(useMouse)

以上是管理effectScope的集中单元,可见当首次调用useSharedMouse时,会产生effectScope实例,并且是单实例,所以当其他页面调用时并不会产生多实例,其内部会有一个subscribers记录订阅者数量,当所有有订阅鼠标位置的实例都卸载的时候(组件卸载会调用onScopeDispose是因为setup函数的执行环境就是在一个effectScope的实例下),则会停止该单元的effectScope并执行移除监听事件(注:此时所有用到鼠标位置的页面已经全部卸载,移除监听的事件与组件没有耦合)。

3. EffectScope的各项参数和实现原理

export function effectScope(detached?: boolean) {
  return new EffectScope(detached)
}

effectScope函数调用时会创建一个EffectScopes实例,该实例管理ReactiveEffect的作用域范围。

 run<T>(fn: () => T): T | undefined {
    if (this._active) {
      const currentEffectScope = activeEffectScope
      try {
        activeEffectScope = this
        return fn()
      } finally {
        activeEffectScope = currentEffectScope
      }
    } else if (__DEV__) {
      warn(`cannot run an inactive effect scope.`)
    }
  }

然后调用effectScope.run(),run方法先用临时变量currentEffectScope指向上一个activeEffectScope,然后把activeEffectScope指向自己,完成上下文环境的转换,然后执行外部传入的fn,这时,当fn函数内部访问activeEffectScope时,则指向自身,当fn执行完毕后进行回溯。

export function effect<T = any>(
  fn: () => T,
  options?: ReactiveEffectOptions
): ReactiveEffectRunner {
//......
  const _effect = new ReactiveEffect(fn)
  if (options) {
    extend(_effect, options)
    if (options.scope) recordEffectScope(_effect, options.scope)
  }
//......
}

export function recordEffectScope(
  effect: ReactiveEffect,
  scope: EffectScope | undefined = activeEffectScope
) {
  if (scope && scope.active) {
    scope.effects.push(effect)
  }
}

fn的执行会调用effect函数的执行,而effect函数的执行在上一章中我们已经知道了它会产生ReactiveEffect实例,而观察上述代码可知,当options中带有EffectScope实例时会调用recordEffectScope函数,该函数的作用就是将ReactiveEffect实例收集到EffectScope实例的effects属性中。

stop(fromParent?: boolean) {
    if (this._active) {
      let i, l;
      for (i = 0, l = this.effects.length; i < l; i++) {
        // 调用scope收集到的响应式副作用的stop,使其失活
        this.effects[i].stop();
      }
      // 执行监听cleanup函数
      for (i = 0, l = this.cleanups.length; i < l; i++) {
        this.cleanups[i]();
      }
      if (this.scopes) {
        // 遍历当前scope内的scopes使其都停止
        for (i = 0, l = this.scopes.length; i < l; i++) {
          this.scopes[i].stop(true);
        }
      }
      // 表示该scope是嵌套的scope
      // stop的调用并不是来自于父scope的stop调用,而是该嵌套scope主动掉用的
      if (!this.detached && this.parent && !fromParent) {
        const last = this.parent.scopes!.pop();
        if (last && last !== this) {
          // 这是一个O(1)的删除操作,类似在数组中删除某一项而不引起数组的重排
          this.parent.scopes![this.index] = last;
          last.index = this.index;
        }
      }
      this.parent = undefined;
      this._active = false;
    }
  }

以上是effectScope的stop方法,当stop调用时会遍历effects,cleanups和scopes分别对应着收集ReactiveEffects的数组,onScopeDiscope的回调数组和子EffectScope数组,而其中最关键的是ReactiveEffect实例的stop执行。

  stop() {
    // stopped while running itself - defer the cleanup
    if (activeEffect === this) {
      this.deferStop = true
    } else if (this.active) {
      cleanupEffect(this)
      if (this.onStop) {
        this.onStop()
      }
      this.active = false
    }
  }
  
function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}

以上是ReactiveEffect的stop实现,关键点是cleanupEffect函数的执行从Set中删除了ReactiveEffect,避免了内存泄漏。另一个关键点是deferStop=true,需要延迟stop,下面将介绍为什么要延迟stop。

  run() {
     //......
    try {
      // ......
      if (effectTrackDepth <= maxMarkerBits) {
        initDepMarkers(this)
      } else {
        cleanupEffect(this)
      }
      return this.fn()
    } finally {
      // ......
      if (this.deferStop) {
        this.stop()
      }
    }
  }

以上代码是ReactiveEffect的run函数代码,也是上一章优化effect函数的代码,假设当obj.msg也就是代码对象的属性还没有访问时已经执行了effectScope的stop方法,如果没有deferStop,此时会清空effectScope的effects属性,然后再执行对obj.msg的访问,而obj.msg的访问会产生ReactiveEffect,这些ReactiveEffect实例会被收集到“桶”中,使得之前的清空功能失效,产生内存泄漏。所以effectScope的stop方法的执行,需要确保ReactiveEffect已经产生了,即应该在this.fn执行后再执行,所以需要deferStop。

总结:EffectScope完成了对ReactiveEffect的作用域划分,使得响应式原理在Vue的整体框架中耦合度变得更低,方便开发者更好的使用vue的响应式系统。