Vue 原始碼解讀(4)—— 非同步更新

李永寧發表於2022-02-24

前言

上一篇的 Vue 原始碼解讀(3)—— 響應式原理 說到通過 Object.defineProperty 為物件的每個 key 設定 getter、setter,從而攔截對資料的訪問和設定。

當對資料進行更新操作時,比如 obj.key = 'new val' 就會觸發 setter 的攔截,從而檢測新值和舊值是否相等,如果相等什麼也不做,如果不相等,則更新值,然後由 dep 通知 watcher 進行更新。所以,非同步更新 的入口點就是 setter 中最後呼叫的 dep.notify() 方法。

目的

  • 深入理解 Vue 的非同步更新機制

  • nextTick 的原理

原始碼解讀

dep.notify

/src/core/observer/dep.js

關於 dep 更加詳細的介紹請檢視上一篇文章 —— Vue 原始碼解讀(3)—— 響應式原理,這裡就不佔用篇幅了。

/**
 * 通知 dep 中的所有 watcher,執行 watcher.update() 方法
 */
notify () {
  // stabilize the subscriber list first
  const subs = this.subs.slice()
  // 遍歷 dep 中儲存的 watcher,執行 watcher.update()
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}

watcher.update

/src/core/observer/watcher.js

/**
 * 根據 watcher 配置項,決定接下來怎麼走,一般是 queueWatcher
 */
update () {
  /* istanbul ignore else */
  if (this.lazy) {
    // 懶執行時走這裡,比如 computed
    // 將 dirty 置為 true,可以讓 computedGetter 執行時重新計算 computed 回撥函式的執行結果
    this.dirty = true
  } else if (this.sync) {
    // 同步執行,在使用 vm.$watch 或者 watch 選項時可以傳一個 sync 選項,
    // 當為 true 時在資料更新時該 watcher 就不走非同步更新佇列,直接執行 this.run 
    // 方法進行更新
    // 這個屬性在官方文件中沒有出現
    this.run()
  } else {
    // 更新時一般都這裡,將 watcher 放入 watcher 佇列
    queueWatcher(this)
  }
}

queueWatcher

/src/core/observer/scheduler.js

/**
 * 將 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 放入已排序的佇列中,且佇列仍是有序的
      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) {
        // 直接重新整理排程佇列
        // 一般不會走這兒,Vue 預設是非同步執行,如果改為同步執行,效能會大打折扣
        flushSchedulerQueue()
        return
      }
      /**
       * 熟悉的 nextTick => vm.$nextTick、Vue.nextTick
       *   1、將 回撥函式(flushSchedulerQueue) 放入 callbacks 陣列
       *   2、通過 pending 控制向瀏覽器任務佇列中新增 flushCallbacks 函式
       */
      nextTick(flushSchedulerQueue)
    }
  }
}

nextTick

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

const callbacks = []
let pending = false

/**
 * 完成兩件事:
 *   1、用 try catch 包裝 flushSchedulerQueue 函式,然後將其放入 callbacks 陣列
 *   2、如果 pending 為 false,表示現在瀏覽器的任務佇列中沒有 flushCallbacks 函式
 *     如果 pending 為 true,則表示瀏覽器的任務佇列中已經被放入了 flushCallbacks 函式,
 *     待執行 flushCallbacks 函式時,pending 會被再次置為 false,表示下一個 flushCallbacks 函式可以進入
 *     瀏覽器的任務佇列了
 * pending 的作用:保證在同一時刻,瀏覽器的任務佇列中只有一個 flushCallbacks 函式
 * @param {*} cb 接收一個回撥函式 => flushSchedulerQueue
 * @param {*} ctx 上下文
 * @returns 
 */
export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // 用 callbacks 陣列儲存經過包裝的 cb 函式
  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 函式
    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
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
} 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
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // 再就是 setImmediate,它其實已經是一個巨集任務了,但仍然比 setTimeout 要好
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // 最後沒辦法,則使用 setTimeout
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

flushCallbacks

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

const callbacks = []
let pending = false

/**
 * 做了三件事:
 *   1、將 pending 置為 false
 *   2、清空 callbacks 陣列
 *   3、執行 callbacks 陣列中的每一個函式(比如 flushSchedulerQueue、使用者呼叫 nextTick 傳遞的回撥函式)
 */
function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  // 遍歷 callbacks 陣列,執行其中儲存的每個 flushSchedulerQueue 函式
  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
}

watcher.run

/src/core/observer/watcher.js

/**
 * 由 重新整理佇列函式 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)
      }
    }
  }
}

watcher.get

/src/core/observer/watcher.js

  /**
   * 執行 this.getter,並重新收集依賴
   * this.getter 是例項化 watcher 時傳遞的第二個引數,一個函式或者字串,比如:updateComponent 或者 parsePath 返回的函式
   * 為什麼要重新收集依賴?
   *   因為觸發更新說明有響應式資料被更新了,但是被更新的資料雖然已經經過 observe 觀察了,但是卻沒有進行依賴收集,
   *   所以,在更新頁面時,會重新執行一次 render 函式,執行期間會觸發讀取操作,這時候進行依賴收集
   */
  get () {
    // 開啟 Dep.target,Dep.target = this
    pushTarget(this)
    // value 為回撥函式執行的結果
    let value
    const vm = this.vm
    try {
      // 執行回撥函式,比如 updateComponent,進入 patch 階段
      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)
      }
      // 關閉 Dep.target,Dep.target = null
      popTarget()
      this.cleanupDeps()
    }
    return value
  }

以上就是 Vue 非同步更新機制的整個執行過程。

總結

  • 面試官 問:Vue 的非同步更新機制是如何實現的?

    Vue 的非同步更新機制的核心是利用了瀏覽器的非同步任務佇列來實現的,首選微任務佇列,巨集任務佇列次之。

    當響應式資料更新後,會呼叫 dep.notify 方法,通知 dep 中收集的 watcher 去執行 update 方法,watcher.update 將 watcher 自己放入一個 watcher 佇列(全域性的 queue 陣列)。

    然後通過 nextTick 方法將一個重新整理 watcher 佇列的方法(flushSchedulerQueue)放入一個全域性的 callbacks 陣列中。

    如果此時瀏覽器的非同步任務佇列中沒有一個叫 flushCallbacks 的函式,則執行 timerFunc 函式,將 flushCallbacks 函式放入非同步任務佇列。如果非同步任務佇列中已經存在 flushCallbacks 函式,等待其執行完成以後再放入下一個 flushCallbacks 函式。

    flushCallbacks 函式負責執行 callbacks 陣列中的所有 flushSchedulerQueue 函式。

    flushSchedulerQueue 函式負責重新整理 watcher 佇列,即執行 queue 陣列中每一個 watcher 的 run 方法,從而進入更新階段,比如執行元件更新函式或者執行使用者 watch 的回撥函式。

    完整的執行過程其實就是今天原始碼閱讀的過程。


面試關 問:Vue 的 nextTick API 是如何實現的?

Vue.nextTick 或者 vm.$nextTick 的原理其實很簡單,就做了兩件事:

  • 將傳遞的回撥函式用 try catch 包裹然後放入 callbacks 陣列

  • 執行 timerFunc 函式,在瀏覽器的非同步任務佇列放入一個重新整理 callbacks 陣列的函式

連結

感謝各位的:點贊收藏評論,我們下期見。


當學習成為了習慣,知識也就變成了常識。 感謝各位的 點贊收藏評論

新視訊和文章會第一時間在微信公眾號傳送,歡迎關注:李永寧lyn

文章已收錄到 github 倉庫 liyongning/blog,歡迎 Watch 和 Star。

相關文章