依赖收集

在这一节中,我们来介绍依赖收集,在介绍之前我们需要知道什么是依赖收集,以及依赖收集的目的。

问:什么是依赖收集?依赖收集的目的是什么?
答:依赖收集就是对订阅数据变化的Watcher收集的过程。其目的是当响应式数据发生变化,触发它们的setter时,能够知道应该通知哪些订阅者去做相应的逻辑处理。例如,当在template模板中使用到了某个响应式变量,在组件初次渲染的时候,对这个响应式变量而言,应该收集render watcher依赖,当其数据发生变化触发setter时,要通知render watcher进行组件的重新渲染。

在之前我们提到过,依赖收集发生在Object.defineProperty()getter中,我们回顾一下defineReactive()代码:

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 省略代码
  const dep = new Dep()
  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    }
  })
}

我们可以从代码中看到,当触发getter的时候,首先判断了Dep.target是否存在,如果存在则调用dep.depend()dep.depend()函数就是依赖真正收集的地方。在阅读完以上代码后,我们可能会有这样几个疑问:

  • Dep是什么?
  • Dep.target是什么?
  • dep.depend是如何进行依赖收集的?又是如何进行依赖移除的?

Dep

让我们首先来回答第一个问题,介绍一下Dep类,Dep类是定义在observer目录下dep.js文件中的一个类,observer目录结构如下:

|-- observer       
|   |-- array.js
|   |-- dep.js
|   |-- index.js
|   |-- scheduler.js
|   |-- traverse.js
|   |-- watcher.js

然后,我们来看一下Dep类的具体定义:

let uid = 0
export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  removeSub (sub: Watcher) {
    remove(this.subs, sub)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  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类首先定义了一个静态属性target,它就是Dep.target,我们会在之后介绍它。然后又定义了两个实例属性,idDep的主键,会在实例化的时候自增,subs是一个存储各种Watcher的数组。例如render watcheruser watchercomputed watcher等。
  • addSubremoveSub对应的就是往subs数组中添加和移除各种Watcher
  • depend为依赖收集过程。
  • notify当数据发生变化触发setter的时候,有一段这样的代码:dep.notify(),它的目的就是当这个响应式数据发生变化的时候,通知subs里面的各种watcher,然后执行其update()方法。这属于派发更新的过程,我们会在之后的章节介绍。

在介绍完以上几个属性和方法后,我们就对Dep是什么以及它做哪些事情有了一个具体的认识。

Dep.target和Watcher

我们接下来回答第二个问题,Dep.target是什么?Dep.target就是各种Watcher的实例,以下面代码举例说明:

<tempalte>
  <div>{{msg}}</div>
</template>

<script>
export default {
  data () {
    return {
      msg: 'Hello, Vue.js'
    }
  }
}
</script>

当组件初次渲染的时候,会获取msg的值,然后执行pushTarget(this),其中this代表当前Watcher实例,pushTarget()函数是定义在dep.js文件中的一个方法,与之对应的还有一个叫做popTarget方法,它们的代码如下:

Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

pushTarget中,我们传递的target参数就是Watcher实例,然后在pushTarget执行的时候,它会动态设置Dep的静态属性Dep.target的值。在分析完pushTarget函数的代码后,我们就能明白为什么说Dep.target就是各种Watcher的实例了。

然后,我们会存在一个新的问题:Watcher类是如何定义的?它其实是定义在watcher.js文件中一个类,其关键代码如下:

let uid = 0

export default class Watcher {
  vm: Component;
  expression: string;
  cb: Function;
  id: number;
  deep: boolean;
  user: boolean;
  lazy: boolean;
  sync: boolean;
  dirty: boolean;
  active: boolean;
  deps: Array<Dep>;
  newDeps: Array<Dep>;
  depIds: SimpleSet;
  newDepIds: SimpleSet;
  before: ?Function;
  getter: Function;
  value: any;

  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)
    // options
    if (options) {
      this.deep = !!options.deep
      this.user = !!options.user
      this.lazy = !!options.lazy
      this.sync = !!options.sync
      this.before = options.before
    } else {
      this.deep = this.user = this.lazy = this.sync = false
    }
    this.cb = cb
    this.id = ++uid // uid for batching
    this.active = true
    this.dirty = this.lazy // for lazy watchers
    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()
    this.expression = process.env.NODE_ENV !== 'production'
      ? expOrFn.toString()
      : ''
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
      if (!this.getter) {
        this.getter = noop
        process.env.NODE_ENV !== 'production' && warn(
          `Failed watching path: "${expOrFn}" ` +
          'Watcher only accepts simple dot-delimited paths. ' +
          'For full control, use a function instead.',
          vm
        )
      }
    }
    this.value = this.lazy
      ? undefined
      : this.get()
  }
  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
  }
  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)
      }
    }
  }
  cleanupDeps () {
    let i = this.deps.length
    while (i--) {
      const dep = this.deps[i]
      if (!this.newDepIds.has(dep.id)) {
        dep.removeSub(this)
      }
    }
    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
  }
  depend () {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
}

