通俗易懂了解Vue中nextTick的內部實現原理


1. 前言

nextTick 是 Vue 中的一個核心功能,在 Vue 內部實現中也經常用到 nextTick。在介紹 nextTick 實現原理之前,我們有必要先了解一下這個東西到底是什么,為什么要有它,它是干嘛用的。

2. nextTick到底是什么

官方文檔對 nextTick 的功能如是說明:

在下次 DOM 更新循環結束之后執行延遲回調。在修改數據之后立即使用這個方法,獲取更新后的 DOM

// 修改數據
vm.msg = 'Hello'
// DOM 還沒有更新
Vue.nextTick(function () {
  // DOM 更新了
})

// 作為一個 Promise 使用 (2.1.0 起新增,詳見接下來的提示)
Vue.nextTick()
  .then(function () {
    // DOM 更新了
  })

2.1.0 起新增:如果沒有提供回調且在支持 Promise 的環境中,則返回一個 Promise。請注意 Vue 不自帶 Promise 的 polyfill,所以如果你的目標瀏覽器不原生支持 Promise (IE:你們都看我干嘛),你得自己提供 polyfill。

從上面的官方介紹中可以看到,nextTick 的主要功能就是更新數據后讓回調函數作用於更新后的DOM 。看到這句話,你可能第一反應是:呸!說了等於沒說,還是不理解。那么請看下面這個例子:

<template>
<div id="example">{{message}}</div>
</template>
<script>
var vm = new Vue({
  el: '#example',
  data: {
    message: '123'
  }
})
vm.message = 'new message' // 更改數據
console.log(vm.$el.innerHTML) // '123'
Vue.nextTick(function () {
  console.log(vm.$el.innerHTML) // 'new message'
})
</script>

在上面例子中,當我們更新了message的數據后,立即獲取vm.$el.innerHTML,發現此時獲取到的還是更新之前的數據:123。但是當我們使用nextTick來獲取vm.$el.innerHTML時,此時就可以獲取到更新后的數據了。這是為什么呢?

這里就涉及到Vue中對DOM的更新策略了,Vue 在更新 DOM 時是異步執行的。只要偵聽到數據變化,Vue 將開啟一個事件隊列,並緩沖在同一事件循環中發生的所有數據變更。如果同一個 watcher 被多次觸發,只會被推入到事件隊列中一次。這種在緩沖時去除重復數據對於避免不必要的計算和 DOM 操作是非常重要的。然后,在下一個的事件循環“tick”中,Vue 刷新事件隊列並執行實際 (已去重的) 工作。

在上面這個例子中,當我們通過 vm.message = ‘new message‘更新數據時,此時該組件不會立即重新渲染。當刷新事件隊列時,組件會在下一個事件循環“tick”中重新渲染。所以當我們更新完數據后,此時又想基於更新后的 DOM 狀態來做點什么,此時我們就需要使用Vue.nextTick(callback),把基於更新后的DOM 狀態所需要的操作放入回調函數callback中,這樣回調函數將在 DOM 更新完成后被調用。

OK,現在大家應該對nextTick是什么、為什么要有nextTick以及怎么使用nextTick有個大概的了解了。那么問題又來了,Vue為什么要這么設計?為什么要異步更新DOM?這就涉及到另外一個知識:JS的運行機制。

3. 前置知識:JS的運行機制

我們知道 JS 執行是單線程的,它是基於事件循環的。事件循環大致分為以下幾個步驟:

  1. 所有同步任務都在主線程上執行,形成一個執行棧(execution context stack)。
  2. 主線程之外,還存在一個"任務隊列"(task queue)。只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件。
  3. 一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務隊列",看看里面有哪些事件。那些對應的異步任務,於是結束等待狀態,進入執行棧,開始執行。
  4. 主線程不斷重復上面的第三步。

 

 主線程的執行過程就是一個 tick,而所有的異步結果都是通過 “任務隊列” 來調度。 消息隊列中存放的是一個個的任務(task)。 規范中規定 task 分為兩大類,分別是宏任務(macro task) 和微任務(micro task),並且每執行完一個個宏任務(macro task)后,都要去清空該宏任務所對應的微任務隊列中所有的微任務(micro task),他們的執行順序如下所示:

for (macroTask of macroTaskQueue) {
    // 1. 處理當前的宏任務
    handleMacroTask();
      
    // 2. 處理對應的所有微任務
    for (microTask of microTaskQueue) {
        handleMicroTask(microTask);
    }
}

在瀏覽器環境中,常見的

  • 宏任務(macro task) 有 setTimeoutMessageChannelpostMessagesetImmediate
  • 微任務(micro task)有MutationObsever 和 Promise.then

