关于vue中删除依赖的那些事

1,632 阅读6分钟

前言


个人在vue源码的学习过程中,不仅仅觉得数据响应式实现的非常的巧妙,在设计收集依赖的环节中,后续的删除依赖的处理也是非常的细节贴心的,也不得不感叹作者满满的细节。那么vue是在何时删除依赖的?删除依赖的作用及场景又是什么呢?接下来我们带着问题,一步步地分析源码实现,相信这会让你更加深入理解响应式原理。如果还有对响应式原理不大熟悉的小伙伴,可以参考下我的写的数据驱动

示例

考虑如下代码:

// contentA 和 contentB, isShow都是data中定义的数据
 <div>
 	<div id="A" v-if="isShow">{{ contentA }}</div>
    <div id="B" v-else>{{ contentB }}</div>
    <button @click="toggle">toggle</button> // toggle用来改变isShow的状态
    <button @click="changeA">changeA</button> // 改变contentA
    <button @click="changeB">changeB</button> // 改变contentB
 </div>
 data () {
 	isShow: true,
    contentA: '',
    contentB: ''
 },
 methods: {
 	toggle () { this.isShow = !this.isShow },
    changeA () { this.contentA += 'a' },
    changeB () { this.contentB += 'b' }
 }

我们接下来以元素的ID代指两个元素。 我们知道,在组件初始化时,都会执行mountComponent函数,从而new Watcher(渲染watcher),当生成一个渲染wathcer的时候就会执行render函数,从而访问到组件上的数据,因为当前组件上isShow是true,所以B元素是不会进行渲染的,所以只会访问到contentA的值,所以就只触发了contetA的getter,所以对于contentA所持有的Dep实例就会收集到当前的渲染wathcer。(为了更好地看到效果,小伙伴们可以在源码的defineReactive的set函数里打上断点,进行调试)
此时如果我们点击changeA,会触发对应的setter函数,点击changeB则不会有任何反应,因为根本就没做依赖收集。
接着我们点击toggle,切换isShow的状态,让B显示A消失,此时如果我们在点击changeA,并没有走到setter中,刚刚明明已经做了依赖收集了,为啥不触发了?但是大家想想这是合理的吗?我觉得是很合理的设计,一个元素都已经不渲染了,为啥还需要对相关的watcher进行派发更新呢?这对性能也是一种消耗。接下来我们一起来分析一下,vue是怎么做到的。

删除依赖

get () {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      value = this.getter.call(vm, vm)
    } catch (e) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      popTarget()
      this.cleanupDeps() // 进行依赖的删除
    }
    return value
  }

  /**
   * Add a dependency to this directive.
   */
  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }

在分析删除依赖收集之前,我们还是先看看addDep函数具体逻辑,这跟后面分析的依赖删除有着密切的关联。
addDep的时机就是依赖收集的时候,当执行dep.depend()的时候,实际上就是执行了watcher.addDep逻辑

  • 首先会判断当前的dep实例的id是否存在于watcher.newDepIds中,不存在则将dep添加到newDeps中,dep.id添加到newDepsIds中
  • 接着会继续判断id是否存在于depIds中,如果不存在,则执行dep.addSub(this),就是把当前的watcher添加到dep.subs数组中

对应我们示例代码的首次渲染而言,这两个判断逻辑都会进入,所以此时会有下面两个结果。

  • 渲染watcher的newsDep数组会存放了contentA所持有的Dep实例,newsIds则存放了对应的dep.id
  • contentA所持有的Dep实例的subs数组中也存有渲染Watcher。

对于渲染Wachter而言,进行new操作的时候,就会执行this.get方法,从而执行了render函数,触发了依赖收集,最后走到了this.cleanupDeps函数,具体代码如下:

/**
   * Clean up for dependency collection.
   */
cleanupDeps () {
  let i = this.deps.length // this.deps 存放旧的dep实例数组
  while (i--) {
    const dep = this.deps[i]
    if (!this.newDepIds.has(dep.id)) {
      dep.removeSub(this)
    }
  }
  // 每次收集完依赖之后将 newDeps 和deps交换 newDepIds跟depIds交换, 接着对newDeps和newDepIds清空
  let tmp = this.depIds
  this.depIds = this.newDepIds
  this.newDepIds = tmp
  this.newDepIds.clear()
  tmp = this.deps
  this.deps = this.newDeps
  this.newDeps = tmp
  this.newDeps.length = 0
}

对于示例代码首次依赖收集的时候,会走以下流程

  • this.deps一开始是一个空数组,所以直接跳过while语句
  • 之后就会将newDeps跟deps, newDepIds 和depIds进行交换,从而保证了本次的newDeps跟newDepIds是下次依赖收集时,作为新的deps跟depIds,从而比较两次依赖收集时的差异,进行删除依赖的操作。

接着当我点击toggle按钮,进行切换的时候,这个时候A消失,B显示了出来,因为在切换的时候isShow的发生了变化,所以就会触发对应的setter函数,因为dep实例中订阅了渲染wather,所以执行dep.notify(),wathcer.update(),页面又会重新进行渲染,进而又执行了render函数。此时A是消失的,B是显示,在render的过程中,只能访问到B上的数据,A的数据没访问到,又重新走了一遍依赖收集的过程。

toggle --> setter --> dep.notify --> wather.update --> render --> 依赖收集(getter) --> watcher.addDep

对于我们示例代码的切换更新阶段addDep具体逻辑如下:

  • 因为当前的dep实例是对应B的contentB所持有的,所以是一个新的dep,this.newDepIds是一个空数组,所以会将当前的dep实例跟id都添加到newDepIds跟newDeps中
  • this.depIds此时存放的只有上次A的contentA所持有的depId,所以也会将当前的渲染wathcer添加到dep.subs数组中

接着执行cleanupDeps函数,流程如下:

  • this.deps中存放了上次A中contentA所持有的dep实例,执行while语句,因为this.newDepIds中现在只有B中contentB所持有的depId,所以会执行到dep.removeSub(this)函数,removeSub的作用就把watcher从dep.subs数组中删除
  • 接下来还是将newDeps和deps交换 newDepIds跟depIds交换,接着对newDeps和newDepIds清空。

此时我们在点击changeA按钮,执行代码

this.contentA += 'a'

因为contentA被改变了,所以会触发setter函数,进而执行dep.notify()函数

notify () {
  // stabilize the subscriber list first
  const subs = this.subs.slice()
  if (process.env.NODE_ENV !== 'production' && !config.async) {
    // subs aren't sorted in scheduler if not running async
    // we need to sort them now to make sure they fire in correct
    // order
    subs.sort((a, b) => a.id - b.id)
  }
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}

但是此时dep.subs数组为空,因为在cleanupDeps函数中,我们执行了dep.removeSub(this),把渲染wathcer从contentA的dep.subs中删除掉,所以执行notify时相当于一个空函数,并不会触发视图的渲染,从而达到了性能优化的细节处理。

总结


主要逻辑在于clearupDeps函数,每次进行收集依赖的时候,都会将当前watcher中新的newDeps跟旧的deps作对比,比较出两者之间的差异之后,如果旧的Dep不在新的newDeps数组中,就把dep所订阅的watcher从subs数组中删除掉,从而删除掉依赖。至此,删除依赖的逻辑我们就分析完了,希望对大家有所帮助。文中有不对的地方,还望指出!!

参考文章