从依赖收集的角度去看Watcher类的时候,我们在其构造函数中需要关注以下四个属性:

this.deps = []             // 旧dep列表
this.newDeps = []          // 新dep列表
this.depIds = new Set()    // 旧dep id集合
this.newDepIds = new Set() // 新dep id集合

我们会在之后的addDepcleanupDeps环节详细介绍以上四个属性的作用,在这一小节,我们主要关注Watcher的构造函数以及get()方法的实现。

Watcher类的构造函数中,当实例化时,depsnewDeps数组以及depIdsnewDepIds集合分别被初始化为空数组以及空集合,在构造函数的最后,判断了如果不是computed watcher(注:只有computed watcherlazy属性才为true),则会马上调用this.get()函数进行求值。

接下来,我们来分析一下this.get()方法的实现,以及pushTargetpopTarget方法配合使用的场景介绍。

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
}

我们可以看到,get()方法的代码不是很复杂,在方法的最前面首先调用pushTarget(this),通过pushTarget()方法首先把当前Watcher实例压栈到target栈数组中,然后把Dep.target设置为当前的Watcher实例。

Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

然后调用this.getter进行求值,拿以下计算属性示例来说:

export default {
  data () {
    return {
      age: 23
    }
  },
  computed: {
    newAge () {
      return this.age + 1
    }
  }
}

value = this.getter.call(vm, vm)
// 相当于
value = newAge()

对于computed watcher而言,它的getter属性就是我们撰写的计算属性方法,调用this.getter的过程,就是执行我们撰写的计算属性方法进行求值的过程。

this.get()方法的最后,调用了popTarget(),它会把当前target栈数组的最后一个移除,然后把Dep.target设置为倒数第二个。

Dep.target = null
const targetStack = []

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

在分析了pushTargetpopTarget后,我们可能会有一个疑问,就是为什么会存在这样的压栈/出栈的操作,这样做的目的是什么?

这样做的目的是因为组件是可以嵌套的,使用栈数组进行压栈/出栈的操作是为了在组件渲染的过程中,保持正确的依赖,以下面代码为例:

// child component
export default {
  name: 'ChildComponent',
  template: '<div>{{childMsg}}</div>',
  data () {
    return {
      childMsg: 'child msg'
    }
  }
}

export default {
  name: 'ParentComponent',
  template: `<div>
    {{parentMsg}}
    <child-component />
  </div>`,
  components: {
    ChildComponent
  }
  data () {
    return {
      parentMsg: 'parent msg'
    }
  }
}

我们都知道,组件渲染的时候,当父组件中有子组件时,会先渲染子组件,子组件全部渲染完毕后,父组件才算渲染完毕,因此组件渲染钩子函数的执行顺序为:

parent beforeMount()
child beforeMount()
child mounted()
parent mounted()

根据以上渲染步骤,当parent beforeMount()开始执行时,会进行parent render watcher实例化,然后调用this.get(),此时的Dep.target依赖为parent render watchertarget栈数组为:

// 演示使用,实际为Watcher实例
const targetStack = ['parent render watcher']

child beforeMount开始执行的时候,会进行child render watcher实例化,然后调用this.get(),此时的Dep.target依赖为child render watchertarget栈数组为:

// 演示使用,实际为Watcher实例
const targetStack = ['parent render watcher', 'child render watcher']

child mounted()执行时,代表子组件的this.getter()调用完毕,进而会调用popTarget()进行出栈操作,此时的栈数组和Dep.target会发生变化:

// 演示使用,实际为Watcher实例
const targetStack = ['parent render watcher']
Dep.target = 'parent render watcher'

parent mounted()执行时,代表父组件的this.getter()调用完毕,进而会调用popTarget()进行出栈操作,此时的栈数组和Dep.target会发生变化:

// 演示使用,实际为Watcher实例
const targetStack = []
Dep.target = undefined

通过以上示例分析,我们就弄明白了为什么会有依赖压栈/出栈这样的步骤以及这样做的目的了。接下来,让我们来分析依赖收集的过程中,addDepcleanupDeps的逻辑。

addDep和cleanupDeps

addDep

在之前Dep类的depend()方法中,我们介绍过其代码实现,它会调用addDep(dep)

export default Dep {
  // 省略其它代码
  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}

根据前面的分析内容,我们知道Dep.target其实就是各种Watcher实例,因此Dep.target.addDep(this)相当于:

