Vue3源码学习——副作用函数effect

675 阅读10分钟

上一节在介绍响应式原理的时候,一直有提到副作用函数effect,这一节,就深入的去了解一下effect

如果之前有了解过 Vue2 源码的朋友,大概也能感觉到,Vue3 的响应式原理和Vue2其实差别上并不大。

  • Vue2 中,主要是通过 Object.defineProperty 来劫持对象属性,同时为每一个属性实例化一个用于收集 watcher容器dep,在 get 阶段,将该属性相关的 watcher 维护进 dep 容器中,在 set 阶段,则去调用dep中所收集到的每个watcher的update方法完成更新。

Vue2响应式原理图:

image.png
  • Vue3中,劫持属性的方法更新为了Proxy,整体逻辑没有太大变化,依然是在get阶段收集依赖,在set阶段触发依赖,只不过这里原来在Vue2中,在每个属性的dep容器中收集到的watcher变成了副作用函数effect

effect

这一节我们重点想了解effect,那我们就从源码中找一下effect出现的位置。

我们先跟着上一节的足迹,看一下trackEffects函数

function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
    ...
    
  if (shouldTrack) {
      // activeEffect 被收集进dep中
    dep.add(activeEffect!)
    activeEffect!.deps.push(dep)

    ...
  }
}

在get阶段的 trackEffects 函数中,我们可以发现有一个 activeEffect 变量被收集进了 dep 中,我们大概就可以猜出这个 activeEffect 就是我们要找的 effect副作用函数而这个activeEffect 又是在哪里被赋值的呢?

这里我们定位到了 ReactiveEffect类,在这个类的 run方法中会将当前类的实例this赋值给activeEffect,那么这里我们就基本可以确定ReactiveEffect类即为我们要找的创建effect副作用函数的类。

class ReactiveEffect<T = any> {
  ...
  run() {
    ...
      this.parent = activeEffect
      activeEffect = this
    ...
}

ReactiveEffect类的内容较多,我们这里暂时先不看ReactiveEffect类的具体内容,先看看Vue3是在哪里创建ReactiveEffect实例的呢?

effect函数

function effect(fn,options) {
  // 如果fn已经是effect函数了,则指向原来的函数
  if (fn.effect) {
    fn = fn.effect.fn
  }

  // 创建ReactiveEffect实例
  const _effect = new ReactiveEffect(fn)
  if (options) {
    extend(_effect, options)
    if (options.scope) recordEffectScope(_effect, options.scope)
  }
  // 如果没有options或者不是懒加载则执行_effect.run
  if (!options || !options.lazy) {
    _effect.run()
  }
  // 将函数执行的方法返回出去
  const runner = _effect.run.bind(_effect)
  runner.effect = _effect
  return runner
}

这里我们定位到了effect函数,effect函数创建了 _effect 变量去接收创建出来的ReactiveEffect实例,在后面运行 _effect.run 方法,也就是我们上面提到的ReactiveEffect实例的run方法,将副作用函数effect维护进dep中。

关于effect这个API我们也可以在代码中进行尝试:

let obj = { count: 0 }
const state = reactive(obj)

effect(() => {
  console.log(state.count)
})

这样我们每次修改state.count的时候,控制台都会打印state.count的值。而我们在上一节响应式原理中有提到,响应式的实现是在get阶段收集副作用函数,在set阶段去触发副作用函数的执行。那么也就可以说明,我们传入effect的 fn 参数里面的变量肯定是被访问过的,换句话说,按照上一节的理论,我们就可以猜测这个传入的fn,一定是被执行过了的,那这次我们就带着我们的这一猜测去ReactiveEffect类中找答案:

ReactiveEffect类

// 响应上下文中的嵌套层次数
let effectTrackDepth = 0

// 二进制位,每一位用于标识当前effect嵌套层级的依赖收集的启用状态
let trackOpBit = 1

// 标识最大标记的层级数
const maxMarkerBits = 30

// 当前激活状态的effect
let activeEffect

// 设置追踪功能是否打开,上一节我们有提到在使用一些数组方法的时候需要关闭追踪
let shouldTrack = true

class ReactiveEffect<T = any> {
  // 用于标识副作用函数是否位于响应式上下文中被执行
  active = true
  
