Vue3的响应式API是怎么实现的?(effect特别篇①)

334 阅读5分钟

之前在ref中我们讲解了track和trigger,也就是响应式数据追踪依赖和触发重新执行的逻辑。但是关于如何注册副作用函数,我们只是在Vue3中的响应式API是怎么实现的?(computed篇)简单提了一下,也就是设置一个全局的activateEffect来作为当前激活的副作用,然后去读取依赖,然后依赖会收集activeEffect到targetMap中。但是注册副作用函数依然有很多复杂的边界条件需要处理,所以这篇文章就介绍一下注册副作用函数中,比较重要的边界处理条件,然后介绍如何实现更完善的注册副作用函数的effect。

effect中的分支切换问题和cleanup:

副作用函数有一种情况是,响应式数据变化之后,代码的执行分支切换了,所以切换之后执行不到的分支对应的响应式数据就不会被再读取,但是这个不被读取的数据,变化之后依然会导致这个副作用函数重新执行。但是这时候这个副作用已经和这个不被读取的响应式无关了:

const data = {
  ok: true,
  text: 'hello Vue'
}

// 创建深度响应的响应式对象obj
const obj = reactive(data)

// 使用effect注册副作用函数,让副作用函数中的响应式依赖收集它
effect(function changeTextnode() {
  document.body.innerText = obj.ok ? obj.text : 'nothing'
})

// 此时changeTextNode会重新运行,但是已经和data.text无关了
obj.ok = false

// 虽然data为false,changeTextnode不会再读取data.text了,
// 但是changeTextNode仍然会重新运行,因为它之前被data.text收集过,
// 所以无关的副作用函数运行造成了性能浪费
obj.text = 'it not working but still invokes the changeTextnode'

如你所见,ok为false的时候,document.body.innerText = 'nothing',所以这个副作用函数这时候就不在依赖obj.text,但是因为obj.text收集过它,所以当obj.text改变时changeTextNode依然会运行,不过运行一个并不依赖于它的副作用没什么用,只是单纯的性能损失,所以要对这种情况进行处理。

解决的办法也很简单粗暴,每次副作用函数执行时,把这个副作用函数从所有相关联的响应式数据的依赖集合中清除。因为每次运行副作用函数,会重新进行读取响应式依赖,所以又会重新追踪所有相关的响应式数据,如果某个数据分支切换后读取不到,那么说明副作用函数不再依赖于它了,而这正是我们想要的结果。

所以要实现这一点,就要求effect在注册副作用函数时,能够记录他被哪个依赖集合所收集了,我们知道收集是在track中,我直接把响应式API实现ref篇上这篇文章中的track和computed篇中的effect搬过来:

function effect(fn, options = {}) {
  const effectFn = () => {
      activateEffect = effectFn
      // 返回fn的结果
      const res = fn()
      return res
  }
  // 将options添加到effectFn上
  effectFn.options = options
  // 保存所有与该副作用函数相关的依赖
  effectFn.deps = []
  // 如果option中不指定懒执行
  if (!options.lazy) {
    effectFn()
  }
  return effectFn
}
function track(target, key) {
  if (!activateEffect) return
  let depsMap = bucket.get(target)
  //找不到就新建一个,注意是使用的Map
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }
  let deps = depsMap.get(key)
  //找不到就新建一个
  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  // 收集全局唯一的activeEffect
  deps.add(activateEffect)
  // 让每个副作用函数都能知道时谁收集了自己,用于解除追踪
  activateEffect.deps.push(deps)
}

在注册副作用函数的时候,在effectFn上添加一个deps数组(函数也是对象),让它可以知道被谁收集了,在track过程中,deps收集activateEffect的时候,activateEffect通过之前添加的的deps数组也收集deps(注意这两个deps是不同的)。注释中的可以用来解除追踪,也就是我们现在所说的这种情况:解除多余的依赖。

有了这个effectFn.deps, 就可以在每次副作用函数运行前执行清理:

function cleanup(effectFn) {
  for (let i = 0; i < effectFn.deps.length; i++) {
    // 响应式数据的副作用函数集合
    const deps = effectFn.deps[i]
    // 删除自己
    deps.delete(effectFn)
  }
  // 重置length
  effectFn.deps.length = 0
}
function effect(fn, options = {}) {
  const effectFn = () => {
      //运行伊始,执行清理过程
      cleanup(effectFn)
      
      activateEffect = effectFn
      
      // 返回fn的结果
      // 执行的时候重新追踪依赖
      const res = fn()
      return res
  }
  // 将options添加到effectFn上
  effectFn.options = options
  // 保存所有与该副作用函数相关的依赖
  effectFn.deps = []
  // 如果option中不指定懒执行
  if (!options.lazy) {
    effectFn()
  }
  return effectFn
}

可以看到在effect中,effectFn在执行之前,会首先执行cleanup,清除所关联的响应式数据中的他自己,然后运行,然后响应式数据重新和effectFn建立关联(track)。

然后我们再来看之前ref篇中的trigger:

// 触发执行
function trigger(target, key) {
  // 寻找target
  const depsMap = targetMap.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  
  //为什么要设置使用一份复制的effect呢?
  const effectToRun = new Set()
  
  // 如果注册activeEffect的过程中,改变了响应式数据,会导致又track又trigger的过程,引发无线递归。
  // 所以去掉当前正在激活的副作用函数
  effects && effects.forEach((fn) => {
      if (fn !== activateEffect) {
        effectToRun.add(fn)
      }
  })
  
  effectToRun.forEach(fn => fn())
}

之前这里没有说明白,就是关于为什么需要effectToRun,原因就是我们这里的cleanup机制,我们知道,trigger和track中的deps都是一个set对象,但是set有一个特点,就是遍历时如果新添加了之前被访问过的数据,那么就会遍历到新添加的数据:

const set = new Set([1])
set.forEach((v) => {
  set.delete(1)
  set.add(1)
})

我们之前所说的,函数执行时先执行cleanup时,会遍历删除掉set中的自己,运行的时候函数重新被set收集。这个过程是在trigger中发生,这时候会发生上面代码中的情况:遍历过程中执行函数,首先运行到删除,这时候删除了,然后执行过程中又收集了。会导致set无限遍历这个数据。

所以为了解决这个问题,我们就使用拷贝来遍历执行副作用函数,这时候删除是在另外一个set中发生的,添加也是在另外一个set中发生的。但遍历的是拷贝,就避免了无限循环的情况。

所以这就是cleanup的问题,在源码中也占据了相当的篇幅,是比较重要的部分