5-Vue源码之【Effect】

前言

我们在响应式中提到过 get 触发 trackset 会去触发 trigger,这 2 个方法就是定义在 effect.ts 文件中的。此外这里还定义了一个 ReactiveEffect 类,该类非常重要,我们响应式挂钩的函数都是经过他包装的。

首先我们要先了解 DepReactiveEffect

Dep

Dep 是一个存放了 ReactiveEffect 实例的 Set 集合,又定义了 2 个 number 变量来维护跟踪层级状态。

export type Dep = Set<ReactiveEffect> & TrackedMarkers

// wasTracked和newTracked维护了几个级别的效果跟踪递归的状态。每个级别一位用于定义是否跟踪了依赖关系。

// 当前trackOpBit位(当前层级) 是否存在 w 和 n
export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0
export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0

// w 和 n 都为二进制数,具体是为了优化
type TrackedMarkers = {
  /**
   * 已跟踪位
   *
   * Effect实例调用 run 方法时,如果已经存在 deps 里已存在 dep ,会调用 initDepMarkers ,去设置 w 值
   * 后续会在 trackEffects 时,如果发现 w 有值,那么就将 shouldTrack 设为false,避免重复存储 dep
   */
  w: number

  /**
   * 新跟踪位
   *
   * dep.n 会在 trackEffects 时,去设置,
   * 避免了同一个 fn 函数中,多次去调用同一个属性,避免多次收集。
   * 等到最后调用 finalizeDepMarkers 时,如果那一层的 n 没值,且 w 有值(w 有值,说明 dep 和 Effect挂钩过)
   * 那么就说明可能不要这个 effect 了,就清掉他。(一般深度超过30会出现这种情况)
   */
  n: number
}
/**
 * 创建 dep 的方法,可以接收一个 ReactiveEffect[]
 *
 * @param effects
 * @returns
 */
export const createDep = (effects?: ReactiveEffect[]): Dep => {
  const dep = new Set<ReactiveEffect>(effects) as Dep
  dep.w = 0
  dep.n = 0
  return dep
}

w 和 n 具体作用,我们暂且不谈,后面会分析。再来看下 ReactiveEffect 这个类

ReactiveEffect

/**
 * Dep 与 ReactiveEffect 的关系:
 * Dep 是一个 ReactiveEffect 的 Set 集合。且又存有 w 和 n 属性。
 * 由于是 ReactiveEffect 的集合,所以在 [...dep] 时,会返回一个 ReactiveEffect 的数组
 */
export class ReactiveEffect<T = any> {
  active = true // 当前ReactiveEffect对象是否激活状态,默认为true,如果为 false 则不进行依赖收集
  deps: Dep[] = [] // 在 effect 被停止时,需要遍历这里的数组,去删除各个 Dep 中带有这个 effect 的 Set元素
  parent: ReactiveEffect | undefined = undefined

  computed?: any

  allowRecurse?: boolean

  private deferStop?: boolean // 稍后暂停

  // 【注】 在构造函数参数中如果带有 修饰符,则表示new的时候会自动添加上属性,如下方便是可以调用 this.fn this.scheduler
  constructor(public fn: () => T, public scheduler: EffectScheduler | null = null, scope?: any) {
    // new ReactiveEffect 时候,如果存在 scope ,那么将其实例存储在 scope.effects 中
    // 这里不做这方面的考虑
    // recordEffectScope(this, scope)
  }

  // 后面解释,这里暂且先只做声明
  run() {}

  stop() {
    // 如果 activeEffect 是当前实例,正在run,那么需要在其结束之后才调用 this.stop()
    // 利用 deferStop 属性,结合上面的 run() 里最后的
    if (activeEffect === this) {
      this.deferStop = true
    } else if (this.active) {
      cleanupEffect(this)
      this.active = false
    }
  }
}

active:如果当前 ReactiveEffect 对象的 activefalse,那么后续就不会进行依赖收集。

deps:非常重要,追踪的属性effect 是相互储存的。比如我页面上依赖了 name 这个属性,那么 name 就会通过 dep 关联并存储上 页面更新(effect),使得 name 被触发 trigger 时,能够找到关联的 页面更新 的方法。同样的,页面更新 这个方法所在的 effect 上的 deps 属性(该属性) 也会保存刚刚提到的 dep, 因为 dep 是一个 Set 集合,里面存储着各种各样的 effect ,然后这里只需要 dep.delete(effect) 就可以删除掉这个存储着(页面更新)副作用函数的对象

