1. 逐行解读effect如何注册副作用函数

当我们通过effect将副函数向响应上下文注册后,副作用函数内访问响应式对象时即会自动收集依赖,并在相应的响应式属性发生变化后,自动触发副作用函数的执行。

// ./effect.ts

export function effect<T = any>(
  fn: () => T,
  options?: ReactiveEffectOptions
): ReactiveEffectRunner {
  if ((fn as ReactiveEffectRunner).effect) {
    fn = (fn as ReactiveEffectRunner).effect.fn
  }

  const _effect = new ReactiveEffect(fn)
  if (options) {
    extend(_effect, options)
    if (options.scope) recordEffectScope(_effect, options.scope)
  }
  // 默认是马上执行副作用函数收集依赖,但可通过lazy属性延迟副作用函数的执行,延迟依赖收集。
  if (!options || !options.lazy) {
    _effect.run()
  }
  // 类型为ReactiveEffectRunner的runner是一个绑定this的函数
  const runner = _effect.run.bind(_effect) as ReactiveEffectRunner
  runner.effect = _effect
  return runner
}

effect函数的代码十分少,主要流程是

  1. 将基于副作用函数构建ReactiveEffect对象
  2. 若为默认模式则马上调用ReactiveEffect对象的run方法执行副作用函数。

不过这里我们有几个疑问

  1. ReactiveEffectRunner是什么?
  2. ReactiveEffect生成的对象究竟是什么?显然ReactiveEffectrun方法才是梦开始的地方,到底它做了些什么?
  3. 针对配置项scoperecordEffectScope的作用?

1.1. ReactiveEffectRunner是什么?

// ./effect.ts

// ReactiveEffectRunner是一个函数,而且有一个名为effect的属性且其类型为ReactiveEffect
export interface ReactiveEffectRunner<T = any> {
  (): T
  effect: ReactiveEffect
}

1.2. ReactiveEffect生成的对象究竟是什么?

// 用于记录位于响应上下文中的effect嵌套层次数
let effectTrackDepth = 0
// 二进制位,每一位用于标识当前effect嵌套层级的依赖收集的启用状态
export left trackOpBit = 1
// 表示最大标记的位数
const maxMarkerBits = 30

const effectStack: ReactiveEffect[] = []
let activeEffect: ReactiveEffect | undefined

export class ReactiveEffect<T = any> {
  // 用于标识副作用函数是否位于响应式上下文中被执行
  active = true
  // 副作用函数持有它所在的所有依赖集合的引用,用于从这些依赖集合删除自身
  deps: Dep[] = []
  // 默认为false,而true表示若副作用函数体内遇到`foo.bar += 1`则无限递归执行自身,直到爆栈
  allowRecurse?: boolean

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

  run() {
    /**
     * 若当前ReactiveEffect对象脱离响应式上下文,那么其对应的副作用函数被执行时不会再收集依赖,并且其内部访问的响应式对象发生变化时,也会自动触发该副作用函数的执行
     */
    if (!this.active) {
      return this.fn()
    }
    // 若参与响应式上下文则需要先压栈
    if (!effectStack.includes(this)) {
      try {
        // 压栈的同时必须将当前ReactiveEffect对象设置为活跃,即程序栈中当前栈帧的意义。
        effectStack.push(activeEffect = this)
        enableTracking()

        trackOpBit = 1 << ++effectTrackDepth

        if (effectTrackDepth <= maxMarkerBits) {
          // 标记已跟踪过的依赖
          initDepMarkers(this)
        }
        else {
          cleanupEffect(this)
        }

        return this.fn()
      }
      finally {
        if (effectTrackDepth <= maxMarkerBits) {
          /**
           * 用于对曾经跟踪过,但本次副作用函数执行时没有跟踪的依赖,采取删除操作。
           * 即,新跟踪的 和 本轮跟踪过的都会被保留。
           */
          finalizeDepMarkers(this)
        }

        trackOpBit = 1 << --effectTrackDepth
        resetTracking()
        // 最后当然弹栈,把控制权交还给上一个栈帧咯
        effectStack.pop()
        const n = effectStack.length
        activeEffect = n > 0 ? effectStack[n - 1] : undefined  
      }
    }

    /**
     * 让当前ReactiveEffect对象脱离响应式上下文,请记住这是一去不回头的操作哦!
     */ 
    stop() {
      if (this.active) {
        cleanupEffect(this)
        this.active = false
      }
    }
  }
}

为应对嵌套effect内部将当前位于响应上下文的ReactiveEffect对象压入栈结构effectStack: ReactiveEffect[],当当前副作用函数执行后再弹出栈。另外,虽然我们通过effect函数将副作用函数注册到响应上下文中,但我们仍能通过调用stop方法让其脱离响应上下文。

function cleanupEffect(effect: ReactiveEffect) {
  const { deps } = effect
  if (deps.length) {
    // 将当前ReactiveEffect对象从它依赖的响应式属性的所有Deps中删除自己,那么当这些响应式属性发生变化时则不会遍历到当前的ReactiveEffect对象
    for (let i = 0; i < deps.length; ++i) {
      deps[i].delete(effect)
    }
    // 当前ReactiveEffect对象不再参与任何响应了
    deps.length = 0
  }
}