const watcher = new Watcher()
watcher.addDep(this)

接下来,让我们来看一下Watcher类中,addDep方法的实现逻辑:

export default Watcher {
  // 精简代码
  constructor () {
    this.deps = []              // 旧dep列表
    this.newDeps = []           // 新dep列表
    this.depIds = new Set()     // 旧dep id集合
    this.newDepIds = new Set()  // 新dep id集合
  }
  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方法的逻辑不是很复杂,首先判断了当前dep是否已经在新dep id集合中,不在则更新新dep id集合以及新dep数组,随后又判断了当前dep是否在旧dep id集合中,不在则调用dep.addSub(this)方法,把当前Watcher实例添加到dep实例的subs数组中。

生硬的分析源码不是很方便我们理解addDep的代码逻辑,我们以下面代码示例说明:

<template>
  <p>位置一:{{msg}}</p>
  <p>位置二:{{msg}}</p>
</template>
<script>
export default {
  name: 'App',
  data () {
    return {
      msg: 'msg'
    }
  }
}
</script>

过程分析:

  • 当组件初次渲染的时候,会实例化render watcher,此时的Dep.targetrender watcher
const updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)
  • 第一次编译读取msg响应式变量时,触发getter进行dep.depend()依赖收集,然后调用addDep()方法,因为depsnewDepsdepIdsnewDepIds初始化为空数组或者空集合,所以此时的dep被添加到newDepIdsnewDeps中并且会执行dep.addSub(this),此时可以用下面代码表示:
// 实例化Dep
const dep = {
  id: 1,
  subs: []
}

// 添加到newDepIds,newDeps
this.newDepIds.push(1)
this.newDeps.push(dep)

// 调用addSub
dep.addSub(this)
console.log(dep) // { id: 1, subs: [new Watcher()] }
  • 当第二次编译读取msg响应式变量时,触发getter进行dep.depend依赖收集,因为depdefineReactive函数中的闭包变量,因此两次触发的getter是同一个dep实例。当调用addDep判断此时的newDepIds集合中dep.id1已经存在,因此直接跳过。

你可能会发现,在分析getter中代码的时候,我们故意忽略了下面这段代码:

if (childOb) {
  childOb.dep.depend()
  if (Array.isArray(value)) {
    dependArray(value)
  }
}

你可能会有这样的疑问:这点代码是干什么的?有什么作用?那么现在,我们举例说明:

<template>
  <p>{{obj.msg}}</p>
</template>
<script>
export default {
  name: 'App',
  data () {
    return {
      obj: {
        msg: 'msg'
      }
    }
  }
}
</script>

过程分析:

  • 当第一次调用defineReactive时,此时defineReactive第一个参数objkey分别为:
obj = {
  obj: {
    msg: 'msg'
  }
}

key = 'obj'

defineReactive在最开始,实例化了一个闭包dep实例,我们假设实例化后的dep如下:

const dep = new Dep()
console.log(dep) // { id: 1, subs: [] }

当代码执行到observe(val)的时候,根据之前我们分析过observe代码的逻辑,因为参数obj[key]的值是一个普通对象,因此会执行new Observer()实例化,而在Observer构造函数中,有这样一段代码:

this.dep = new Dep()

它又实例化了一个dep并且把实例化后的dep赋值给this.dep,我们假设此时实例化后的dep如下所示:

const dep = new Dep()
console.log(dep) // { id: 2, subs: [] }

因为obj = { msg: 'msg' }是一个对象,因此执行this.walk()遍历obj对象的属性,然后再次调用defineReactive又实例化了一个闭包dep实例,我们假设实例后的dep如下所示:

const dep = new Dep()
console.log(dep) // { id: 3, subs: [] }

现在,我们已经有了三个dep实例了,其中两个是defineReactive函数中的闭包实例dep,另外一个是childOb(Observer实例)的属性dep

  • 在组件开始渲染的时候,根据响应式原理加上我们在template中读取了obj.msg变量,因此会先触发obj对象的getter,此时depid=1的那个闭包变量dep。此时的Dep.targetrender watcher,然后进行dep.depend()依赖收集,当走到addDep方法的时候,因为我们关注的四个属性全部为空数组或者空集合,因此会把此时的dep添加进去,此时的dep表示如下:
const dep = {
  id: 1,
  subs: [new Watcher()]
}
  • dep.depend()依赖收集完毕后,会判断childOb,因为childObObserver的实例,因此条件判断为真,调用childOb.dep.depend()。当执行到addDep()时,此时的depid=2的那个Observer实例属性dep,不在newDepIdsdepIds中,因此会把其添加进去,此时的dep表示如下:
const dep = {
  id: 2,
  subs: [new Watcher()]
}
  • 当响应式变量objgetter触发完毕后,会触发obj.msggetter,此时的depid=3的那个闭包变量dep。此时的Dep.target依然为render watcher,然后进行dep.depend()依赖收集,这个过程与objgetter进行依赖收集的过程基本是一样的,当addDep()方法执行后,此时的dep表示如下:
const dep = {
  id: 3,
  subs: [new Watcher()]
}

唯一的区别时,此时的childObundefined,不会调用childOb.dep.depend()进行子属性的依赖收集。

在分析完以上代码后,我们很容易回答一下问题:
问:childOb.dep.depend()是干什么的?有什么作用?
答:childOb.dep.depend()这段代码是进行子属性的依赖收集,这样做的目的是为了当对象或者对象属性任意一个发生变化时,都可以通知其依赖进行相应的处理。

<template>
  <p>{{obj.msg}}</p>
  <button @click="change">修改属性</button>
  <button @click="add">添加属性</button>
</template>
<script>
import Vue from 'vue'
export default {
  name: 'App',
  data () {
    return {
      obj: {
        msg: 'msg'
      }
    }
  },
  methods: {
    change () {
      this.obj.msg = 'new msg'
    },
    add () {
      this.$set(this.obj, 'age', 23)
    }
  },
  watch: {
    obj: {
      handler () {
        console.log(this.obj)
      },
      deep: true
    }
  }
}
</script>

拿以上例子说明:

  • 当存在childOb.dep.depend()收集子属性依赖时,我们无论是修改msg的值还是添加age新属性,都会触发user watcher,也就是打印this.obj的值。
  • 当不存在childOb.dep.depend()收集子属性依赖时,我们修改msg的值,虽然会通知render watcher进行组件重新渲染,但不会通知user watcher打印this.obj的值。

cleanupDeps

在这一小节,我们的目标是弄清楚为什么要进行依赖清除以及如何进行依赖清除。

先来看Watcher类中对于cleanupDeps的实现:

export default Watcher {
  // 精简代码
  constructor () {
    this.deps = []              // 旧dep列表
    this.newDeps = []           // 新dep列表
    this.depIds = new Set()     // 旧dep id集合
    this.newDepIds = new Set()  // 新dep id集合
  }
  cleanupDeps () {
    let i = this.deps.length
    while (i--) {
      const dep = this.deps[i]
      if (!this.newDepIds.has(dep.id)) {
        dep.removeSub(this)
      }
    }
    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
  }
}

我们还是举例说明,假如有如下组件:

<template>
  <p v-if="count < 1">{{msg}}</p>
  <p v-else>{{age}}</p>
  <button @click="change">Add</button>
</template>
<script>
import Vue from 'vue'
export default {
  name: 'App',
  data () {
    return {
      count: 0,
      msg: 'msg',
      age: 23
    }
  },
  methods: {
    change () {
      this.count++
    }
  }
}
</script>

过程分析:

  • 当组件初次渲染完毕后,render watcher实例的newDeps数组有两个dep实例,其中一个是在count响应式变量getter被触发时收集的,另外一个是在msg响应式变量getter被触发时收集的(age因为v-if/v-else指令的原因,在组件初次渲染的时候不会触发agegetter),我们使用如下代码进行表示:
this.deps = []
this.newDeps = [
  { id: 1, subs: [new Watcher()] },
  { id: 2, subs: [new Watcher()] }
]
  • 当我们点击按钮进行this.count++的时候,会触发组件重新更新,因为count < 1条件为假,因此在组件重新渲染的过程中,也会触发age响应式变量的getter进行依赖收集。当执行完addDep后,此时newDeps发生了变化:
this.deps = [
  { id: 1, subs: [new Watcher()] },
  { id: 2, subs: [new Watcher()] }
]
this.newDeps = [
  { id: 1, subs: [new Watcher()] },
  { id: 3, subs: [new Watcher()] }
]
this.depIds = new Set([1, 2])
this.newDepIds = new Set([1, 3])

在最后一次调用this.get()的时候,会调用this.cleanupDeps()方法,在这个方法中首先遍历旧依赖列表deps,如果发现其中某个dep不在新依赖id集合newDepIds中,则调用dep.removeSub(this)移除依赖。在组件渲染的过程中,this代表render watcher,调用这个方法后当我们再修改msg变量值的时候,就不会触发组件重新渲染了。在遍历完deps数组后,会把depsnewDepsdepIdsnewDepIds的值进行交换,然后清空newDepsnewDepIds

在分析完以上示例后,我们就能明白为什么要进行依赖清除了:避免无关的依赖进行组件的重复渲染、watch回调等

最后更新时间:
贡献者: wangtunan