parent:用来避免调用 run 方法时, activeEffect 刚好是当前 ReactiveEffect 实例,如果是,则退出函数,不进行操作。

computed:计算属性相关,不做讨论

allowRecurse:是否允许允许递归调用

fn:TS 语法,构造函数中传入就会自动给 this.fn 赋值,这就是我们 track 的副作用函数

scheduler:TS 语法,构造函数中传入就会自动给 this.scheduler 赋值,如果存在在 run 的时候,就会调用这个方法,这个方法会让调度器来判断什么时候执行 fn 方法


run

依赖追踪之前都会先执行一次该方法。举个例子:

renderer.ts 中,渲染页面时会调用 new ReactiveEffect 这时候就创建了一个 ReactiveEffect 实例,并且将 页面渲染 的方法传入 fn ,随即会在之后调用一个该实例的 run 方法,最终会调用传入的 fn ,而我们的 fn 里就会去执行 track 或者 trigger

【注】

1、 shouldTrack 是全局属性,用来判断是否 可以追踪依赖
2、 activeEffect 是全局属性,当 effect 实例执行到 run 方法时候,就会将当前实例 this 赋值给 activeEffect,等到调用 this.fn 时,里面 track 的属性,只会把当前 activeEffect 挂钩的 fn 存储到对应的 key 的 dep 里

const run = () => {
  /* ============= 第一部分 =============== */
  // 未激活则不进行依赖收集,直接调用 fn 函数
  if (!this.active) {
    return this.fn()
  }
  let parent: ReactiveEffect | undefined = activeEffect
  let lastShouldTrack = shouldTrack

  // 这段代码的作用是:防止 activeEffect 等于当前 this
  //    这里 parent 必须为 undefined 才能继续往下走。
  //    首先 activeEffect 会在 run 的时候被赋值为 this
  //    而在 run 执行到最后的 fn() 之后,会被重新赋值回去
  //    如果这里出现了 effect 嵌套行为,那么 parent = activeEffect
  //    在嵌套的第二层的 effect 中, activeEffect 就为 第一层的 effect
  //    然后通过链表不断向上查找 parent ,如果找到的 parent 等于 this ,那么就退出这次 run
  //    如果找到了 undefined ,那么即可退出循环,继续向下走
  while (parent) {
    // 防止出现 activeEffect 有值且等于 this 的情况,一遇到这种情况直接退出
    if (parent === this) {
      return
    }
    parent = parent.parent
  }
  try {
    // fn 执行的时候,会触发到响应式属性的 get ,继而会需要正确的 activeEffect ,所以在fn 执行之前,
    // 我们要先处理一下 activeEffect
    // activeEffect 这时候还是上一层的Effect 或 undefined ,赋值给 this.parent ,形成链表关系,然后修改 activeEffect
    this.parent = activeEffect
    activeEffect = this
    shouldTrack = true

    // 根据effect递归的深度,修改 trackOpBit
    trackOpBit = 1 << ++effectTrackDepth

    // 深度只要不超过30
    if (effectTrackDepth <= maxMarkerBits) {
      initDepMarkers(this) // 将 this.deps 里的 w 设置 trackOpBit位
    } else {
      // 如果超过了30 ,则清除当前effect关联的所有Dep映射
      cleanupEffect(this)
    }
    /* ============= 第二部分 =============== */
    return this.fn()
  } finally {
    /* ============= 第三部分 =============== */
    // 执行完 fn() 调用 finally
    if (effectTrackDepth <= maxMarkerBits) {
      finalizeDepMarkers(this)
    }

    // 还原 trackOpBit
    trackOpBit = 1 << --effectTrackDepth

    // fn 执行完之后,还原 activeEffect 值
    activeEffect = this.parent
    shouldTrack = lastShouldTrack
    this.parent = undefined

    // 如果用户调用 effect.stop 时,刚好是在该effect运行期间,那么就会给其打赏 deferStop标志 ,有了这个标志,那么就会在执行完这个effect之后停止,清除这个 effect
    if (this.deferStop) {
      this.stop()
    }
  }
}

我们可以将整个 run 分为三部分:

  1. fn 执行前:处理 effect 嵌套,赋值 activeEffect,处理递归深度

  2. 执行 fn: 可以调用 代理对象 里的属性,触发了 track

  3. fn 执行后:清空当前深度的 dep 的 w 和 n

1. 第一部分

activeEffect

activeEffect = this
shouldTrack = true