OK,有了這個概念之后,接下來我們就進入本篇文章的正菜:從Vue源碼角度來分析nextTick的實現原理。

4. nextTick源碼分析

nextTick 的源碼位於src/core/util/next-tick.js,總計118行。

nextTick源碼主要分為兩塊:

  1. 能力檢測
  2. 根據能力檢測以不同方式執行回調隊列

4.1 能力檢測

Vue 在內部對異步隊列嘗試使用原生的 Promise.thenMutationObserver 和 setImmediate,如果執行環境不支持,則會采用 setTimeout(fn, 0) 代替。

宏任務耗費的時間是大於微任務的,所以在瀏覽器支持的情況下,優先使用微任務。如果瀏覽器不支持微任務,使用宏任務;但是,各種宏任務之間也有效率的不同,需要根據瀏覽器的支持情況,使用不同的宏任務。

這一部分的源碼如下:

let microTimerFunc
let macroTimerFunc
let useMacroTask = false

/* 對於宏任務(macro task) */
// 檢測是否支持原生 setImmediate(高版本 IE 和 Edge 支持)
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} 
// 檢測是否支持原生的 MessageChannel
else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} 
// 都不支持的情況下,使用setTimeout
else {
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

/* 對於微任務(micro task) */
// 檢測瀏覽器是否原生支持 Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
  }
} 
// 不支持的話直接指向 macro task 的實現。
else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}

首先聲明了兩個變量: microTimerFunc 和 macroTimerFunc ,它們分別對應的是 micro task 的函數和 macro task 的函數。對於 macro task 的實現,優先檢測是否支持原生 setImmediate,這是一個高版本 IE 和Edge 才支持的特性,不支持的話再去檢測是否支持原生的 MessageChannel,如果也不支持的話就會降級為 setTimeout 0;而對於 micro task 的實現,則檢測瀏覽器是否原生支持 Promise,不支持的話直接指向 macro task 的實現。

4.2 執行回調隊列

接下來就進入了核心函數nextTick中,如下:

const callbacks = []   // 回調隊列
let pending = false    // 異步鎖

// 執行隊列中的每一個回調
function flushCallbacks () {
  pending = false     // 重置異步鎖
  // 防止出現nextTick中包含nextTick時出現問題,在執行回調函數隊列前,提前復制備份並清空回調函數隊列
  const copies = callbacks.slice(0)
  callbacks.length = 0
  // 執行回調函數隊列
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

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
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // 如果沒有提供回調,並且支持Promise,返回一個Promise
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

首先,先來看 nextTick函數,該函數的主要邏輯是:先把傳入的回調函數 cb 推入 回調隊列callbacks 數組,同時在接收第一個回調函數時,執行能力檢測中對應的異步方法(異步方法中調用了回調函數隊列)。最后一次性地根據 useMacroTask 條件執行 macroTimerFunc 或者是 microTimerFunc,而它們都會在下一個 tick 執行 flushCallbacksflushCallbacks 的邏輯非常簡單,對 callbacks 遍歷,然后執行相應的回調函數。

nextTick 函數最后還有一段邏輯:

 if (!cb && typeof Promise !== 'undefined') {
  return new Promise(resolve => {
    _resolve = resolve
  })
}

這是當 nextTick 不傳 cb 參數的時候,提供一個 Promise 化的調用,比如:

nextTick().then(() => {})

當 _resolve 函數執行,就會跳到 then 的邏輯中。

這里有兩個問題需要注意:

  1. 如何保證只在接收第一個回調函數時執行異步方法?

    nextTick源碼中使用了一個異步鎖的概念,即接收第一個回調函數時,先關上鎖,執行異步方法。此時,瀏覽器處於等待執行完同步代碼就執行異步代碼的情況。

  2. 執行 flushCallbacks 函數時為什么需要備份回調函數隊列?執行的也是備份的回調函數隊列?

    因為,會出現這么一種情況:nextTick 的回調函數中還使用 nextTick。如果 flushCallbacks 不做特殊處理,直接循環執行回調函數,會導致里面nextTick 中的回調函數會進入回調隊列。

5. 總結

以上就是對 nextTick 的源碼分析,我們了解到數據的變化到 DOM 的重新渲染是一個異步過程,發生在下一個 tick。當我們在實際開發中,比如從服務端接口去獲取數據的時候,數據做了修改,如果我們的某些方法去依賴了數據修改后的 DOM 變化,我們就必須在 nextTick 后執行。如下:

getData(res).then(()=>{
  this.xxx = res.data
  this.$nextTick(() => {
    // 這里我們可以獲取變化后的 DOM
  })
})

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM