Vue之深入响应式原理(二)

三、依赖收集

目标

  • 了解什么是依赖收集
  • 了解依赖收集的流程以及它的目的

上面我们了解 Vue 会把普通对象变成响应式对象,响应式对象 getter 相关的逻辑就是做依赖收集,现在我们来详细分析这个过程。

我们先来回顾一下 getter 部分的逻辑:

export function defineReactive(obj, key, val, customSetter, shallow) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  const getter = property && property.get
  const setter = property && property.set

  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  // 递归观测,当然前提val不是基础类型,observe中已作判断
  let childOb = !shallow && observe(val)

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      const value = getter ? getter.call(obj) : val
      console.log('getter:', value)

      /***********************依赖收集*************************/
      if (Dep.target) {
        dep.depend()
      }
      /***********************依赖收集*************************/

      return value
    },
    // ...
  })
}

这段代码我们需要关注的是在 get 函数中通过 dep.depend 做依赖收集,还有就是访问该属性,最终肯定要返回该属性对应的值。

Dep

Dep 是整个 getter 依赖收集的核心,它是建立这个数据与 watcher 之间的桥梁:

import { remove, isObject } from './util'

let depId = 0

/**
 * 一个dep是一个可观察的对象,可以有多个订阅它的指令。
 * 建立数据与watcher之间的桥梁
 */
export class Dep {

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

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

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

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

  notify() {
    const subs = this.subs.slice()

    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }

}

/**
 * 当前正在评估的目标观察者。
 * 这是全局唯一的,因为一次只有一个观察者可以评估
 */
Dep.target = null
const targetStack = []

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

Dep 是一个 Class,它定义了一些属性和方法,这里需要特别注意的是它有一个静态属性 Dep.target,这是一个全局唯一 Watcher,这是一个非常巧妙的设计,因为在同一时间只能有一个全局的 Watcher 被计算,另外它的自身属性 subs 也是 Watcher 的数组。

Dep 实际上就是对 Watcher 的一种管理,Dep 脱离 Watcher 单独存在是没有意义的,为了完整地讲清楚依赖收集过程,我们有必要看一下 Watcher 的一些相关实现:

Watcher

let watcherId = 0

export class Watcher {

  constructor(vm, expOrFn, cb, options, isRenderWatcher) {
    console.log('================ new Watcher ================')

    this.id = ++watcherId
    this.active = true

    this.vm = vm
    this.getter = expOrFn
    this.cb = cb
    this.expression = expOrFn.toString()

    if (isRenderWatcher) {
      vm._watcher = this
    }

    vm._watchers.push(this)


    this.deps = []
    this.newDeps = []
    this.depIds = new Set()
    this.newDepIds = new Set()

    this.value = this.get()
  }

  /**
   * 评估getter,然后重新收集依赖关系。
   */
  get() {
    pushTarget(this)

    let value
    const vm = this.vm

    try {
      value = this.getter.call(vm, vm)
    } catch (e) {
      throw e
    } finally {
      popTarget()
      this.cleanupDeps()
    }

    return value
  }

  /**
   * 向此指令添加依赖项。
   * @param dep 
   */
  addDep(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
  }

  /**
   * Subscriber 接口
   * 依赖项更改时将调用
   */
  update() {
    queueWatcher(this)
  }

  /**
   * 调度作业接口。 
   * 由调度程序调用。
   */
  run() {
    if (this.active) {
      const value = this.get()

      // ... TODO
    }
  }
}

Watcher 是一个 Class,在它的构造函数中,定义了一些和 Dep 相关的属性:

this.deps = []
this.newDeps = []
this.depIds = new Set()
this.newDepIds = new Set()

其中,this.depsthis.newDeps 表示 Watcher 实例持有的 Dep 实例的数组;而 this.depIdsthis.newDepIds 分别代表 this.depsthis.newDepsid Set(这个 Set 是 ES6 的数据结构)。那么这里为何需要有 2 个 Dep 实例数组呢,稍后我们会解释。

