watcher 更新如何与 nextTick 协作

Liu Bowen

Liu Bowen / 2020, 五月, 10

本文对应 Vue.js v2.6.11.

本文仅覆盖 Vue.js v2watcher 更新以及 nextTick 部分。本文的目标是阐述在最新版本 Vue.js v2.6.11 中所有的 watcher 实例在触发 update 函数后,是如何借助 nextTick 特性实现:

  1. 当次事件循环中的多次修改仅有最后一次生效。

  2. 总是等到当前 tick 事件循环 task 完成后,才会真正执行 watcher.update 函数。

为什么是 watcher

vue.js 内部实现中,watcher 不仅仅是 vm.$watch API 如此。watcher 本质上扮演了一个订阅数据更新 topic 的订阅者 subscriber所有的数据更新后的回调触发逻辑都依赖于 watcher 实例。不同类型的 watcher 实例起到不同的回调效果。

  1. 每个视图组件都依赖于一个唯一与之对应的 renderWatcher 实例,该实例始终接受一个用于视图更新的 expOrFn 函数作为 renderWatcherrenderWatcher.getter 函数。该函数在 renderWatcher 收到更新后,进行函数调用执行。当 renderWatcher.getter 触发时,即调用 vnode 的创建函数 vm._renderDOMDiff 更新函数 vm._patch

    js
    new Watcher(vm, updateComponent)
    
  2. 每一个 computed[computed] 对应一个 lazyWatcher 实例,该实例始终接受一个用户传入的 lazyWatcher.get 函数来在 取值当前 computed[computed] 才进行惰性计算,并在依赖没有变化时,始终始终缓存的 lazyWatcher.value 值。因为 lazyWatcher 的计算过程是 同步的立即计算,即不依赖于 nextTick,那么本文将忽略此类型 watcher 实例。

  3. 当开发者调用 vm.$watch 或传入 vm.$options.watch[watchKey] 时,本质上是创建一个 userWatcher

    js
    // src/core/instance/state.js#L355-L356
    options.user = true // 定义为 userWatcher
    new Watcher(vm, expOrFn, cb, options)
    

    额外的,每个用户传入的 userWatcher 回调,都将作为 userWatcher.cb 注册。在每次 userWatcher.get 调用时,触发 cb 回调函数。

二者协作解决了什么问题

  1. 避免 无用计算,因为在单次事件循环 tick 中,至多仅有一次 UI 更新机会,那么若在单次事件循环存在的多次 renderWatcher 更新视图操作时,就不能立即执行更新视图操作,而应该借助去重操作和 nextTick 调度延迟执行视图更新操作,实现最终仅有最后一次视图更新生效。

  2. 减少 不必要的因多余计算而造成的 强制同步布局avoid布局抖动

避免无用计算

首先,基于现行 html living standard 标准对浏览器事件循环章节 event loop processing model 对一次事件循环 tick 的定义:在每次执行完当前 task 并清空其附属 micro-task queue 后会因为性能 至多执行一次 UI 绘制(是否绘制取决于硬件刷新率)。

11: update the rendering

  1. Rendering opportunities: Remove from docs all Document objects whose browsing context do not have a rendering opportunity.

    A browsing context has a rendering opportunity if the user agent ivs currently able to present the contents of the browsing context to the user, accounting for hardware refresh rate constraints and user agent throttling for performance reasons, but considering content presentable even if it's outside the viewport.

    Browsing context rendering opportunities are determined based on hardware constraints such as display refresh rates and other factors such as page performance or whether the page is in the background. Rendering opportunities typically occur at regular intervals.

即始终有一次事件循环 tick 对应 至多一次 UI 绘制

  1. 对于 renderWatcher 来说,在一次事件循环 tick 中,多次的 renderWatcher.get 触发,对应多次 UI 视图更新。显然这在一次事件循环 tick 中是多余的。对于当次 UI 绘制来说,始终仅有最后一次生效。那么为了避免多次无用的回调调用,就一定要在一次事件循环 tick 中保证 至多执行一次 renderWatcher.get 函数,进而始终 至多执行一次 组件视图更新。

  2. 对于 userWatcher 来说,在一次事件循环 tick 中,可能存在多次依赖更新,那么也会存在如同 renderWatcher 一样的局面,为了避免多次无用的调用,故应该在一次事件循环 tick 中始终至多执行一次组件视图更新。

