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
執行是單線程的,它是基於事件循環的。事件循環大致分為以下幾個步驟:
- 所有同步任務都在主線程上執行,形成一個執行棧(
execution context stack
)。 - 主線程之外,還存在一個"任務隊列"(
task queue
)。只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件。 - 一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務隊列",看看里面有哪些事件。那些對應的異步任務,於是結束等待狀態,進入執行棧,開始執行。
- 主線程不斷重復上面的第三步。
主線程的執行過程就是一個 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
) 有setTimeout
、MessageChannel
、postMessage
、setImmediate
; - 微任務(
micro task
)有MutationObsever
和Promise.then
。
OK,有了這個概念之后,接下來我們就進入本篇文章的正菜:從Vue
源碼角度來分析nextTick
的實現原理。
4. nextTick源碼分析
nextTick
的源碼位於src/core/util/next-tick.js
,總計118行。
nextTick
源碼主要分為兩塊:
- 能力檢測
- 根據能力檢測以不同方式執行回調隊列
4.1 能力檢測
Vue
在內部對異步隊列嘗試使用原生的 Promise.then
、MutationObserver
和 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 執行 flushCallbacks
,flushCallbacks
的邏輯非常簡單,對 callbacks
遍歷,然后執行相應的回調函數。
nextTick
函數最后還有一段邏輯:
if (!cb && typeof Promise !== 'undefined') { return new Promise(resolve => { _resolve = resolve }) }
這是當 nextTick
不傳 cb
參數的時候,提供一個 Promise 化的調用,比如:
nextTick().then(() => {})
當 _resolve
函數執行,就會跳到 then
的邏輯中。
這里有兩個問題需要注意:
-
如何保證只在接收第一個回調函數時執行異步方法?
nextTick
源碼中使用了一個異步鎖的概念,即接收第一個回調函數時,先關上鎖,執行異步方法。此時,瀏覽器處於等待執行完同步代碼就執行異步代碼的情況。 -
執行
flushCallbacks
函數時為什么需要備份回調函數隊列?執行的也是備份的回調函數隊列?因為,會出現這么一種情況:
nextTick
的回調函數中還使用nextTick
。如果flushCallbacks
不做特殊處理,直接循環執行回調函數,會導致里面nextTick
中的回調函數會進入回調隊列。
5. 總結
以上就是對 nextTick
的源碼分析,我們了解到數據的變化到 DOM
的重新渲染是一個異步過程,發生在下一個 tick。當我們在實際開發中,比如從服務端接口去獲取數據的時候,數據做了修改,如果我們的某些方法去依賴了數據修改后的 DOM 變化,我們就必須在 nextTick
后執行。如下:
getData(res).then(()=>{ this.xxx = res.data this.$nextTick(() => { // 這里我們可以獲取變化后的 DOM }) })