在执行副作用函数前和执行后我们会看到分别调用了enableTracking()resetTracking()函数,它们分别表示enableTracking()执行后的代码将启用依赖收集,resetTracking()则表示后面的代码将在恢复之前是否收集依赖的开关执行下去。要理解它们必须结合pauseTracking()和实际场景说明:

let shouldTrack = true
const trackStack: boolean[] = []

export function enableTracking() {
  trackStack.push(shouldTrack)
  shouldTrack = true
}

export function resetTracking() {
  const last = trackStack.pop()
  shouldTrack = last === undefined ? true : last
}

export function pauseTracking() {
  trackStack.push(shouldTrack)
  shouldTrack = false
}

假设我们如下场景

const values = reactive([1,2,3])
effect(() => {
  values.push(1)
})

由于在执行push时内部会访问代理对象的length属性,并修改length值,因此会导致不断执行该副作用函数直到抛出异常Uncaught RangeError: Maximum call stack size exceeded,就是和(function error(){ error() })()不断调用自身导致栈空间不足一样的。而@vue/reactivity是采用如下方式处理

;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
  instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
    pauseTracking()
    const res = (toRaw(this) as any)[key].apply(this, args)
    resetTracking()
    return res
  }
})

即通过pauseTracking()暂停push内部的发生意外的依赖收集,即push仅仅会触发以其他形式依赖length属性的副作用函数执行。然后通过resetTracking()恢复到之前的跟踪状态。

最后在执行副作用函数return this.fn()前,居然有几句难以理解的语句

try {
  trackOpBit = 1 << ++effectTrackDepth

  if (effectTrackDepth <= maxMarkerBits) {
    initDepMarkers(this)
  }
  else {
    cleanupEffect(this)
  }

  return this.fn()
}
finally {
  if (effectTrackDepth <= maxMarkerBits) {
    finalizeDepMarkers(this)
  }

  trackOpBit = 1 << --effectTrackDepth
}

我们可以将其简化为

try {
  cleanupEffect(this)

  return this.fn()
}
finally {}

为什么在执行副作用函数前需要清理所有依赖呢?我们可以考虑一下如下的情况:

const state = reactive({ show: true, values: [1,2,3] })
effect(() => {
  if (state.show) {
    console.log(state.values)
  }
})
setTimeout(() => {
  state.values.push(4)
}, 5000)

setTimeout(() => {
  state.show = false
}, 10000)

setTimeout(() => {
  state.values.push(5)
}, 15000)

一开始的时候副作用函数将同时依赖showvalues,5秒后向values追加新值副作用函数马上被触发重新执行,再过10秒后show转变为false,那么if(state.show)无论如何运算都不成立,此时再对values追加新值若副作用函数再次被触发显然除了占用系统资源外,别无用处。 因此,在副作用函数执行前都会先清理所有依赖(cleanupEffect的作用),然后在执行时重新收集。

面对上述情况,先清理所有依赖再重新收集是必须的,但如下情况,这种清理工作反而增加无谓的性能消耗

const state = reactive({ show: true, values: [1,2,3] })
effect(() => {
  console.log(state.values)
})

那么应该如何处理提高才能提高性能呢?请看下一节《依赖清理优化算法详解-算法思路》

1.3. 什么是EffectScope?

Vue 3.2引入新的Effect scope API,可自动收集setup函数中创建的effectwatchcomputed等,当组件被销毁时自动销毁作用域(scope)和作用域下的这些实例(effectwatchcomputed等)。这个API主要是提供给插件或库开发者们使用的,日常开发不需要用到它。

还记得petite-vue中的context吗?当遇到v-ifv-for就会为每个子分支创建新的block实例和新的context实例,而子分支下的所有ReactiveEffect实例都将统一被对应的context实例管理,当block实例被销毁则会对对应的context实例下的ReactiveEffect实例统统销毁。

block实例对应是DOM树中动态的部分,可以大概对应上Vue组件,而context实例就是这里的EffectScope对象了。

使用示例:

const scope = effectScope()
scope.run(() => {
  const state = reactive({ value: 1 })
  effect(() => {
    console.log(state.value)
  })
})
scope.stop()

那么effect生成的ReactiveEffect实例是如何和scope关联呢? 那就是ReactiveEffect的构造函数中调用的recordEffectScope(this, scope)

export function recordEffectScope(
  effect: ReactiveEffect,
  scope?: EffectScope | null
) {
  // 默认将activeEffectScope和当前副作用函数绑定
  scope = scope || activeEffectScope
  if (scope && scope.active) {
    scope.effects.push(effect)
  }
}

1.4. 小结

petite-vue中使用@vue/reactivity的部分算是剖析完成了,也许你会说@vue/reactivity可不止这些内容啊,这些内容我将会在后续的《vue-lit源码剖析》中更详尽的梳理分析,敬请期待。

Copyright © fsjohnhuang 2022 all right reserved,powered by GitbookFile Modify: 2022-04-26 09:47:23

results matching ""

    No results matching ""