重点来看 try 块 包裹起来的代码,首先确定了 activeEffect ,这样再后面首次 key 被 track 时,就能知道 dep 要与那个 effect 关联。

trackOpBit 、effectTrackDepth

trackOpBit = 1 << ++effectTrackDepth

这是 2 个全局数值,跟深度有关,和 w、n 配合,给框架带来了更好的性能提升。

这里的深度指的是递归,举个例子便于理解:

effect(() => {
  console.log(state.name)
  effect(() => {
    console.log(state.age)
  })
})

state 是一个响应式数据,这里的 effect 内部会调用 new ReactiveEffect() 并且将回调带给 fn 后,立刻执行一次 run 方法。

然后我们就会走到 run 的第一部分,赋值 activeEffect,这里 trackOpBit 也会变成

// 初始状态
trackOpBit = 0
effectTrackDepth = 1

// 进入第一层 effect
trackOpBit = 0b0010 // 实际是以32位带符号的整数运算的,因此设立了 maxMarkerBits 最大深度30,我这里简单用 4位2进制位 表示

紧接着我们去执行 fn 方法,上面也说了, fneffect 的回调,所以实际上是去执行了下面的代码

console.log(state.name)
effect(() => {
  console.log(state.age)
})

这里因为调用了响应式数据的 name,触发了 track

紧接着又执行了一次 effect,这时候 trackOpBit 再次进行变化,接着又触发第二层的回调,响应式数据 age 触发 track

// 进入第二层 effect
trackOpBit = 0b0100

会发现正是由于我们的 trackOpBit ,我们知道的我们响应式数据是再哪一层进行的 track

当然目前效果还不明显,主要是在后面会让 dep 的 w 和 ntrackOpBit 进行 位或运算。这样便使得我们能够知道这个 dep 或者说这个 响应式数据的 key 分别在哪一层发生了收集。

那它们何时发生位或的呢?继续往下看。

initDepMarkers、cleanupEffect

// 深度只要不超过30
if (effectTrackDepth <= maxMarkerBits) {
  initDepMarkers(this) // 将 this.deps 里的 w 设置 trackOpBit位
} else {
  // 如果超过了30 ,则清除当前effect关联的所有Dep映射
  cleanupEffect(this)
}

// 遍历传入的 deps,将其设为 已跟踪
export const initDepMarkers = ({ deps }: ReactiveEffect) => {
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      deps[i].w |= trackOpBit
    }
  }
}

// 遍历传入的 deps,并清空 effect
function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect
  if (deps.length) {
    for (let i = 0; i < deps.length; i++) {
      // deps 是 dep 的数组,dep 又是各个 Effect 的 Set 集合
      // 这里是遍历 deps ,然后由于 effect 要被停掉,
      // 那么就需要删除 dep 中属于 effect 的依赖
      // 这样之后,有这个 effect 的其他 dep,在触发trigger 时,就不会调用到这个 effect
      deps[i].delete(effect)
    }
    deps.length = 0
  }
}

initDepMarkers

即是将当前 effect 所挂钩的 deps 里的 depw 都打上 深度标志,确定是处于哪一层。值得注意的是,首次 new ReactiveEffect 之后执行的第一次 run 方法,是不存在 deps 的,所以,首次执行的 dep 的 w 都没有打上标志,那么就可以用这个 w 来判断,如果首次执行,则在 keyeffect 发生 track 时需要挂钩一次,后续的话, w 有值的时候,就不需要重复挂钩

// 挂钩就是指下面的这个方法, dep 和 effect 挂钩, dep 又是和 响应式对象的 key 关联
// 下面的代码在 track 方法中会写到

// 只有首次创建的 dep 或者 清空了effect 之后才会进入该方法
if (shouldTrack) {
  dep.add(activeEffect!)
  activeEffect!.deps.push(dep)
}

cleanupEffect

使用二进制位的最终目的就是为了优化,而一旦超出限制,即比如你的 effect 深度超出了最大范围 30 层(正常来说不可能,一般一层就够用了,感觉很少业务会去嵌套这些 effect)就需要清空那个 effect 挂钩的 dep,在等到后面 track 时,重新进行挂钩。

2. 第二部分
return this.fn()

这里就会执行 fn ,内部如果有去获取响应式对象的 key ,则会触发 track

track

/**
 * 响应式数据会在 componentUpdateFn 的时候(DOM渲染)调用,
 * 然后调用 effect.run() 时,这时候 activeEffect 就有值了,最后调用 this.fn 即调用 componentUpdateFn 会去 render 我们的vnode。
 * 然后里面的值就被 track 了
 *
 * @param target
 * @param type
 * @param key
 */
