vue3学习与my-vue3实现04: cleanup

386 阅读4分钟

上章问题分析

// vue/examples/reactivity/problem.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="../../dist/vue.js"></script>
    <style>
    </style>
  </head>
  <body>

  </body>
  <script>
    const { reactive, effect } = Vue
    const obj = reactive({
      name: 'echo',
      ok: true,
    })
    effect(() => {
      // 打印三次
      console.log('effect run')
      document.body.innerText = obj.ok ? obj.name : 'not'
    })
    obj.ok = false
    obj.name = 'yahaa'
  </script>
</html>
分支切换

要解决上一章的问题,我首先需要引入一个概念——分支切换。在上面的代码中,我们可以看到effectFn内部存在着一个三元表达式,会根据字段obj.ok值的不同执行不同的代码分支,这就是所谓的分支切换。

而分支切换就为我们的问题埋下的伏笔。拿上面的代码来说,字段obj.ok的初始值为true,这时会读取字段obj.text的值,所以当effectFn函数执行时会触发字段obj.ok和字段obj.text这个两个属性的读取操作,副作用函数也就与这个两个属性建立了联系。但是当obj.ok的值改为false后,并触发副作用函数执行后,此时字段obj.text不会被读取,理想状态下副作用函数effectFn不应该与字段obj.text建立联系,但是我们知道程序并不如我们所预期的那样,obj.ok被改为false后,我们继续修改obj.text的值,由于前一次obj.text读取操作与副作用函数effectFn建立的关联还存在于“桶”中,仍会导致副作用函数被重新执行,这时就产生了遗留的副作用函数。这也就是为什么effect run会被打印三次。

cleanup

解决这个问题的方法就是每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除。

Page 2 (1).webp
ReactiveEffect

要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它,因此需要我们重新设计副作用函数。如下面代码所示,我们定义了一个ReactiveEffect类,并为其添加了deps属性,用来存储所有包含当前副作用函数的依赖集合:

// reactivity/src/effect.ts
export let activeEffect: ReactiveEffect | undefined

export class ReactiveEffect<T = any> {
  // 用于存储所有与该副作用函数相关联的依赖集合
  deps: Dep[] = []

  constructor(public fn: () => T) {
  }

  run() {
    // 当调用effect注册副作用函数时,将这个类赋值给activeEffect
    activeEffect = this!
    // 执行副作用函数
    return this.fn()
  }
}

// 用于注册副作用函数
export function effect<T = any>(fn: () => T) {
  const _effect = new ReactiveEffect(fn)
  _effect.run()
}
trackEffects

ReactiveEffect中的deps依赖集合我们在track函数收集:

// reactivity/src/effect.ts
export function track(target: object, key: unknown) {
  if (!activeEffect)
    return
  let depsMap = targetMap.get(target)
  if (!depsMap)
    targetMap.set(target, (depsMap = new Map()))
  // 参照vue源码修改为dep  
  let dep = depsMap.get(key as string)
  if (!dep)
    depsMap.set(key as string, (dep = createDep())) // 修改
  trackEffects(dep)
}

export function trackEffects(dep: Dep) {
  // 将当前激活的副作用函数收集到桶中
  dep.add(activeEffect!)
  // dep就是一个与当前副作用函数存在联系的依赖集合
  // 将其添加到activeEffect.deps数组中
  activeEffect!.deps.push(dep)
}
// reactivity/src/dep.ts
import type { ReactiveEffect } from './effect'

export type Dep = Set<ReactiveEffect>

export const createDep = (effects?: ReactiveEffect[]): Dep => {
  const dep = new Set<ReactiveEffect>(effects) as Dep
  return dep
}

在track函数中我们将当前执行的副作用函数activeEffect添加到依赖集合dep中,这说明dep是一个与当前副作用函数存在联系的一来就集合。于是我们也把dep添加到activeEffect.deps数组中,这样就完成了对依赖集合的收集。

有了对依赖集合的收集,我们就可以在每次副作用函数执行时,在ReactiveEffect对象属性deps中获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除:

// reactivity/src/effect.ts
export class ReactiveEffect<T = any> {
  // 用于存储所有与该副作用函数相关联的依赖集合
  deps: Dep[] = []

  constructor(public fn: () => T) {
  }

  run() {
    // 调用cleanupEffect函数完成清除工作
    cleanupEffect(this)
    activeEffect = this!
    return this.fn()
  }
}

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
  }
}
export function trigger(target: object, key: unknown) {
  const depsMap = targetMap.get(target)
  if (!depsMap)
    return
  const effects = depsMap.get(key as string)
  effects && effects.forEach(effect => effect.run()) //修改
}
死循环

至此我们响应系统已经可以避免副作用函数产生遗留了。但是当我们尝试运行代码的时候,会发现当前的实现会导致死循环,问题出现在trigger中:

export function trigger(target: object, key: unknown) {
  const depsMap = targetMap.get(target)
  if (!depsMap)
    return
  const effects = depsMap.get(key as string)
  effects && effects.forEach(effect => effect.run()) //问题在这一行
}

在trigger函数内部,我们遍历effects集合,它是一个Set集合,里面存储着副作用函数。当副作用函数执行的时候,会调用cleanupEffect从effects集合中将当前执行的副作用函数剔除,但是副作用的执行会导致其被重新被收集到集合中,而此时effects集合的遍历仍会继续进行。在语言规范中对此情况有说明,在调用forEach遍历Set集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,那么该值会重新被访问。而想要解决这个问题,只需要我们构造另外一个Set集合并遍历它就可以了。

export function trigger(target: object, key: unknown) {
  const depsMap = targetMap.get(target)
  if (!depsMap)
    return
  // 修改
  const deps = depsMap.get(key as string)
  // 新增
  const effects: ReactiveEffect[] = []
  deps?.forEach((dep) => {
    if (dep)
      effects.push(dep)
  })
  effects.forEach(effect => effect.run())
}

编译后再运行文章开头的代码,我们就可以看到effect run只被打印了两次。

代码仓库

github.com/KoiraCMT/my…