  // 用于收集副作用函数容器的数组
  // 在trackEffect函数中会执行以下代码,从而将收集当前副作用函数的容器维护进deps中:
  // ...
  // dep.add(activeEffect!)
  // activeEffect!.deps.push(dep)
  deps: Dep[] = []
  
  // 当effect发生嵌套的时候,指向上一层级的effect
  parent: ReactiveEffect | undefined = undefined

  // 省略一些暂时不关注的内容
  ...

  run() {
  // 若当前 ReactiveEffect 对象脱离响应式上下文,那么其对应的副作用函数被执行时不会再收集依赖
  // (active默认是true,在stop方法中可以被赋值为false,而stop方法通常在卸载环节被触发,
  // 不管是组件卸载还是监听器卸载,副作用函数当然不再需要被收集了,这样说是不是会更好理解一些?)
    if (!this.active) {
      return this.fn()
    }
    let parent: ReactiveEffect | undefined = activeEffect
    
    // 缓存是否需要收集依赖
    let lastShouldTrack = shouldTrack
    ...
    
    try {
      // 将上一层级的effect赋值给parent保存
      this.parent = activeEffect
      // 将当前层级的effect赋值给activeEffect
      activeEffect = this
      shouldTrack = true
      // 是一个二进制类型的值,每一位用于标识当前 `effect` 嵌套层级的依赖收集的启用状态
      trackOpBit = 1 << ++effectTrackDepth

        // 如果当前嵌套层级不超过30
      if (effectTrackDepth <= maxMarkerBits) {
        initDepMarkers(this)
      } else {
        cleanupEffect(this)
      }
      return this.fn()
    } finally {
      if (effectTrackDepth <= maxMarkerBits) {
        finalizeDepMarkers(this)
      }

      trackOpBit = 1 << --effectTrackDepth

      // 通过parent返回上一层嵌套的effect
      activeEffect = this.parent
      
      // 回退之前的shouldTrack值
      shouldTrack = lastShouldTrack
      
      // 清空parent指针
      this.parent = undefined

      if (this.deferStop) {
        this.stop()
      }
    }
  }

  stop() {
    if (activeEffect === this) {
      this.deferStop = true
    } else if (this.active) {
      cleanupEffect(this)
      if (this.onStop) {
        this.onStop()
      }
      this.active = false
    }
  }
}

ReactiveEffect类中,注释里每一步已经写的比较详细了,这里我们可以重点关注里面的run方法,先顺一下整个run方法的流程:

  • 首先,在run方法最开始执行的时候,我们判断当前副作用函数是否已经被卸载了,如果已经被卸载了,那我们只做执行函数的操作,不再进行下面的依赖收集逻辑。
  • 然后,利用parent这个指针将当前的effect副作用函数上下文存储下来,确保了当effect中出现嵌套副作用函数时不会出现作用域问题。
  • effectTrackDepth去记录当前嵌套的层级;trackOpBit是一个用二进制表示的数字,它在后面initDepMarkers以及finalizeDepMarkers函数中会用到,主要是用来维护该副作用函数在当前层级是否被追踪了
  • 根据嵌套层级来判断我们要执行的函数,如果嵌套层级大于30层,执行清除依赖的函数cleanupEffect,否则,执行initDepMarkers函数。
  • 执行我们传入的函数fn,在执行fn时会对其中涉及到的响应式数据进行依赖追踪。
  • 前面工作完成之后,执行finalizeDepMarkers函数,再将前面维护的一些变量一一复原。

过程中,我们涉及到了几个不明所以的函数,这里我们来一个一个看一看,逻辑也都不难。

cleanupEffect函数

我们先看当effect层级大于30层时会触发的cleanupEffect函数,相对来说比较简单: cleanupEffect函数主要是用来清空deps中所收集的副作用函数。

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
  }
}

initDepMarker函数

我们再看一下initDepMarker函数,该函数会在我们层级不大于30层时执行:

const initDepMarkers = ({ deps }: ReactiveEffect) => {
  if (deps.length) {
    // 遍历收集到的依赖,并为其上的w属性赋值,w代表wasTracked,值为通过或运算得到
    for (let i = 0; i < deps.length; i++) {
      deps[i].w |= trackOpBit 
    }
  }
}

initDepMarkers函数就是遍历deps,为其每一项上的w属性,通过与trackOpBit进行或等运算进行赋值,主要是用来维护该副作用函数在当前层级是否被追踪了,w属性则会在下面的finalizeDepMarkers函数中被使用。