export function track(target: object, type: TrackOpTypes, key: unknown) {
  // 只有可以收集依赖 且 存在活动的 effect 时执行
  if (shouldTrack && activeEffect) {
    // 跟据 target -> key -> 依赖的关系,先获取到 依赖Map
    let depsMap = targetMap.get(target)
    // 没有就先生成一个
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
    // 在用 key 去获取到 相应的依赖
    let dep = depsMap.get(key)
    // 不存在依赖,调用 createDep 生成
    // 这里生成的 dep 是一个 new Set 里面存放了 ReactiveEffect
    // 会在后面将 activeEffect 存入
    if (!dep) {
      depsMap.set(key, (dep = createDep()))
    }

    trackEffects(dep)
  }
}

export function trackEffects(dep: Dep) {
  let shouldTrack = false
  // 深度不超过30
  if (effectTrackDepth <= maxMarkerBits) {
    // 如果ReactiveEffect实例的fn函数中,多次使用了同一个代理对象的同一个属性,有了这个条件判断可以直接避免多次收集。
    // 比如:effect(() => { console.log(state.count);console.log(state.count) }) 因为第一次会给 n 赋值
    // 所以当第二次获取 state.count 时,执行到这一步,会被卡住, shouldTrack 还是为 false
    if (!newTracked(dep)) {
      dep.n |= trackOpBit // 设置新跟踪位
      shouldTrack = !wasTracked(dep) // 由于执行 run 方法之前会调用 w |= trackOpBit,所以大概率 shouldTrack 为 false, 除了首次的情况,首次不存在 dep, dep也是新的,所以 w 也是 0
    }
  } else {
    // 深度超过 30 之后,清除掉了 activeEffect ,需要重新去挂载,所以这里大概率返回 true
    shouldTrack = !dep.has(activeEffect!)
  }

  // 只有首次创建的 dep 或者 清空了effect 之后才会进入该方法
  if (shouldTrack) {
    // 将 activeEffect 存入 dep ,等待 trigger 遍历
    dep.add(activeEffect!)

    // 这里互存,便于找到彼此(比如在 cleanupEffect 中就是用 deps 去清空依赖的)
    // 注意这里的 dep 是一个 new Set 值,所以在 cleanupEffect 中是用 deps[i].delete(effect)
    activeEffect!.deps.push(dep)
  }
}

这里维护了两个 Map, 一个是以 targetMapdepsMap。他们的关系如下:

  graph LR
  A((targetMap)) --key--> 整个代理对象target
  A --value-->
  B((depsMap)) --key--> 代理对象中的某属性key
  B --value--> dep

在我们 track 的时候,就可以通过 target 找到对应的 depsMap ,在用 key 找到对应的 dep

顺便再回忆下刚刚说到的 depeffect 的关系

  graph LR
  effect.deps --是一个数组,里面存储了--- dep
  dep --是一个Set,里面存储了--- effect

这里简单概括下 track方法 的功能就是:

首次进入,会利用 target , key 生成一个 dep,让其与 activeEffect 相互关联,如果 dep 已存在且 shouldTrack 为 false,则说明 dep 已经关联过了,不需要重复关联。

QA.1. 如何判断需不需要 shouldTrack?

第一部分 的时候,调用 effect.run() 会有一步 initDepMarkers ,它会给已有depw 位或运算trackOpBit,而我们首次调用 effectdep 是不存在的,会再后面 track 方法 中去生成一个 新的 dep,这个 depw 为 0 ,所以这里需要与 effect 相互关联,后面再次进入 run ,就会设上 w 的值,然后 track 的时候发现 w 大于 0,就不会去重复关联了

trigger

export const trigger = (target: object, type: TriggerOpTypes, key?: unknown) => {
  const depsMap = targetMap.get(target)
  if (!depsMap) {
    return
  }

  const deps: (Dep | undefined)[] = []

  if (key != undefined) {
    deps.push(depsMap.get(key))
  }

  switch (type) {
    // 【注】 最开始我没有这一步
    // 直到后来,调用 list.push("xx") 的时候,触发了 trigger
    // 但是由于 xx 对应的下标是 新下标 , 是不存在 dep 的
    // 所以这里临时用 length 的 dep
    case TriggerOpTypes.ADD:
      if (isArray(target) && isIntegerKey(key)) {
        deps.push(depsMap.get('length'))
      }
      break

    default:
      break
  }

  // 由于 deps 是 dep的数组,而 dep 又是一个 Set。 所以需要 遍历一次,解构一次
  // 最后得到的 effect 数组即需要执行的数组
  const effects: ReactiveEffect[] = []

  deps.forEach((dep) => {
    dep && effects.push(...dep)
  })

  if (effects.length > 0) {
    triggerEffects(effects)
  }
}

