Vue 异步更新机制

1,446 阅读6分钟

前言

因为目前的项目使用的是 Vue.js, 最近也在看 Vue.js 源码,基于此做了以下总结和输出。本章主要是关于 Vue 的异步更新机制以及源码实现的分析。

dep.notify

根据Vue的响应式原理,当触发某个数据的 setter 方法后,它的 setter 函数会通知闭包中的 Dep,Dep 则会调用它管理的所有 watcher 对象。触发 watcher 对象的 update 实现。

dep.notify

/src/core/observer/dep.js

  // 通知 dep 中所有的 watcher, 执行 watcher 中的 update 方法
  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)
    }
    // 遍历 dep 中存储的 watcher,执行 watcher.update()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }

watcher.update

/src/core/observer/watcher.js

 /**
   * Subscriber interface.
   * Will be called when a dependency changes.
   * 根据 watcher 配置项决定走哪个流程,一般是 queueWatcher
   */
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      // 懒执行时,走该逻辑,例如 compted
      // 将 dirty 设置为 true , 在组件更新之后,当响应式数据再次被更新时, 执行 computed getter
      // 重新执行 computed 回调函数, 计算新值, 然后缓存到 watcher.value
      this.dirty = true
    } else if (this.sync) {
      // 当同步执行时 直接执行 run 函数渲染视图
      this.run()
    } else {
      // 将 watcher 放入 watcher 队列
      queueWatcher(this)
    }
  }

queueWatcher

/src/core/observer/scheduler.js

/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 * 将 watcher 放入 watcher 队列
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // 如果 watcher 已经存在,则跳过,不会重复放入队列中
  if (has[id] == null) {
    // 缓存 watcher.id 用于判断 watcher 是否已经入队 
    has[id] = true
    if (!flushing) {
      // 当前没有刷新队列状态,则 watcher 直接放入队列中
      queue.push(watcher)
    } else {
      // 已经在刷新队列状态,则根据当前 watcher.id 找到大于它的 watcher.id 的位置,然后将自己插入到该位置之后的下一个位置
      // 即将当前的 watcher 放入队列中,并保持队列是有序的
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      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

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        // 如果是非生产环境 并且设置异步为 false 的情况下走同步执行
        flushSchedulerQueue()
        return
      }
      /**
       * 经常用到的 nextTick  即 this.$nextTick  Vue.nextTick
       * 1. 将回调函数 flushSchedulerQueue 放入 callbacks 数组中
       * 2. 通过 pending 控制向浏览器任务队列中添加 flushCallbacks 函数
       */
      nextTick(flushSchedulerQueue)
    }
  }
}

从 queueWatcher 代码中看出 watcher 对象并不是立即更新视图,而是被 push 进了一个队列 queue,此时状态处于 waiting 的状态,这时候会继续会有 watcher 对象被 push 进这个队列 queue,等到下一个 tick 运行时将这个队列 queue 全部拿出来 run 一遍,这些 watcher 对象才会被遍历取出,更新视图。同时,id重复的 watcher 不会被多次加入到 queue 中去。这也解释了同一个 watcher 被多次触发,只会被推入到队列中一次。 基于此,我们可以根据下图理解下整个流程:

image.png

Vue 为了避免频繁的操作 DOM,采用异步的方式更新 DOM。这些异步操作会通过 nextTick 函数将这些操作以 cb 的形式放到任务队列中(以微任务优先),当每次 tick 结束之后就会去执行这些 cb,更新 DOM。

nextTick