基于以上目标,Vue.js 内部借助 nextTick 机制实现在当次事件循环 ticktask 执行过程中,收集依赖的变化,但不立即执行回调函数,而是让 nextTick 延迟回调函数到一个特定的时机来触发回调。那么这就有了处理将多次高频调用处理为仅保留最终调用的机会。

减少强制同步布局和布局抖动

js run --> calculate style --> layout --> paint --> composite

在常规情况下,所有的 JS 样式修改都遵循以上渲染管道。仅当 JS 执行完成时,才会开始进行样式计算。但在 JS 中若提前获取当前事件循环 tick 的设置过样式的 DOM 节点的样式时(即使不是获取修改的样式),浏览器为了返回一个修改后的样式集合,不得不提前开始样式计算,并返回对应 DOM 更新后的样式值。在此期间,浏览器执行了 强制同步布局,多次的强制同步布局操作会造成当前页面的 布局抖动

为什么布局在这里显得尤为重要?

Layout is almost always scoped to the entire document.

因为布局总是作用于整个文档

所以减少因多次更新意外造成的布局抖动显得尤为重要。在没有对单次事件循环 tick 进行更新合并时,当开发者代码多次更新状态导致其中一次视图更新意外触发强制同步布局时,那么多次相同更新会触发布局抖动,这种对渲染的影响会因为在单次事件循环 tick 中的多次更新而被无限放大。进而大幅降低当前事件循环 tick 渲染性能。若存在更新合并时,即使当前更新存在开发者代码造成的强制同步布局,也会因为仅有最后一次更新生效,而始终在一次事件循环 tick 中仅有一次开发者触发的强制同步布局,进而 尽可能 地维护了当前渲染管道流程,大幅提升渲染性能。

那么为了避免以上的问题,Vue.js 实现了一套基于事件循环模型的 nextTick 更新机制,所有的数据更新都 不是立即 应用到视图上的,那么结合前文视图由 renderWatcher 驱动的观点,后文阐述的部分核心点如下:

  1. 如何收集待执行的 watcher 回调并进行去重;

  2. 在何等时机触发当前事件循环 tick 中,收集的所有待执行 watcher 回调函数。

此处,watcher 回调是泛指如 renderWatcher 对应的视图更新函数,lazyWatcher 对应的 computed 计算函数等由发布者触发的回调函数,而不仅仅是开发者定义的 watcher 回调函数。

概述 nextTick

什么是 nextTick,其内部核心原理又是什么?这里以一次 vm.$nextTick 调用来简要阐述 nextTick 的核心原理。

API 挂载:在初始化 Vue global API 时,会在 renderMixin 中在 Vue 原型对象上经历以下 $nextTick 挂载:

js
Vue.prototype.$nextTick = function (fn: Function) {
  return nextTick(fn, this)
}

那么以下调用:

js
vm.$nextTick(() => {
  // do something you like
})

本质上是以下调用:

js
nextTick(() => {
  // do something you like
}, this) // 此处 this 恒定指向 vue 实例

最终得到原始的 nextTick 函数实现:

js
export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

不难看出 nextTick 本质上是在缓存传入的 cb 函数。其缓存容器是 es module 模块词法作用域内的 callbacks 变量。上文函数的职责如下:

  1. 缓存传参 cb 函数;

  2. pendingfalsy 值时,调用模块词法作用域中的 timerFunc 函数;

  3. 在没有传入 cb 函数,且词法作用域支持 Promise 构造函数时,nextTick 将返回一个 Promise 实例,而不是 undefined

那么我们要探究的 nextTick 的本质,可抽象为模块变量 callbackstimerFunc 函数的功能组合体。在 src/core/util/next-tick.js 中,我们不难得到 timerFunc 是根据 JS 运行时进行实现。

以下按照运行时优先级进行排序:

  1. 在支持 Promise 时,timerFunc 对应:

    js
    timerFunc = () => {
      p.then(flushCallbacks)
      if (isIOS) setTimeout(noop)
    }
    isUsingMicroTask = true
    
  2. 在非 IE 且支持 MutationObserver 时:

    js
    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 时:

    js
    timerFunc = () => setImmediate(flushCallbacks)
    
  4. 最后,使用以下实现进行兜底:

    js
    timerFunc = () => {
      setTimeout(flushCallbacks, 0)
    }
    

