vue3-effect源码解析

1,895 阅读23分钟

阅读准备

本文使用的vue版本为3.2.26。在阅读 effect 源码之前,我们需要知道它的特性,可以通过阅读单例测试源码或者是阅读官网的 API了解特性,推荐阅读单例,了解特性在后面阅读时才能更好理解。

  通过上一章vue3-reactive源码解析,可以猜想到,effect主要职责是存储Proxy track(收集)的依赖,当Proxy triggle(触发)后查看trigger是否是track存储的依赖,如果是的话则执行监听函数。关于Proxy是如何tracktriggle的可以看上一章vue3-reactive源码解析

  为了方便表达,我将用户传入effect的回调函数统称为监听函数,由effect包裹后的统称为收集函数。

通过文档和单例可以知道effect有以下特性

  • 传入的收集函数不会递归执行,就算当前effect内触发了已经收集的依赖。比如effect(() => {rea.a; rea.a = 2})

  • effect函数返回的也是函数,可以直接通过返回的函数执行监听函数。

  • effect可以包裹effect返回的方法,会重新包装,当触发时会执行两次。

  • effect可以在监听函数中再次调用effect,但是里层不会收集外层监听函数的依赖,外层也不会收集到里层的依赖,比如:

      const rea = reactive({a: 1, b: 2})
      // 1, 2
      effect(() => {
        console.log(rea.a)
        effect(() => {
          console.log(rea.b)
        })
      })
      // 2, 2
      rea.a = 2
      // 3
      rea.b = 3
    
  • effect第二个可选json参数,这个参数包含lazy, scheduler, allowRecurse, onStop, onTrack, onTragger属性

    • lazyboolean,是否懒加载,如果是true调用effect不会立即执行监听函数,由用户手动触发。
    • scheduler: function,被触发引起effect要重新收集依赖时的调度器,当传入时effect收到触发时不会重新执行监听函数而是执行这个function,由用户自己调度。
    • allowRecurse:是否允许递归,这个参数需要和scheduler配套使用,是否允许递归scheduler对监听函数无效
    • onStop:当effectstop(停止监听)时的钩子。
    • onTrack:当effecttrack时的钩子。
    • onTrigger:当effecttrigger时的钩子。

思考实现

如果是我们自己编写effect会怎么实现呢?上一章知道了Proxy会通过track函数告知我们收集到了哪个对象的哪个key,会通过triggle函数因为哪个对象的哪个key引起了触发。加上上面的阅读准备我们知道了effect大概需求

  • track只收集effect内的依赖,trigger是触发effect的收集函数或调度器。
  • effect收集函数被重新执行时需要清空之前收集的依赖并重新收集,因为收集的可能存在分支比如if,收集也是动态的。到这里是不是有大概的思路了,

  根据需我们可以简单的实现大概逻辑:

effect.drawio.png

  在vueeffect实现的原理流程其实跟上图是差不多的,接下来我们就一份一份拆解出来看看它各个部分是怎么实现的,我们先看看targetkeyeffect的关系在里面是怎么实现的。

Dep

  vue中是如何存储targetkeyeffect的关系的,在effect源码中我们可以看到顶部有一段代码

// Dep: Set对象,可以存储多个effect对象
export type Dep = Set<ReactiveEffect> & TrackedMarkers

// Dep附加对象,用来标识effect的状态
type TrackedMarkers = {
  /**
   * wasTracked
   */
  // 之前被收集
  w: number
  /**
   * newTracked
   */
  // 当前被收集
  n: number
}

// key关联Dep的Map 为了方便我们叫他kDepMap
type KeyToDepMap = Map<any, Dep>
// Target -> kDepMap
const targetMap = new WeakMap<any, KeyToDepMap>()

  在effect源码文件中声明了一个类型为WeakMaptargetMap变量,这个targetMap就是存储监听函数执行期间Proxytrack出来的依赖与effect

  Proxy调用track时抛出的参数中有代理的Target(raw)和引起trackkey,而一个Target可以有多个key引起trackkey也可能是对象,因为Target可能是Map或者WeakMap,除了存储Targetkey外,也要存储这个key是在哪些effect的监听函数中使用的,所以vue采用双Map的存储方式。kDepMap存储每个keyeffect的引用关系,然后targetMap存储targetkDepMap的引用关系。

  上面还使用了tsDep的类型来标记kDepMapvalueDepSet的基础上附加值为number属性whDep中的wn是干什么用的呢,我们看到源码中的注释wasTrackednewTracked从字面意思可以猜测出来,应该是记录之前是否被收集和现在是否被收集。我们之前讲过,数据收集是动态的,所以每次执行收集前需要清空之前的依赖,然后附加上现在的依赖,确保依赖正确,比如下方的代码:

const reuser = reactive({ 
  name: 'bill', 
  sex: '男',
  setLog: 'name'
});

// 第一次执行收集到的key是setLog、name
effect(() => {
  console.log(ret[reuser.setLog])
})
// 更改后收集到的key是setLog、sex
reuser.setLog = 'sex'

  vue中将这两个属性直接关联到Dep中,也就是说Target的每个key都有当前之前是否被收集、现在是否被收集的标识状态。按照平常的做法来说这种状态应该是附加到effect实例,因为DepSet它里面存储的不止是一个effect,每个effect都应该有状态,但是现在附加到了Dep上,也就意味着必须要对Depwn做一些特殊的处理:

  • effect函数执行完毕之后必须要还原Depwh的状态,否则Set中其他effect使用就不正确了

  • effect函数执行前必须恢复w属性(之前是否被trick

  • effect函数递归调用时,wh属性必须能够完整记录每一层的状态,比如下方这种方式调用

      const rea = reactive({ a: 1, b: 2 })
      effect(() => {
        console.log(rea.a);
        rea.a = 2
        effect(() => {
          console.log(rea.a)
        })
      })
    

  因为这些都是与effect中实现直接挂钩的,等我们讲到effect具体实现时再看看他们是怎么具体实现的。现在我们可以先思考wh怎么实现这三点的,前两点都还好,只是恢复和还原状态,但是第三点要记录多层状态。要记录多层状态,而wh又是number,可以得出结论,这两个属性是要用位运算符做多层状态管理,关于位运算符是怎么做状态的,大家可以看看我之前写的这篇文章。递归调用effect时每一层effectwh都用一个特定的位来标识这一次effect的状态,在二进制中的用1表示true0表示false这是常规做法。在vue中使用的是从第二位开始标记当前状态,每多一层就将当前标识状态的往前推一位,例如:

  const rea = reactive({ a: 1 })
  
  //初次生成
  // key: a
  // Dep: { w: 0, n: 0 }
  //   w.toString(2): 00000000000000000000000000000000
  //   n.toString(2): 00000000000000000000000000000000
  //
  effect(() => {
    //第一次收集
    //  key: a
    //  Dep: { w: 0, n: 2 }
    //    w.toString(2): 00000000000000000000000000000000
    //    n.toString(2): 00000000000000000000000000000010
    //第二次收集,恢复已经被收集状态
    //  key: a
    //  Dep: { w: 2, n: 2 }
    //    w.toString(2): 00000000000000000000000000000010
    //    n.toString(2): 00000000000000000000000000000010
    //
    console.log(rea.a);

    effect(() => {
      // 进入内层
      // 第一次收集
      //   key: a
      //   Dep: { w: 0, n: 6 }
      //     w.toString(2): 00000000000000000000000000000000
      //     n.toString(2): 00000000000000000000000000000110
      // 第二次收集,恢复已经被收集状态
      //   key: a
      //   Dep: { w: 6, n: 6 }
      //     w.toString(2): 00000000000000000000000000000110
      //     n.toString(2): 00000000000000000000000000000110
      //
      console.log(rea.a)

     //离开外层清空恢复进入状态
     //第一次收集离开
     // key: a
     // Dep: { w: 0, n: 2 }
     //   w.toString(2): 00000000000000000000000000000000
     //   n.toString(2): 00000000000000000000000000000010
     //第二次收集离开
     // key: a
     // Dep: { w: 2, n: 2 }
     //   w.toString(2): 00000000000000000000000000000010
     //   n.toString(2): 00000000000000000000000000000010
     //
    })

    //离开外层清空恢复进入状态
    //  key: a
    //  Dep: { w: 0, n: 0 }
    //    w.toString(2): 00000000000000000000000000000000
    //    n.toString(2): 00000000000000000000000000000000
    //
  })
  rea.a = 2

  要标识当前递归调用了多少次,还需要用一个变量来记录,在effect中使用effectTrackDepth变量来记录,现在来看看Dep的具体管理方法:

// -----effect文件中------
// 当前effect层叠数
let effectTrackDepth = 0
// 当前trick需要操作的bit
export let trackOpBit = 1

// 在使用时 trackOpBit = 1 << ++effectTrackDepth
// 也就是effect递归多少次就往前推多少位
// effectTrackDepth = 0    trackOpBit = 00000000000000000000000000000010
// effectTrackDepth = 1    trackOpBit = 00000000000000000000000000000100
// effectTrackDepth = 2    trackOpBit = 00000000000000000000000000001000
// effectTrackDepth = 3    trackOpBit = 00000000000000000000000000010000
// ----------------------

// 创建dep
export const createDep = (effects?: ReactiveEffect[]): Dep => {
  const dep = new Set<ReactiveEffect>(effects) as Dep
  // 初始化
  dep.w = 0
  dep.n = 0
  return dep
}

// 传入的Dep 查看当关联key在effect中之前是否被track
export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0

// 传入的Dep 查看当关联key在effect中现在是否被track
export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0

  为了方便操作vue会创建一个trackOpBit变量,这个变量根据当前effect的递归往前推进,保证trackOpBit的二进制位数中为1的位置和wh二进制数标识当前effect状态的位置是保持一致的。当需要判断key在当前effect之前和现在是否被收集时只需要dep.w & trackOpBitdep.n & trackOpBit是否大于0就行了,如果对于 &运算符不了解可以看看我之前写的这篇文章

  通过Dep记录的这某个key上一次是否被收集和现在是否被收集,我们可以猜测到vue是怎么管理targetMap的了,vue中重新收集时(即调用effect监听函数)可能不是简单粗暴的直接剔除KeyToDepMapSet所有当前的effect,然后再收集,而是:

  • 之前key被收集,但是当前没有收集,则在key关联的Dep中剔除当前effect
  • 之前key没有被收集,当时当前被收集,则在key关联的Dep中添加当前effect`
  • 之前key被收集,当前也被收集,则保持不变

effect

  接下来我们看看effect函数的具体代码

export const extend = Object.assign
// 创建effect函数
export function effect<T = any>(
  fn: () => T,
  options?: ReactiveEffectOptions
): ReactiveEffectRunner {
  // 如果当前fn已经是收集函数包装后的函数,则获取监听函数当做入参
  if ((fn as ReactiveEffectRunner).effect) {
    fn = (fn as ReactiveEffectRunner).effect.fn
  }

  // 创建effect对象
  const _effect = new ReactiveEffect(fn)
  // 将用户传入的参数附加到effect对象上
  if (options) {
    extend(_effect, options)
    // 如果有定义域作用于则记录,这个我们后面章节再讲,这里不影响主流程
    if (options.scope) recordEffectScope(_effect, options.scope)
  }
  // 如果不是懒加载则立即执行包装后的监听函数
  if (!options || !options.lazy) {
    _effect.run()
  }
  // 绑定收集函数的this对象,和effect对象
  const runner = _effect.run.bind(_effect) as ReactiveEffectRunner
  runner.effect = _effect
  return runner
}

  effect函数主要是创建ReactiveEffect对象,将用户传入的参数附加到对象上,履行lazy参数的职责。

  effect返回的是effect.run函数,这个函数的effect属性会指向effect对象,this也会设置为effect对象。所以当懒加载时,或者用户主动执行effect包装后的监听函数,也能够正确的track

  我们看到入参时会查看监听函数是否是effect包装后的函数,如果是会拿到未包装前的监听函数(存储再effect对象的fn属性上)再创建effect,所以effect可以包裹effect返回的方法,会重新包装,当触发时会执行两次。

  这里ReactiveEffect采用了class写法,每个effect函数都会创建一个实例,接下来我们看看这个class的具体代码

// 最多30个互相引用,如果超出则清理
const maxMarkerBits = 30
// 正在执行的effect栈
const effectStack: ReactiveEffect[] = []
// 当前正在执行的effect
let activeEffect: ReactiveEffect | undefined

let effectTrackDepth = 0

// effect对象
export class ReactiveEffect<T = any> {
  // 当前对象是否是有效的,为false则是已加stop的了
  active = true
  // 记录当前effect 收集到的所有key对应的Dep
  deps: Dep[] = []

  // 是否是computed 创建后可以附加
  computed?: boolean
  // 是否允许递归响应
  allowRecurse?: boolean
  // 停止监听钩子
  onStop?: () => void
  // 被收集时钩子
  onTrack?: (event: DebuggerEvent) => void
  // 被触发时钩子
  onTrigger?: (event: DebuggerEvent) => void

  // 构造函数
  constructor(
    // 监听函数
    public fn: () => T,
    // 调度器
    public scheduler: EffectScheduler | null = null,
    // 作用域
    scope?: EffectScope | null
  ) {
    // 记录当前对象的空间范围
    recordEffectScope(this, scope)
  }

  // 收集函数
  run() {
    // 如果当前effect已经被stop
    if (!this.active) {
      // 直接监听函数,不做收集逻辑
      return this.fn()
    }

    // 查看当前调度栈是否包含当前对象,如果包含说明是嵌套运行,不再执行
    if (!effectStack.includes(this)) {
      try {
        // 当前effect入栈
        effectStack.push((activeEffect = this))
        // 开启收集
        enableTracking()

        // 根据层叠数更改trackOpBit
        trackOpBit = 1 << ++effectTrackDepth

        // 查看当前effect层叠数是否超过允许的最大记录数
        if (effectTrackDepth <= maxMarkerBits) {
          // 记录恢复上一次dep状态 也就是更改w
          initDepMarkers(this)
        } else {
          // 如果超过了最大bit记录数,则清除当前effect关联的所有Dep映射
          cleanupEffect(this)
        }
        return this.fn()
      } finally {
        // 如果当前effect轮询个数没超限制
        if (effectTrackDepth <= maxMarkerBits) {
          // 整理effect deps 删除失效无用的dep, 恢复 dep w n状态
          finalizeDepMarkers(this)
        }

        // 恢复执行位数
        trackOpBit = 1 << --effectTrackDepth

        // 恢复收集状态
        resetTracking()
        // 出栈
        effectStack.pop()
        // 将正在使用effect替换成栈顶
        const n = effectStack.length
        activeEffect = n > 0 ? effectStack[n - 1] : undefined
      }
    }
  }

  // 停止监听
  stop() {
    if (this.active) {
      // 清除当前effect关联的所有Dep映射
      cleanupEffect(this)
      if (this.onStop) {
        this.onStop()
      }
      this.active = false
    }
  }
}

  关于computedscoperecordEffectScope我们后面的章节再讲,这里不会影响当前业务,先忽略它们。

  effect函数传入的参数都会附加到ReactiveEffect对象上,其中scheduler可以通过构造函数传入。effect对象上还会附加deps属性,这个属性是记录effect关联的所有keyDep对象。这里附加是因为响应式对象不止只有reactive, 还有其他响应式对象的依赖需要存储,其他响应式对象我们后面再讲。还有一方面是为了方便的管理,比如在执行前会还原当前Dep在之前是否被收集,执行完毕后需要对当前关联的所有Dep还原状态,停止监听时直接通过关联的dep删除effect

  在effect对象中使用run方法执行监听方法和附加状态。当effect对象被停用时调用run方法只是执行监听方法。

  当进入收集函数时会进行检测当前对象是否已经在执行栈内,如果在栈内则中断执行,我们可以看到allowRecurse参数并没有在这里使用上,所以即使声明了allowRecurse参数对于收集函数的递归也是没什么效果。

  正式进入会将正在执行的effect对象替换成当前effect对象,并且入栈。当在一个收集函数内调用另一个收集函数时时会叠加effectTrackDepth变量。还有我们之前说的trackOpBit变量,确保trackOpBit的中1的位数是跟Depwh标识当前effect的位置是一致的,这样就能正确的使用wasTrackednewTracked方法。

  收集函数还有个最大叠层数限制这个层叠数是30,在maxMarkerBits中声明。在jsnumber中使用32位的二进制数来表示数字的,第32位是符号位(0为正,1为负),那就是说最多能表示31个状态,而在wn中最后一位没有使用,第一次进去是使用1 << 1,直接从第二位开始的。所以最大的层数只能是30

  执行收集函数时是怎么恢复上次是否被收集的状态呢,因为每个effect对象都记录了key关联上的depdeps),当最新进入时,这些Dep就是上次的收集值,如果当前层叠数没超过30次,只需要在最新执行前将这些dep都打上之前被收集的标记就行了,在收集函数中使用initDepMarkers函数来实现的,下面我们看看源码

// 初始effect dep 的 记录
export const initDepMarkers = ({ deps }: ReactiveEffect) => {
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].w |= trackOpBit // set was tracked
    }
  }
}

  如果层叠数超过30次呢?这时候wh无法正确的记录状态了,要怎么正确的收集和更新dep呢?因为无法记录状态,所以不知道之前是否收集过,那么就执行简单粗暴的方法,直接将之前effect对象收集的Dep删除掉,并删掉Depeffect对象的引用,那新增加的就一定是正确的,这样就绕过需要状态的问题了。在effect是通过cleanupEffect方法清空当前effect对象与Dep的互相引用的,我们看看实现这个方法的实现

// 清空当前effect对象与Dep的互相引用的
function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect
  // 清除Dep中effect的引用
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}

  到这我们就看完了执行前的准备就已经完成,现在看看执行后effect的deps和Dep对象的处理是怎么处理的。

  • 如果已经超过最大层叠数,则effectdepsDep不需要做任何处理,因为之前收集的Dep已经被删除了,现在存下来的肯定是最新,而且也没用到Depwn状态。

  • 如果没超过最大层叠数,Depwn因为是被多个effect对象引用,所以执行后要恢复到进入时的状态,确保其他effect对象使用时是正确的。为什么不能直接还原到最初的状态({w: 0, n: 0}),因为收集函数可能互相引入,当前收集函数执行完,执行权还要交还给上一个收集函数,要确保上一个收集函数内的wh状态正确。除了恢复状态我们还要更新effect对象的deps属性,在执行前都打上了被收集的标识,那么执行后只需要查看key关联的Dep现在是否被收集就能判断是需要删除或保留(添加是在trick方法进行的)。在vue中是使用finalizeDepMarkers函数来管理这部分需求的,接下来我们看看实现:

    // 更新effect对象的deps属性
    export const finalizeDepMarkers = (effect: ReactiveEffect) => {
      // 获取deps
      const { deps } = effect
      if (deps.length) {
        let ptr = 0
        for (let i = 0; i < deps.length; i++) {
          const dep = deps[i]
          // 如果之前dep已经收集,但是当前没有被收集,直接删除
          if (wasTracked(dep) && !newTracked(dep)) {
            dep.delete(effect)
          } else {
            // 更新deps
            deps[ptr++] = dep
          }
          // clear bits
          // 清除dep在这次收集函数中的状态
          dep.w &= ~trackOpBit
          dep.n &= ~trackOpBit
        }
        // 更新deps
        deps.length = ptr
      }
    }
    

    如果之前收集过但是现在没收集的则直接删除,否则就保留,并且每个dep中标识当前effect状态的位标识符都重置为进入时的状态。这个函数通过记录当前保留的总数,然后要删除的dep的位置替换成要保留的dep,最后更新length,写的也是相当精妙。

  在恢复dep状态和更新effect对象的deps后,也会将当前trackOpBitactiveEffecteffectStack恢复到进入前状态。到这里收集方法就已经看完了,接下来我们看看实例上的另外一个方法stop,这个方法相对比较简单,只是清空targetMap中当前effect对象引用,调用停用钩子,并更改当前effect对象的状态active为已经被停用。effect中还为这个方法对外提供了一个主动调起的辅助方法stop:

export function stop(runner: ReactiveEffectRunner) {
  runner.effect.stop()
}

  到这里effect对象里面具体的实现已经讲完了,那track又是怎么存储effectDep的,又是怎么将effectDep两者关联的呢,接下来让我们探索track里的具体实现。

track函数

  让我们回顾一下之前内容,track函数是在Proxy的基础拦截器或者是集合修改器中获取数据时触发的,主要是关联effect跟收集到的依赖,接下来我们看看track函数的具体实现,先看看具体代码

// 当前是否正在收集,当前开启收集,并且有正在使用的effect对象
export function isTracking() {
  return shouldTrack && activeEffect !== undefined
}

// 收集effect对象的依赖建立关系
export function track(target: object, type: TrackOpTypes, key: unknown) {
  // 如果当前没有进行收集则直接返回
  if (!isTracking()) {
    return
  }
  // 获取 KeyToDepMap(keys -> Dep)
  let depsMap = targetMap.get(target)
  // 如果不存在则初始化KeyToDepMap
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  // 获取当前 key的Dep
  let dep = depsMap.get(key)
  // 如果不存在则创建Dep
  if (!dep) {
    depsMap.set(key, (dep = createDep()))
  }

  // 如果是开发环境则记录具体信息
  const eventInfo = __DEV__
    ? { effect: activeEffect, target, type, key }
    : undefined

  trackEffects(dep, eventInfo)
}

  track函数的逻辑并不复杂,首先检测当前是否正在收集,这个判断就是当前trackStack栈顶是开启了收集,并且当前正在执行收集函数,如果不是正在收集则直接退出,确保只有正在执行收集函数时才能进入。然后查看映射关系中是否存在当前TargetKeyToDepMap(keys -> Dep)如果不存在则创建,在通过key查找是否有Dep没有则创建。如果是开发环境还会创建需要附加到钩子的具体收集信息,最后调用trackEffects方法,可以猜测得到trackEffects才是真正实现具体业务的方法。

  下面我们看看trackEffects的具体实现代码:


// track,更改Dep状态,更新effect对象的deps
export function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  // 是否是新增的依赖
  let shouldTrack = false
  // 查看当前层叠数是否能超过了记录的最大限制
  if (effectTrackDepth <= maxMarkerBits) {
    // 查看是否记录过当前依赖
    if (!newTracked(dep)) {
      // 记录是当前收集的依赖
      dep.n |= trackOpBit // set newly tracked
      // 如果effect之前已经收集过,则不是新增依赖
      shouldTrack = !wasTracked(dep)
    }
  } else {
    // 如果层叠数超过了最大,则查看当前dep在effect中实收存储过
    // 因为超过最大进入前会清空所有dep,
    // 第一次进入一定会收集,当收集重复key时才会跳过
    shouldTrack = !dep.has(activeEffect!)
  }

  // 如果是新增的收集
  if (shouldTrack) {
    // dep添加当前正在使用的effect
    dep.add(activeEffect!)
    // effect的deps也记录当前dep 双向引用
    activeEffect!.deps.push(dep)
    // 如果当前是开发环境,还要执行onTrack钩子
    if (__DEV__ && activeEffect!.onTrack) {
      activeEffect!.onTrack(
        Object.assign(
          {
            effect: activeEffect!
          },
          debuggerEventExtraInfo
        )
      )
    }
  }
}

  没超过最大层叠数时,收集函数收集到的Dep需要打上当前被收集的状态,给effect对象执行完毕后更新deps属性使用性。如果当前收集到了dep,但是之前不存在,说明这个dep是新增的。当超过最大层叠数时执行前就清空之前的所有Dep中当前effect对象的引用,所以当进入收集函数时所有dep就都是新增的。新增的dep时需要将当前effect添加到这个Dep中,并且将这个dep添加到当前effect的deps中,然后触发收集钩子。

  到这里track函数里面具体的实现已经讲完了,effect通过监听函数执行前设置当前effect,并使用Depwn属性标记状态,然后在track中使用,通过这种方式确定当前是否在effect内收集到的依赖,确定状态,更新状态。

triggle 函数

  接下来我们看看triggle函数是如何通过targetkeytargetMap存储库确定要执行的收集函数。

// trigger 值变化
export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  // 获取当前target的KeyToDepMap
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    // never been tracked
    return
  }

  // 需要触发的deps
  let deps: (Dep | undefined)[] = []
  if (type === TriggerOpTypes.CLEAR) {
  // 如果是清除当前数据(Set和Map中的操作),那所有dep都应该触发
    deps = [...depsMap.values()]
  } else if (key === 'length' && isArray(target)) {
    // 如果是修改数组长度,
    // length和被删除的下标的key 关联的dep都应该被触发
    depsMap.forEach((dep, key) => {
      if (key === 'length' || key >= (newValue as number)) {
        deps.push(dep)
      }
    })
  } else {
    // 先获取当前key关联的deps
    if (key !== void 0) {
      deps.push(depsMap.get(key))
    }

    // also run for iteration key on ADD | DELETE | Map.SET
    // 判别操作类型,
    // 有些操作会关联到其他操作,需要分别判断
    switch (type) {
      // 如果是增加操作
      case TriggerOpTypes.ADD:
        // 数组需要单独判断,之前我们说过数组的迭代收集到的key是length
        if (!isArray(target)) {
          // 因为是新增,获取迭代收集的dep
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            // 如果是map还需要收集MAP_KEY_ITERATE_KEY
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        } else if (isIntegerKey(key)) {
          // 如果是数组新增下标那么length一定会修改
          deps.push(depsMap.get('length'))
        }
        break

      // 如果是删除操作
      case TriggerOpTypes.DELETE:
        if (!isArray(target)) {
          // 删除迭代都需要重新执行
          deps.push(depsMap.get(ITERATE_KEY))
          if (isMap(target)) {
            deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
          }
        }
        // 因为删除操作一定会有length属性的变化,会引起length的triggle,这里就不需要重复收集
        break
      // 如果是更改
      case TriggerOpTypes.SET:
        // 用户可能直接获取map.values或者 map.entries直接拿到value
        if (isMap(target)) {
          deps.push(depsMap.get(ITERATE_KEY))
        }
        break
    }
  }

  // 附加trigger调试信息给onTrigger钩子使用
  const eventInfo = __DEV__
    ? { target, type, key, newValue, oldValue, oldTarget }
    : undefined

  // 假如只有一个Dep依赖则直接triggerEffects
  if (deps.length === 1) {
    if (deps[0]) {
      if (__DEV__) {
        triggerEffects(deps[0], eventInfo)
      } else {
        triggerEffects(deps[0])
      }
    }
  } else {
    // 假如有多个deps需要对内部的effect做一遍去重
    const effects: ReactiveEffect[] = []
    for (const dep of deps) {
      if (dep) {
        effects.push(...dep)
      }
    }
    if (__DEV__) {
      triggerEffects(createDep(effects), eventInfo)
    } else {
      triggerEffects(createDep(effects))
    }
  }
}

  trigger不仅只是获取gettargetMap指定keyDep,因为数据操作中有很多关联性的东西,比如新增和删除都需要重新触发迭代操作,下面我们详细分析各个操作的关联性。

  • Map执行clear时,需要触发所有之前收集的effect

  • Array更新length时,之前收集到length值大于当前length值,那么存储库中之前收集到的下标小于等于当前lengthkey关联的Dep需要重新触发,因为有可能之前有值,现在值被删除;为什么是有可能呢因为当收集到超出边界的下标时更改length也会重复触发,例如:

      const rearr = reactive([1,1,1,1,1] as any[])
      effect(() => {
        console.log(rearr[4])
      })
      effect(() => {
        console.log(rearr[6])
      })
      rearr.pop()
      // 1 undefined 
      // undefined undefined
    
  • 当添加数据时,所有依赖函数收集到keyITERATE_KEYTargetMapKeyMAP_KEY_ITERATE_KEYTargetArratkeylength都应该触发,前两个很好理解,添加自然迭代就需要触发,但是第三个不是添加也会更改length吗,为什么也需要触发呢?这是因为当使用api时底层里面会先添加数据,这时数据内的length直接就被更改了,当拦截到length更改时已经获取不到旧值,前面我们看Proxyset处理器触发前会做一条判断,那就是只有keyvalue更改了才会触发,这里length始终不会触发,因为始终是一致,所以当添加时就应该要触发。

      const t = new Proxy([1,2,3,4], {
      set(target, key, value) {
        console.log(key, value, target[key])
        target[key] = value
        return true
      }
    })
    t.push(4)
    // 4 4 undefined
    // length 5 5
    t.splice(4, 0, 5)
    // 5 4 undefined
    // 4 5 4
    // length 6 6
    
  • 当删除数据时,所有依赖函数收集到keyITERATE_KEYTargetMapKeyMAP_KEY_ITERATE_KEY都应该触发,为什么这里就不需要触发TargetArraykey为lengtheffect,这是因为底层删除数组某项时都是通过更改length来实现,能够获取到旧值,当length新旧值发生更改时能够trigger所以就不需要重复收集了。

    ...
    t.pop()
    t.splice(3, 1) 
    // length 3 4
    // length 3 3
    
  • 当更改数据时,所有依赖收集到MapkeyITERATE_KEY都应该触发。为什么只要Map中的呢,因为如果是Array或者json时,都得通过具体key来访问,在deps.push(depsMap.get(key))就能收集到;而Map可以通过entriesvalues直接获取,所以Map应该关联上ITERATE_KEY,而Set数据结构并没有提供直接修改的方法所以也不需要判断。

  如果通过获取回来关联的Dep只有一个的话就直接触发里面所有的effect,如果是获取到多个Dep的话需要对effect去重,因为一个effect可能在一次触发中被收集多次,比如下方代码。代码中去重的方法是对所有Dep(Set)扩散,然后放入到一个新的Dep中而DepSet对象就会自动去重。

const name = { name: 'key' }
const remap = new Map([[name, 1]])
const te = effect(() => {
  console.log(remap.get(name))
  console.log([...remap.values()])
})
remap.set(name, 2)
// 直接获取到key为name的dep
// 获取执行remap.values方法获取到key位ITERATE_KEY的dep
// 两个dep都包含map,只需要执行一次,去重

  到这里就已经获取到所有关联的effect了,然后传入到triggerEffects函数中,triggerEffects函数就是具体执行effect监听函数的实现,我们看看具体代码

// 执行因为trigger变化的所有effect
export function triggerEffects(
  dep: Dep | ReactiveEffect[],
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  // 获取所有effect
  for (const effect of isArray(dep) ? dep : [...dep]) {
    // 如果触发关联的effect 是当前正在执行的,并且没有声明允许递归则不在重复执行 
    if (effect !== activeEffect || effect.allowRecurse) {
      // 触发onTrigger钩子
      if (__DEV__ && effect.onTrigger) {
        effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))
      }
      // 如果当前effect有注册调度器,则使用调度器,否则则执行effect注册的函数
      if (effect.scheduler) {
        effect.scheduler()
      } else {
        effect.run()
      }
    }
  }
}

  triggerEffects传入参数允ArrayDep,会对所有的effect做一次遍历,逐一执行触发钩子、监听函数,大家注意到如果用户自定义了调度器scheduler的话是执行scheduler并不会直接执行监听函数。

  当前effect是当前正在执行监听函数的effect时会有三种特殊情况:

  • allowRecursefalse则直接跳过
  • allowRecursetrue没有自定义调度器时,将执行收集钩子和收集函数,但是执行监听函数前会判断当前effect是否在执行栈中,如果是直接跳过,所以这里只是执行了收集钩子,监听函数并没有允许递归
  • allowRecursetrue有自定义调度器时,将执行钩子和自定义调度器,允许递归有效。

  allowRecurse对于监听函数并没有实质作用,即使声明了也不会允许递归,它是作用于scheduler的。

小结

  • effect执行收集函数时不会触发自身
  • effect函数返回的是收集方法,可以显示调用
  • effect函数可以传递effect函数返回的方法,会重新包装,但是源绑定方法是一致的
  • effect监听函数中可以再调用其他收集函数,被调用者不会收集到当前effect的依赖
  • 对于已经停止观察的effect可以在外层套一层effect继续监听
  • effect可选参数{ lazy: 懒加载, scheduler: 调度器, scope: .., allowRecurse: 是否允许递归 , onStop: 停止调度钩子, onTrack: 收集时钩子, onTragger: 触发时钩子 }
  • allowRecurse参数是针对scheduler
  • vue通过targetMapeffect和收集的targetkey建立关系。
  • key通过Depeffect建立关系,effect通过缓存depskey建立关系
  • effectdeps管理方式有两种,effect层叠数少于30时通过wn状态细粒增删,超过30则进入前删,后续都是增

上一章:vue3-reactive源码解析

下一章:vue3-ref源码解析