const callbacks = []
let pending = false

 /**
 * 1. 使用 try catch 包装 cb 函数,然后将其放入 callbacks 数组中
 * 2. 判断 pending 值,如果为false 表示当前浏览器任务队列中没有正在执行的 flushCallbacks 函数
 *    如果 pening 为 true 则表示浏览器任务队列中已经被放入了 flushCallbacks 函数
 *  pending 的作用:保证在同一时刻,浏览器的任务队列中只有一个 flushCallbacks 函数
 * @param {*} cb 接收一个回调函数
 * @param {*} ctx 回调函数执行的上下文环境
 * @returns 
 */
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 将传入的回调函数放入 callbacks 数组中
  callbacks.push(() => {
    if (cb) {
      // 用 try catch 包装回调函数,便于错误捕获
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    // 执行 timerFunc,在浏览器的任务队列中(首选微任务队列)放入 flushCallbacks 函数
    // 主要用于判断 Promise,MutationObserver,setImmediate、setTimeout 的优先级
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

timerFunc

/src/core/util/next-tick.js

// 可以看到 timerFunc 的作用很简单,就是将 flushCallbacks 函数放入浏览器的异步任务队列中
let timerFunc
// 判断1:是否原生支持Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  // 首选 Promise.resolve().then()
  timerFunc = () => {
    // 在 微任务队列 中放入 flushCallbacks 函数
    p.then(flushCallbacks)
    /**
     * 在有问题的UIWebViews中,Promise.then不会完全中断,但是它可能会陷入怪异的状态,
     * 在这种状态下,回调被推入微任务队列,但队列没有被刷新,直到浏览器需要执行其他工作,例如处理一个计时器。
     * 因此,我们可以通过添加空计时器来“强制”刷新微任务队列。
     */
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
  // 判断2: 是否原生支持MutationObserver
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // MutationObserver 次之
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
  // 判断3:是否原生支持setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // 再就是 setImmediate,它其实已经是一个宏任务了,但仍然比 setTimeout 要好
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
  // 判断4:上面都不行,直接用setTimeout
} else {
  // 最后没办法,则使用 setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}


flushCallbacks

/src/core/util/next-tick.js

const callbacks = []
// pending用来标识同一个时间只能执行一次
let pending = false
/**
 * 将 pending 设置为false
 * 清空 callbacks 数组
 * 执行 callbacks 数组中的每一个函数
 */
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

flushSchedulerQueue

/src/core/observer/scheduler.js

/**
 * Flush both queues and run the watchers.
 * 刷新队列,由 flushCallbacks 函数负责调用,主要做了如下两件事:
 *   1、更新 flushing 为 ture,表示正在刷新队列,在此期间往队列中 push 新的 watcher 时需要特殊处理(将其放在队列的合适位置)
 *   2、按照队列中的 watcher.id 从小到大排序,保证先创建的 watcher 先执行,也配合 第一步
 *   3、遍历 watcher 队列,依次执行 watcher.before、watcher.run,并清除缓存的 watcher
 */
function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  // 标志现在正在刷新队列
  flushing = true
  let watcher, id

  /**
   * 刷新队列之前先给队列排序(升序),可以保证:
   *   1、组件的更新顺序为从父级到子级,因为父组件总是在子组件之前被创建
   *   2、一个组件的用户 watcher 在其渲染 watcher 之前被执行,因为用户 watcher 先于 渲染 watcher 创建
   *   3、如果一个组件在其父组件的 watcher 执行期间被销毁,则它的 watcher 可以被跳过
   * 排序以后在刷新队列期间新进来的 watcher 也会按顺序放入队列的合适位置
   */
  queue.sort((a, b) => a.id - b.id)

  // 这里直接使用了 queue.length,动态计算队列的长度,没有缓存长度,是因为在执行现有 watcher 期间队列中可能会被 push 进新的 watcher
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    // 执行 before 钩子,在使用 vm.$watch 或者 watch 选项时可以通过配置项(options.before)传递
    if (watcher.before) {
      watcher.before()
    }
    // 将缓存的 watcher 清除
    id = watcher.id
    has[id] = null

    // 执行 watcher.run,最终触发更新函数,比如 updateComponent 或者 获取 this.xx(xx 为用户 watch 的第二个参数),当然第二个参数也有可能是一个函数,那就直接执行
    watcher.run()
  }

  // keep copies of post queues before resetting state
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()

  /**
   * 重置调度状态:
   *   1、重置 has 缓存对象,has = {}
   *   2、waiting = flushing = false,表示刷新队列结束
   *     waiting = flushing = false,表示可以像 callbacks 数组中放入新的 flushSchedulerQueue 函数,并且可以向浏览器的任务队列放入下一个 flushCallbacks 函数了
   */
  resetSchedulerState()

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}

/**
 * Reset the scheduler's state.
 */
function resetSchedulerState () {
  index = queue.length = activatedChildren.length = 0
  has = {}
  if (process.env.NODE_ENV !== 'production') {
    circular = {}
  }
  waiting = flushing = false
}


/**
 * 由 刷新队列函数 flushSchedulerQueue 调用,如果是同步 watch,则由 this.update 直接调用,完成如下几件事:
 *   1、执行实例化 watcher 传递的第二个参数,updateComponent 或者 获取 this.xx 的一个函数(parsePath 返回的函数)
 *   2、更新旧值为新值
 *   3、执行实例化 watcher 时传递的第三个参数,比如用户 watcher 的回调函数
 */
run () {
  if (this.active) {
    // 调用 this.get 方法
    const value = this.get()
    if (
      value !== this.value ||
      // Deep watchers and watchers on Object/Arrays should fire even
      // when the value is the same, because the value may
      // have mutated.
      isObject(value) ||
      this.deep
    ) {
      // 更新旧值为新值
      const oldValue = this.value
      this.value = value

      if (this.user) {
        // 如果是用户 watcher,则执行用户传递的第三个参数 —— 回调函数,参数为 val 和 oldVal
        try {
          this.cb.call(this.vm, value, oldValue)
        } catch (e) {
          handleError(e, this.vm, `callback for watcher "${this.expression}"`)
        }
      } else {
        // 渲染 watcher,this.cb = noop,一个空函数
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}

总结

Vue 的异步更新机制的核心是利用了浏览器的异步任务队列来实现的,首选微任务队列,宏任务队列次之。

  1. 遍历属性为其增加 get,set 方法,在 get 方法中会收集依赖(dev.subs.push(watcher)),而 set 方法则会调用 dep 的 notify 方法,此方法的作用是通知 dep 中收集的所有的 watcher 并调用 watcher 的 update 方法,我们可以将此理解为设计模式中的发布与订阅。

  2. 默认情况下 update 方法被调用后会触发 queueWatcher 函数,此函数的主要功能就是将 watcher 实例本身加入一个队列中(queue.push(watcher)),然后调用 nextTick(flushSchedulerQueue)。

  3. 然后通过 nextTick 方法将一个刷新 watcher 队列的方法(flushSchedulerQueue)放入一个全局的 callbacks 队列中,然后异步的将 callbacks 遍历并执行(此为异步更新队列)。如果此时浏览器的异步任务队列中没有一个叫 flushCallbacks 的函数,则执行 timerFunc 函数,将 flushCallbacks 函数放入异步任务队列。如果异步任务队列中已经存在 flushCallbacks 函数,等待其执行完成以后再放入下一个 flushCallbacks 函数。

  4. flushSchedulerQueue 是一个函数,目的是调用 queue 中所有 watcher 的 watcher.run 方法,从而进入更新阶段,run 方法被调用后接下来的操作就是通过新的虚拟 DOM 与老的虚拟 DOM 做 diff 算法后生成新的真实 DOM

  5. 如上所说 flushSchedulerQueue 在被执行后调用 watcher.run(),于是你看到了一个新的页面

参考

Vue 源码解析
通俗易懂的Vue异步更新机制