不难看出所有的实现有一个共同点是都包含了 flushCallbacks 函数,如下:

js
function flushCallbacks() {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

上文函数的主要职责在于取 callbacks 容器浅副本,逐个迭代执行 callbacks 中所有函数。以上所有的 timerFunc 实现核心差异点是使用不同的 task source 来实现调用 flushCallbacks 函数,即最终 nextTick 本质上是一个 回调函数调度器,优先借助 micro-task,否则使用 task 来实现清空 callbacks 列表。

那么结合 nextTick 的整体,不难得到以下结论:

  1. 本质上是依赖事件循环的 processing model 实现回调函数延迟调用;

  2. 优先使用 micro-task,否则回退至 task 来实现。

FAQ

  • Q: 为什么 callbacksArray 类型,而不是 Function 类型?

  • A: 在当次事件循环 tick 中,可能存在多次 nextTick 调用,例如:

    js
    // SomeComponent.vue
    export default {
      created() {
        this.$nextTick(doSomething)
        this.$nextTick(doOneMoreTime)
      }
    }
    

    那么使用 Array 类型来缓存当前事件循环中 tick 多个 传入 nextTick 函数的回调函数。

视图与 renderWatcher

前文,笔者已经阐述了 nextTick 函数的核心原理和功能,目的是为了 批量 延迟函数调用。那么 watcher 的更新又是如何与 nextTick 关联的呢?将 watcher 的更新借助 nextTick 的延迟调用能力,那么我们就可以延迟 watcher 的更新,即有机会 在延迟期间 实现合并多次更新。下文以与视图唯一对应的 renderWatcher 为例。

Vue.js 中,每个视图组件都关联一个与组件自身唯一对应的 renderWatcher 实例。

js
// src/core/instance/lifecycle.js#L141-L213
function mountComponent(/* ... */) {
  // ...
  if (/*  */) {
    // ...
    // 此处为开发环境的逻辑简化
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  } else {
    // 此处为生产环境逻辑
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }
  // we set this to vm._watcher inside the watcher's constructor
  // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  // component's mounted hook), which relies on vm._watcher being already defined
  new Watcher(
    vm,
    updateComponent,
    noop,
    {
      before() {
        if (vm._isMounted && !vm._isDestroyed) {
          callHook(vm, 'beforeUpdate')
        }
      }
    },
    true /* isRenderWatcher */
  )
}

new Watcher 实例化一个 renderWatcher 时,在 Watcher 构造函数内部会使得当前 renderWatcher 成为当前 vm 实列的 vm._watcher 属性。否则 vm._watchernullref

不难看出 updateComponent 就是 renderWatcher.getter 对应的函数,那么在 renderWatcher.update 调用时,本质上是调用的 renderWatcher.getter,进而调用 updateComponent 实现视图更新。updateComponent 中对应功能函数如下:

  1. vm._render 如下:

    js
    // src/core/instance/render.js#L69-L128
    Vue.prototype._render = function (): VNode {
      // ...
      const { render, _parentVnode } = vm.$options
      // ...
      try {
        currentRenderingInstance = vm
        vnode = render.call(vm._renderProxy, vm.$createElement)
      } catch (e) {
        // ...
      } finally {
        currentRenderingInstance = null
      }
    
      // ...
      return vnode
    }
    

    不难得出,vm._render 的核心职责在于调用 $options.render 渲染函数之际,指定调用上下文为 vm._renderProxy,并传参 vm.$createElement 函数,并最终 vm._render 产出 vnode

  2. vm._update 如下:

    js
    // src/core/instance/lifecycle.js#L59-L88
    Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
      //...
      if (!preVnode) {
        vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
      } else {
        vm.$el = vm.__patch__(prevVnode.vnode)
      }
      // ...
    }
    

    vm._update 函数的核心在于以上代码,其中 vm.__patch__ 函数的作用如同 snabbdom 库的 patch 函数。作用是根据 vnode 最小幅度的创建或修改真实的 DOM

    那么我们在某种程度上可认为 vm._update 函数的核心功能就是将 vm._render 调用产生的 vnode 通过 diff 最小幅度的创建或修改真实的 DOM nodes