Watcher 还定义了一些原型的方法,和依赖收集相关的有 getaddDepcleanupDeps 方法,单个介绍它们的实现不方便理解,我会结合整个依赖收集的过程把这几个方法讲清楚。

过程分析

之前我们介绍当对数据对象的访问会触发他们的 getter 方法,那么这些对象什么时候被访问呢?那就是 Vue 的 $mount 方法中,其中有一段比较重要的逻辑,大致如下:

let updateComponent = () => {
  this._update(this._render())
}

new Watcher(this, updateComponent, () => { }, {}, true)/* isRenderWatcher */

当我们去实例化一个渲染 watcher 的时候,首先进入 watcher 的构造函数逻辑,然后会执行它的 this.get() 方法,进入 get 函数,首先会执行:

pushTarget(this)
export function pushTarget(target) {
  targetStack.push(target)
  Dep.target = target
}

实际上就是把 Dep.target 赋值为当前的渲染 watcher 并压栈(为了恢复用)。接着又执行了:

value = this.getter.call(vm, vm)

this.getter 对应就是 updateComponent 函数,这实际上就是在执行:

this._update(this._render())

它会先执行 this._render() 方法,在这个过程中会对 this.message 数据的访问,这个时候就触发了数据对象的 getter

那么每个对象值的 getter 都持有一个 dep,在触发 getter 的时候会调用 dep.depend() 方法,也就会执行 Dep.target.addDep(this)

在这里插入图片描述

刚才我们提到这个时候 Dep.target 已经被赋值为渲染 watcher,那么就执行到 addDep 方法:

addDep(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)
    }
  }
}

这时候会做一些逻辑判断(保证同一数据不会被添加多次)后执行 dep.addSub(this),那么就会执行 this.subs.push(sub),也就是说把当前的 watcher 订阅到这个数据持有的 depsubs 中,这时候就将 watcher 收集到了,这个目的是为后续数据变化时候能通知到哪些 subs 做准备。

至此,dep.depend() 执行结束,message属性的依赖收集完毕,同时访问到 message 的值,于是完成初始渲染页面。

若有多个属性需要依赖收集,则会有多个 dep 对应一个 watcher, 这里是多对一的关系,后续讲到计算、侦听属性,就是多对多的关系。

所以在 vm._render() 过程中,会触发所有数据的 getter,这样实际上已经完成了一个依赖收集的过程。那么到这里就结束了么,其实并没有,在完成依赖收集后,还有几个逻辑要执行,首先是是要递归去访问 value,触发它所有子项的 getter,这个属于 deepWatch 的逻辑之后会详细讲。接下来执行:

popTarget()

popTarget

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

实际上就是把 Dep.target 恢复成上一个状态,因为当前 vm 的数据依赖收集已经完成,那么对应的渲染Dep.target 也需要改变。

为什么有这样一个入栈和出栈的过程呢?因为我们考虑到有嵌套组件的情况,在执行父组件的 $mount 以后,又会执行子组件的 $mount;那父组件的 $mount 执行时,会把父组件的渲染 watcher push到 targetStack 中,依次就是子组件的渲染 watcher;当子组件渲染完成,就会 popTarget,此时 Dep.target 又恢复到父组件的渲染 watcher,所以巧妙的利用了栈的数据结构来保留当前计算的 target

最后执行:

this.cleanupDeps()

其实很多人都分析过并了解到 Vue 有依赖收集的过程,但我几乎没有看到有人分析 清除依赖 的过程,其实这是大部分同学会忽视的一点,也是 Vue 考虑特别细的一点。

/**
 * 清理依赖项集合。
 */
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
}

考虑到 Vue 是数据驱动的,所以每次数据变化都会重新 render,那么 this._render() 方法又会再次执行,并再次触发数据的 getters,即每次渲染都会进行 addDep 操作,所以 Wathcer 在构造函数中会初始化 2 个 Dep 实例数组;

  • newDeps 表示新一轮添加的 Dep 实例数组,
  • deps 表示上一次添加的 Dep 实例数组。