export const triggerEffects = (effects: ReactiveEffect[]) => {
  for (let i = 0; i < effects.length; i++) {
    const effect = effects[i]

    // 这个判断避免了会有重复 trigger
    // 即 只有 effect 不等于当前活动的 activeEffect 时,才触发 run
    // 因为 activeEffect 就是在 run 的时候去设置的,且在 run 里还有一层 if (parent === this) return 拦截
    if (effect !== activeEffect || effect.allowRecurse) {
      // 如果有调度器则使用调度器(异步执行)
      if (effect.scheduler) {
        effect.scheduler()
      } else {
        effect.run()
      }
    }
  }
}

简单概括下:

trigger 通过 target 和 key 找到 dep ,由于我们 dep 是一个 effectSet 集合
所以只需要遍历去执行 effect.run 即可
Vue 里其实还针对特殊类型做了一些特殊处理,有兴趣的可以源码了解了解

QA.2. effect 为啥不会重复调用?
// 下面的代码,既触发了 track 和 trigger
// 而 trigger 又会触发 effect 的回调,那按照感觉来说,应该要重复调用
// 可这里却只执行了一次,是为什么?
effect(() => {
  state.name += 1
})
上面的代码里, trigger 是触发了,但是不一定会调用回调。

trigger 调用回调的地方有一个 if (effect !== activeEffect || effect.allowRecurse)

这个判断拦截了,如果遇到了 effect 和 activeEffect 相同的情况,除非他允许自己递归,

否则是不会进去调用回调的。

且在 effect.run() 里, if (parent === this) return ,这里又会进行一层判断

如果发现 activeEffect 是自己,那么也会退出,这样也执行不到回调方法
3. 第三部分
// 执行完 fn() 调用 finally
if (effectTrackDepth <= maxMarkerBits) {
  finalizeDepMarkers(this)
}

// 还原 trackOpBit
trackOpBit = 1 << --effectTrackDepth

// fn 执行完之后,还原 activeEffect 值
activeEffect = this.parent
shouldTrack = lastShouldTrack
this.parent = undefined

接下来就简单了,基本是进行还原操作。

/**
 * 当 track 结束后(执行完 run 里的 fn()) 会调用这个方法
 * 他会去清除无效的 effect 且重置当前深度的 w 和 n
 *
 * @param effect
 */
export 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]
      if (wasTracked(dep) && !newTracked(dep)) {
        dep.delete(effect)
      } else {
        deps[ptr++] = dep
      }
      // 清空那一层的 w 和 n
      dep.w &= ~trackOpBit
      dep.n &= ~trackOpBit
    }
    deps.length = ptr
  }
}

总结

effect 这块当时看的很绕,不知道写出来的文档能不能让大家看的清晰,就怕写的太烂,写着写着又变成只有自己知道的那种感觉((lll ¬ ω ¬))……

总结下流程:

用户首次进入项目,在 虚拟 DOM 构建完成,这里会创建一个 ReactiveEffect 实例 effect ,然后将页面渲染的方法存储进回调里。

然后主动调用一次 effect.run ,进入第一部分,修改 activeEffect = this , 递归深度, 由于是首次进入, effectdeps 长度为 0 ,就没有 dep 能被改写 w

紧接着进入第二部分,调用 this.fn() 这里就会去调用 页面渲染 ,然后假设这里用了代理对象 proxyStatename 属性。那么就会创建一个相关的 dep (因为首次进入,会创建一个新的,后面进来则不需要重新创建了)

新创建的 depwn 都是 0 ,所以这时候 shouldTrack 会被设为 true ,进而会将 depactiveEffect 关联起来。

第三部分就是还原操作。

然后,每当 name 被修改,那么会触发 trigger ,遍历 dep ,从中找到与之关联的所有的 effect ,在调用 effect.run() 就会继续从 第一部分开始

原文链接:https://juejin.cn/post/7237531176587018300 作者:pnm学编程

(0)
上一篇 2023年5月28日 上午10:56
下一篇 2023年5月28日 上午11:06

相关推荐

发表回复

登录后才能评论