由以上对 updateComponent 函数的内涵的探讨,所以这是笔者说 renderWatcher.getter 在调用之际是在进行视图更新的原因。

那么,根据前文阐述,我们将 renderWatcher 的核心职责归纳如下:

  1. 在实例化 Watcher 时,传入最后一个参数 true,使得当前 watcher 实例为 renderWatcher。使得 vm._watcher 值为 renderWatcher,而不是 null

  2. renderWatcher 实例的 epxOrFn 参数定义为 updateComponent 函数,并成为 renderWatcher.getter 属性。updateComponent 函数对应了视图更新函数。那么当 renderWatcher.getter 被调用时,即是进行 diff 比对,最终实现 最小幅度范围 的视图更新。

视图如何触发 watcher 更新

众所周知,所有的 vue template 都会被 vue-template-compiler 转换为 render 函数。在 render 函数中,所有的模板插值都对应了 vm 上对应的字段。

如下 vue template:

html
<div id="app">{{ msg }}</div>

将被编译为vue template explorer

js
function render() {
  with (this) {
    return _c(
      'div',
      {
        attrs: {
          id: 'app'
        }
      },
      [_v(_s(msg))]
    )
  }
}

上文中 with 语句起到的作用是在其块级作用域中拓展了作用域,使得 msg 的取值为 this.msg,那么以上渲染函数等价于:

js
function render() {
  return _c(
    'div',
    {
      attrs: {
        id: 'app'
      }
    },
    [_v(_s(this.msg))]
  )
}

根据之前文章对 data 依赖收集及其触发原理的分析。我们不难得到,在 watcher 所订阅的依赖更新时,将通过 data[dataKey].__ob__.dep.notify 调用 dep.subs[i].update 方法来实现通知订阅者。

js
// src/core/observer#L37-L49
export default class Dep {
  // ...
  subs: Array<Watcher>
  // ...
  notify() {
    // ...
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

根据类型定义,所有的 dep.subs[i] 均为 watcher 实例,那么 subs[i].update 调用,实际上是 watcher.update 调用:

js
// src/core/observer/watcher.js#L160-L173
export default class Watcher {
  /**
   * Subscriber interface.
   * Will be called when a dependency changes.
   */
  update() {
    /* istanbul ignore else */
    if (this.lazy) {
      // 此 if 分支对应 lazyWatcher
      this.dirty = true
    } else if (this.sync) {
      // 此分支对应 vuex 的 watcher
      this.run()
    } else {
      // 此分支对应 renderWatcher 或 userWatcher
      queueWatcher(this)
    }
  }
}

纵观整个 vue.js 源码,this.lazy 仅在定义 computed 的键值时,才会 truethis.sync 仅对 vuexstrict mode 下生效见 vuex v3.3.0 源码,而剩下的 if 分支是着重需要讨论的 watchernextTick 的协作分支。

如何避免重复更新

queueWatcher 如下,从函数语义来看,该函数就是为了队列化需要更新的 renderWatcheruserWatcher

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.
 */
export function queueWatcher(watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // 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) {
        flushSchedulerQueue()
        return
      }
      nextTick(flushSchedulerQueue)
    }
  }
}

queueWatcher 借助一个 key-value 数据结构和唯一的 watcher.id 做了一件非常重要的多次 watcher 更新 合并/去重操作。仅在 has[id]falsy 值时,才会加入到 queue 中。

在初始时,waiting 标识为 false,那么进入到以下 if 语句中:

js
if (!waiting) {
  waiting = true
  // ...
  nextTick(flushSchedulerQueue)
}

通过 nextTick 函数调度了 flushSchedulerQueue 函数的执行。

flushSchedulerQueue

函数如下:

js
/**
 * Flush both queues and run the watchers.
 */
function flushSchedulerQueue() {
  // ...
  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child)
  // 2. A component's user watchers are run before its render watcher (because
  //    user watchers are created before the render watcher)
  // 3. If a component is destroyed during a parent component's watcher run,
  //    its watchers can be skipped.
  queue.sort((a, b) => a.id - b.id)

  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
    // in dev build, check and stop circular updates.
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        // ...
        break
      }
    }
  }

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

  resetSchedulerState()

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