在执行 cleanupDeps 函数的时候,会首先遍历 deps,若其中没有新增依赖收集中的 dep, 则移除对 dep.subs 数组中 Wathcer 的订阅,然后把 newDepIdsdepIds 交换,newDepsdeps 交换,并把 newDepIdsnewDeps 清空。

在添加 deps 的订阅过程,已经能通过 id 去重避免重复订阅了,那么为什么需要做 deps 订阅的移除呢?这其实就是一种性能优化的手段。

考虑到一种场景,我们的模板会根据 v-if 去渲染不同子模板 a 和 b,当我们满足某种条件的时候渲染 a 的时候,会访问到 a 中的数据,这时候我们对 a 使用的数据添加了 getter,做了依赖收集,那么当我们去修改 a 的数据的时候,理应通知到这些订阅者。那么如果我们一旦改变了条件渲染了 b 模板,又会对 b 使用的数据添加了 getter,如果我们没有依赖移除的过程,那么这时候我去修改 a 模板的数据,会通知 a 数据的订阅的回调,这显然是有浪费的。

因此 Vue 设计了在每次添加完新的订阅,会移除掉旧的订阅,这样就保证了在我们刚才的场景中,如果渲染 b 模板的时候去修改 a 模板的数据,a 数据订阅回调已经被移除了,所以不会有任何浪费,真的是非常赞叹 Vue 对一些细节上的处理。

来看个例子:

const vm = new Vue({
  el: '#app',
  data: {
    display: true,
    message: 'vue-1'
  },
  render() {
    let message = ''
    let display = this.display

    if (display) {
      message = this.message
      console.log('(display为true,则访问message ======>', message + ')')
    }
    console.log('(页面渲染为:', display + ' ' + message + ')')
    return display + ' ' + message 
  }
})

setTimeout(() => {
  vm.display = false
}, 1000)

setTimeout(() => {
  vm.message = 'vue-2'
}, 2000)

setTimeout(() => {
  vm.display = true
}, 3000)
  • 这里初始化时, display 属性为 truedisplaymessage 属性我们都会进行访问,即两者都会被依赖收集,页面渲染结果为 true vue-1

在这里插入图片描述

  • 1000ms 过后,将 display 属性修改为 false,这时只会访问 display 属性,message 属性不会被依赖收集,页面渲染结果为 false

在这里插入图片描述

  • 2000ms 过后,将 message 属性修改为 vue-2,因为上次更新并没有对 message 进行依赖收集,因此渲染watcher 不会执行 update 操作,也就不会触发 vm 的更新渲染;

在这里插入图片描述

  • 3000ms 过后,将 display 属性修改为 true,这时又会对两者进行依赖收集,页面渲染结果为 true vue-2

在这里插入图片描述

总结

  • 依赖收集就是订阅数据变化的 watcher 的收集

  • 依赖收集的目的是为了当这些响应式数据发生变化,触发它们的 setter 的时候,能知道应该通知哪些订阅者去做相应的逻辑处理,我们把这个过程叫派发更新,下一节我们来详细分析一下派发更新的过程。

通过这一节的分析,我们对 Vue 数据的依赖收集过程已经有了认识,并且对这其中的一些细节做了分析,其实 WatcherDep 就是一个非常经典的观察者设计模式的实现。

四、派发更新

目标

  • 了解什么是派发更新
  • 了解派发更新的流程以及其中做的一些优化

通过上一节分析我们了解了响应式数据依赖收集过程,收集的目的就是为了当我们修改数据的时候,可以对相关的依赖派发更新,那么这一节我们来详细分析这个过程。

我们先来回顾一下 setter 部分的逻辑:

export function defineReactive(obj, key, val, customSetter, shallow) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  const getter = property && property.get
  const setter = property && property.set

  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  // 递归观测,当然前提val不是基础类型,observe中已作判断
  let childOb = !shallow && observe(val)

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // ...
    set: function reactiveSetter(newVal) {
      const value = getter ? getter.call(obj) : val
      console.log('setter:', newVal)

      // 新旧的值没有发生改变
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }

      if (customSetter) {
        customSetter()
      }

      // 用于没有setter的访问器属性
      if (getter && !setter) return

      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }

      // 递归观测,当然前提val不是基础类型,observe中已作判断
      childOb = !shallow && observe(newVal)

      /***********************派发更新*************************/
      dep.notify()
      /***********************派发更新*************************/
    }
  })
}

setter 的逻辑有 2 个关键的点,一个是 childOb = !shallow && observe(newVal),如果 shallow 为 false 的情况,会对新设置的值变成一个响应式对象;另一个是 dep.notify(),通知所有的订阅者,这是本节的关键,接下来我会带大家完整的分析整个派发更新的过程。

过程分析

当我们在组件中对响应的数据做了修改,就会触发 setter 的逻辑,最后调用 dep.notify() 方法,它是 Dep 的一个实例方法:

class Dep {
  // ...
  notify () {
    const subs = this.subs.slice()

    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

这里的逻辑非常简单,遍历所有的 subs,也就是 Watcher 的实例数组,然后调用每一个 watcherupdate 方法:

class Watcher {
  // ...
  update () {
    queueWatcher(this)
  }
}  

这里对于 Watcher 的不同状态,会执行不同的逻辑,computedsync 等状态的分析我会之后抽一小节详细介绍,在一般组件数据更新的场景,会走到最后一个 queueWatcher(this) 的逻辑,queueWatcher 的实现:

export const MAX_UPDATE_COUNT = 100

const queue = []
let has = {}
let circular = {}
let waiting = false
let flushing = false
let index = 0

/**
 * 将观察者推送到观察者队列中。 
 * 具有重复ID的作业将被跳过,除非在刷新队列时,被推送到队列中
 * @param watcher 
 */
export function queueWatcher(watcher) {
  const id = watcher.id

  if (has[id] == null) {
    has[id] = true

    if (!flushing) {
      queue.push(watcher)
    } else {
      /**
       * 在flush的过程中,又执行queueWatcher时
       * 需要将当前的watcher插入到按id排序的合适位置,
       * 如果watcher已经在queue队列中,那么它将立即被运行
       */
      let i = queue.length - 1

      while (i < index && queue[i].id > watcher.id) {
        i--
      }

      queue.splice(i + 1, 0, watcher)
    }

    /**
     * queue the flush
     * 保证只执行一次
     */
    if (!waiting) {
      waiting = true

      /**
       * TODO
       * nextTick(flushSchedulerQueue)
       */
      setTimeout(flushSchedulerQueue, 0)
    }

  }
}

这里引入了一个队列的概念,这也是 Vue 在做派发更新的时候的一个优化的点,它并不会每次数据改变都触发 watcher 的回调,而是把这些 watcher 先添加到一个队列里,然后在 nextTick 后执行 flushSchedulerQueue

这里有几个细节要注意一下,首先用 has 对象保证同一个 Watcher 只添加一次;接着对 flushing 的判断,else 部分的逻辑稍后我会讲;最后通过 waiting 保证对 nextTick(flushSchedulerQueue) 的调用逻辑只有一次,另外 nextTick 的实现我之后会抽一小节专门去讲,目前就可以理解它是在下一个 tick,也就是异步的去执行 flushSchedulerQueue,这里我们使用 setTimeout 代替 nextTick 的逻辑。

在这里插入图片描述

接下来我们来看 flushSchedulerQueue 的实现:

/**
 * 刷新队列并运行watchers。
 */
function flushSchedulerQueue() {
  flushing = true

  let watcher, id

  /**
   * 先排序,然后再flush
   * 这样可以确保:
   * 1. 组件从父到子更新。 (因为父级总是在子级之前创建的)
   * 2. 组件用户自定义的watchers在渲染watcher之前运行
   *  (因为用户自定义的watchers是在渲染watcher之前创建的)
   * 3. 如果在父组件的watcher运行期间销毁了一个组件,则可以跳过该watcher
   */
  queue.sort((a, b) => a.id - b.id)

  /**
   * 不缓存长度,因为在我们运行现有watchers时可能会推送更多的watchers
   */
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]

    id = watcher.id
    has[id] = null
    watcher.run()

    // 在开发版本中,检查并停止循环更新。
    if (has[id] != null) {
      circular[id] = (circular[id] || 0) + 1

      if (circular[id] > MAX_UPDATE_COUNT) {
        console.warn(
          'You may have an infinite update loop' + (
            watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`
          ),
          watcher.vm
        )
        break
      }
    }
  }

  resetSchedulerState()
}
  • 队列排序

queue.sort((a, b) => a.id - b.id) 对队列做了从小到大的排序,这么做主要有以下要确保以下几点:

1.组件的更新由父到子;因为父组件的创建过程是先于子的,所以 watcher 的创建也是先父后子,执行顺序也应该保持先父后子。

2.用户的自定义 watcher 要优先于渲染 watcher 执行;因为用户自定义 watcher 是在渲染 watcher 之前创建的。

3.如果一个组件在父组件的 watcher 执行期间被销毁,那么它对应的 watcher 执行都可以被跳过,所以父组件的 watcher 应该先执行。

  • 队列遍历

在对 queue 排序后,接着就是要对它做遍历,拿到对应的 watcher,执行 watcher.run()。这里需要注意一个细节,在遍历的时候每次都会对 queue.length 求值,因为在 watcher.run() 的时候,很可能用户会再次添加新的 watcher,这样会再次执行到 queueWatcher,如下:

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id

  if (has[id] == null) {
    has[id] = true

    if (!flushing) {
      queue.push(watcher)
    } else {
      /**
       * 在flush的过程中,又执行queueWatcher时
       * 需要将当前的watcher插入到按id排序的合适位置,
       * 如果watcher已经在queue队列中,那么它将立即被运行
       */
      let i = queue.length - 1

      while (i > index && queue[i].id > watcher.id) {
        i--
      }

      queue.splice(i + 1, 0, watcher)
    }

    // ...
  }
}

可以看到,这时候 flushing 为 true,就会执行到 else 的逻辑,然后就会从后往前找,找到第一个待插入 watcher 的 id 比当前队列中 watcher 的 id 大的位置。把 watcher 按照 id的插入到队列中,因此 queue 的长度发生了变化。

  • 状态恢复

这个过程就是执行 resetSchedulerState 函数:

/**
 * 重置调度程序的状态。
 */
function resetSchedulerState() {
  index = queue.length = 0
  has = {}

  circular = {}

  waiting = flushing = false
}

逻辑非常简单,就是把这些控制流程状态的一些变量恢复到初始值,把 watcher 队列清空。

接下来我们继续分析 watcher.run() 的逻辑:

class Watcher {

  /**
   * 调度作业接口。 
   * 由调度程序调用。
   */
  run() {
    if (this.active) {
      const value = this.get()

      // ... TODO
    }
  }
}

那么对于渲染 watcher 而言,它在执行 this.get() 方法求值的时候,会执行 getter 方法:

updateComponent = () => {
  this._update(this._render())
}

所以这就是当我们去修改组件相关的响应式数据的时候,会触发组件重新渲染的原因,接着就会重新执行 patch 的过程,但它和首次渲染有所不同,之后我们会花一小节去详细介绍。

总结

通过这一节的分析,我们对 Vue 数据修改派发更新的过程也有了认识,实际上就是当数据发生变化的时候,触发 setter 逻辑,把在依赖过程中订阅的的所有观察者,也就是 watcher,都触发它们的 update 过程,这个过程又利用了队列做了进一步优化,就是把所有要执行 updatewatcher 推入到队列中,在 nextTick 后执行 flush 操作,遍历所有的 watcher 执行 run,最后执行它们的回调函数。nextTick 是 Vue 一个比较核心的实现了,我们后面有空会来重点分析它的实现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值