当我们后面需要判断某个dep是否被追踪过,那么我们就可以用当前层级对应的trackOpBitdep的w属性进行&位运算:

const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0

这里可以举个例子:

比如我们在第一层嵌套的时候:
effectTrackDepth = 1
trackOpBit = 0000000000000000010
那此时当执行到initDepMarkers的时候,deps的每一项通过或等操作之后,对应的w属性即为:
dep.w = 0000000000000000010

当我们进入第二层嵌套的时候:
effectTrackDepth = 2
trackOpBit = 0000000000000000100
那么我们进行或等运算之后得到的dep.w就是0000000000000000100

那么如果我们想知道在第2层级,dep是否被追踪,就可以根据:
dep.w & trackOpBit = 0000000000000000100 & 0000000000000000100 = 100 (这里假设都是二进制表示)
那么dep.w & trackOpBit > 0 成立,说明该dep在第二层级被追踪过

finalizeDepMarkers函数

最后,当我们传入的函数执行完毕之后,也就是在run方法中调用this.fn之后,会进入到finally环节,执行finalizeDepMarkers函数,这个函数中,涉及了两个判断:wasTrackednewTracked,这里就用到了前面为dep赋值的w属性n属性

// 是否已被收集过
const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0

// 是否新收集
const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0

const finalizeDepMarkers = (effect: ReactiveEffect) => {
  const { deps } = effect
  if (deps.length) {
    let ptr = 0
    for (let i = 0; i < deps.length; i++) {
      const dep = deps[i]
      // 如果某个dep之前被收集过,但是在新的一轮收集中没有收集到,说明这次的副作用函数在被执行时不可能触发对应的effect执行,可以直接将对应的副作用函数清除
      if (wasTracked(dep) && !newTracked(dep)) {
        dep.delete(effect)
      } else {
        deps[ptr++] = dep
      }
      dep.w &= ~trackOpBit
      dep.n &= ~trackOpBit
    }
    deps.length = ptr
  }
}

// 此处额外提一下n属性(newTracked的由来),主要就是在收集依赖阶段对新收集的依赖进行标记
function trackEffects(...) {
    ...
  if (effectTrackDepth <= maxMarkerBits) {
    if (!newTracked(dep)) {
      dep.n |= trackOpBit 
      shouldTrack = !wasTracked(dep)
    }
  } 
  ...
}

finalizeDepMarkers函数的工作主要就是清除无效的副作用函数。举个🌰:

const state = reactive({ a: 1, show: true });

effect(() => {
  if (state.show) {
    console.log(`a: ${state.a}`)
  }
});

setTimeout(() => {
  state.show = false
  state.a++
}, 1000)

上面这个例子中,我们在控制台中只会打印一次1,这个根据逻辑我们也都能明白,可这里Vue3的内部还是有一点操作的:

  • 首先,在第一次执行effect的时候,我们将() =>{if(state.show){console.log(a: ${state.a})}}这个函数传入了effect中,在effect中我们执行了this.fn,所以我们就在控制台中打印出了1
  • 而我们在执行this.fn的时候,实际上也就访问了对应state.showstate.a,从而触发了它们的getter,进行了依赖收集,此时这个effect的deps中保存的就是两个dep
  • 当我们定时器结束之后,首先改变了state.show,所以触发state.show的setter,在setter中会去查看state.show属性在get阶段收集到的副作用函数,并执行对应effect.run方法。
  • effect.run方法则会触发上面提到的initDepMarkers方法,此时我们遍历deps上的两个dep,将他们都标记为已追踪wasTracked
  • 然后执行this.fn,但不同的是,因为我们的state.show已经被赋值为false了,所以在访问阶段不会访问到state.a,也就无法对state.a进行收集,这样一来,在执行finalizeDepMarkers函数的时候则会将对应的effect删除
  • 最后,在执行state.a++时,虽然触发了setter,但是因为没有对应的effect,所以并不会在控制台进行打印。

总结

至此,我们总算是基本搞清楚了Vue3中的副作用函数effect到底是个什么东西,以及多层级嵌套副作用函数effect时,代码是如何运行的,同时也更详细的了解了副作用函数的收集和运行。

参考文章:

官网

大佬的小册