watcher 更新如何與 nextTick 協做

本文同步發表於 個人博客
本文對應 Vue.js v2.6.11.

做者正在看機會,若有合適內推機會,煩請聯繫html


本文僅覆蓋 Vue.js v2watcher 更新以及 nextTick 部分。本文的目標是闡述在最新版本 Vue.js v2.6.11 中全部的 watcher 實例在觸發 update 函數後,是如何藉助 nextTick 特性實現:vue

  1. 當次事件循環中的屢次修改僅有最後一次生效。
  2. 老是等到當前 tick 事件循環 task 完成後,纔會真正執行 watcher.update 函數。

<!-- 1. 概述 nextTick 原理,及其背後 event loop 驅動 傳入 nextTick 的回調函數的調用。node

  1. 概述鏈接 watcher 和 flushSchedulerQueue 函數
  2. 結合以上兩點綜合闡述 renderWatcher 和 userWatcher 藉助 nextTick 的觸發鏈路 -->

爲何是 watcher

vue.js 內部實現中,watcher 不只僅是 vm.$watch API 如此。watcher 本質上扮演了一個訂閱數據更新 topic 的訂閱者 subscriber全部的數據更新後的回調觸發邏輯都依賴於 watcher 實例。不一樣類型的 watcher 實例起到不一樣的回調效果。react

  1. 每一個視圖組件都依賴於一個惟一與之對應的 renderWatcher 實例,該實例始終接受一個用於視圖更新的 expOrFn 函數做爲 renderWatcherrenderWatcher.getter 函數。該函數在 renderWatcher 收到更新後,進行函數調用執行。當 renderWatcher.getter 觸發時,即調用 vnode 的建立函數 vm._renderDOMDiff 更新函數 vm._patchgit

    new Watcher(vm, updateComponent)
  2. 每個 computed[computed] 對應一個 lazyWatcher 實例,該實例始終接受一個用戶傳入的 lazyWatcher.get 函數來在 取值當前 computed[computed] 才進行惰性計算,並在依賴沒有變化時,始終始終緩存的 lazyWatcher.value 值。由於 lazyWatcher 的計算過程是 同步的當即計算,即不依賴於 nextTick,那麼本文將忽略此類型 watcher 實例。
  3. 當開發者調用 vm.$watch 或傳入 vm.$options.watch[watchKey] 時,本質上是建立一個 userWatchergithub

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

    額外的,每一個用戶傳入的 userWatcher 回調,都將做爲 userWatcher.cb 註冊。在每次 userWatcher.get 調用時,觸發 cb 回調函數。web

兩者協做解決了什麼問題

TL, DR: 在單次事件循環 tick 中,至多僅有一次 UI 更新機會,那麼若在單次事件循環存在的屢次 renderWatcher 更新視圖操做時,就不能當即執行更新視圖操做,而應該藉助去重操做和 nextTick 調度延遲執行視圖更新操做,實現最終僅有最後一次視圖更新生效。vuex

首先,基於現行 html living standard 標準對瀏覽器事件循環章節 event loop processing model 對一次事件循環 tick 的定義:在每次執行完當前 task 並清空其附屬 micro-task queue 後會由於性能 至多執行一次 UI 繪製(是否繪製取決於硬件刷新率)。express

11: update the renderingapi

  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 is 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 延遲迴調函數到一個特定的時機來觸發回調。那麼這就有了處理將屢次高頻調用處理爲僅保留最終調用的機會。

那麼本文要闡述的部分核心點以下:

  1. 如何收集待執行的回調函數並進行去重;
  2. 在何等時機觸發當前事件循環 tick 中,收集的全部待執行 watcher 回調函數。

概述 nextTick

什麼是 nextTick,其內部核心原理又是什麼?這裏以一次 vm.$nextTick 調用來簡要闡述 nextTick 的核心原理。

API 掛載:在初始化 Vue global API 時,會在 renderMixin 中在 Vue 原型對象上經歷如下 $nextTick 掛載:

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

那麼如下調用:

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

本質上是如下調用:

nextTick(() => {
  // do something you like
}, this) // 此處 this 恆定指向 vue 實例

最終獲得原始的 nextTick 函數實現:

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 對應:

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

    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 時:

    timerFunc = () => setImmediate(flushCallbacks)
  4. 最後,使用如下實現進行兜底:

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

不難看出全部的實現有一個共同點是都包含了 flushCallbacks 函數,以下:

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 調用,例如:

    // 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 實例。

// 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 以下:

    // 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 的核心職責在於調用 &dollar;options.render 渲染函數之際,指定調用上下文爲 vm._renderProxy,並傳參 vm.$createElement 函數,並最終 vm._render 產出 vnode

  2. vm._update 以下:

    // 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:

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

將被編譯爲vue template explorer

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

上文中 with 語句起到的做用是在其塊級做用域中拓展了做用域,使得 msg 的取值爲 this.msg,那麼以上渲染函數等價於:

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

根據以前文章對 data 依賴收集及其觸發原理的分析。咱們不可貴到,在 watcher 所訂閱的依賴更新時,將經過 data[dataKey].__ob__.dep.notify 調用 dep.subs[i].update 方法來實現通知訂閱者。

// 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 調用:

// 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

/**
 * 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 語句中:

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

經過 nextTick 函數調度了 flushSchedulerQueue 函數的執行。

flushSchedulerQueue

函數以下:

/**
 * 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,根據如下:

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

    由於 Array.prototype.sort 屬於原地排序,當回調函數返回值大於 0 時,會在原數組中原地交換 ab 順序,故以上排序結果爲小序在前的升序。又由於 watcher 實例化是 自增 ID。因此前文升序排列 watcher 代表父組件 renderWatcher 始終先於子組件 renderWatcher

    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] 的取值函數:

      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 函數。

      export default class Watcher {
        // ...
        /**
         * Scheduler job interface.
*/
    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)
          }
        }
      }
    }
  }
  ```
  1. 重置 watcher 隊列標識,表示當前隊列已經由 nextTick 調度獲得調用。

    resetSchedulerState()
  2. 對於 <keep-alive> 的緩存組件,激活 activated 鉤子。

    // call component activated hooks
    callActivatedHooks(activatedQueue)
  3. 對當前隊列 queue 中的 renderWatcher 對應的 vm 實例,調用 updated 鉤子。

    // 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

相關文章
相關標籤/搜索