其职责在于:

  1. 根据 watcher 创建的先后顺序排列 watcher,根据以下:

    js
    queue.sort((a, b) => a.id - b.id)
    

    因为 Array.prototype.sort 属于原地排序,当回调函数返回值大于 0 时,会在原数组中原地交换 ab 顺序,故以上排序结果为小序在前的升序。又因为 watcher 实例化是 自增 ID。所以前文升序排列 watcher 表明父组件 renderWatcher 始终先于子组件 renderWatcher

    js
    this.id = ++uid // uid for batching
    
  2. 迭代迭代调用 queue 容器中的 watcher.run 方法,进而实现调用其 watcher.get 函数,进而实现调用 watcher.getter 函数(对应实例化 Watcher 时的 expOrFn 参数):

    1. 在调用 watcher.run 时可能会触发其他 watcher,故迭代时,不会固定容器长度;

    2. renderWatcher 来说,watcher.getter 本质上调用的是的 updateComponent 函数,其本质对应了视图更新函数—— vm._render vnode 创建函数和 vm._patch DOM 更新函数。

    3. 对于 userWatcher 来说,watcher.getter 对应了 $options.watcher[watcherKey] 的取值函数:

      js
      export default class Watcher {
        // ...
        constructor(/* ... */) {
          //...
          // parse expression for getter
          if (typeof expOrFn === 'function') {
            this.getter = expOrFn
          } else {
            // 此分支对应了 $options.watcher[watcherKey as string]
            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
                )
            }
          }
        }
      }
      

      userWatcher.getter 对应调用获得的返回值是 vm[watcherKey] 的值。

      另外在调用 userWatcher.run 时,因为 watcher.usertrue,那么会额外调用 userWatcher.cb 函数。并将 watcher.getter 的返回值和 watcher.value 作为新旧值传入 userWatcher.cb 函数。

      js
      export default class Watcher {
        // ...
        /**
         * Scheduler job interface.
         * Will be called by the scheduler.
         */
        run() {
          if (this.active) {
            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
            ) {
              // set new value
              const oldValue = this.value
              this.value = value
              if (this.user) {
                try {
                  // !! 调用用户定义的  $options.watch[watchKey] 回调函数
                  this.cb.call(this.vm, value, oldValue)
                } catch (e) {
                  // ...
                }
              } else {
                this.cb.call(this.vm, value, oldValue)
              }
            }
          }
        }
      }
      
  3. 重置 watcher 队列标识,表示当前队列已经由 nextTick 调度得到调用。

    js
    resetSchedulerState()
    
  4. 对于 <keep-alive> 的缓存组件,激活 activated 钩子。

    js
    // call component activated hooks
    callActivatedHooks(activatedQueue)
    
  5. 对当前队列 queue 中的 renderWatcher 对应的 vm 实例,调用 updated 钩子。

    js
    // call component updated hooks
    callUpdatedHooks(updatedQueue)
    

至此,上文已经解释了 flushSchedulerQueue 背后的本质原理。

nextTick(flushSchedulerQueue)

结合前文对 nextTick 概述 和对 watcher 更新链路和 flushScheduleQueue 的分析,不难得出以下结论:

  1. 所有的 renderWatcheruserWatcher 更新调用由 queueWatcher 驱动,此时所有的 watcher 更新并不会在当前事件循环 ticktask 执行上下文之上得到执行。

  2. queueWatcher 解决的核心思路是 nextTick(flushSchedulerQueue) 函数调用。

  3. nextTick 给予了 flushSchedulerQueue 函数延迟调用的能力。nextTick 基于当前 JS 运行时以 micro-tasktask 的优先级进行实现。所有的 watcher 更新函数的 调用时机完全取决于 nextTick 的运行时实现

    1. nextTickmiro-task 实现时,所有的 watcher 更新函数在当前事件循环 tick 的清空 micro-task queue 阶段得到执行。

    2. nextTicktask 实现时,所有的 watcher 更新函数基于一个全新的 task (即作为 setTimeout 的回调函数的 flushSchedulerQueue)得到执行。

  4. flushScheduleQueue 函数是最终当前事件循环 tick 中收集的 watcher 更新的 真正执行者

References