vue中有一個較為特殊的API,nextTick。根據官方文檔的解釋,它可以在DOM更新完畢之后執行一個回調,用法如下:
// 修改數據
vm.msg = 'Hello'
// DOM 還沒有更新
Vue.nextTick(function () {
// DOM 更新了
})
盡管MVVM框架並不推薦訪問DOM,但有時候確實會有這樣的需求,尤其是和第三方插件進行配合的時候,免不了要進行DOM操作。而nextTick就提供了一個橋梁,確保我們操作的是更新后的DOM。
本文從這樣一個問題開始探索:vue如何檢測到DOM更新完畢呢?
檢索一下自己的前端知識庫,能監聽到DOM改動的API好像只有MutationObserver了,后面簡稱MO.
理解MutationObserver
MutationObserver是HTML5新增的屬性,用於監聽DOM修改事件,能夠監聽到節點的屬性、文本內容、子節點等的改動,是一個功能強大的利器,基本用法如下:
//MO基本用法
var observer = new MutationObserver(function(){
//這里是回調函數
console.log('DOM被修改了!');
});
var article = document.querySelector('article');
observer.observer(article);
MO的使用不是本篇重點。這里我們要思考的是:vue是不是用MO來監聽DOM更新完畢的呢?
那就打開vue的源碼看看吧,在實現nextTick的地方,確實能看到這樣的代碼:
//vue@2.2.5 /src/core/util/env.js
if (typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]')) {
var counter = 1
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
counter = (counter + 1) % 2
textNode.data = String(counter)
}
}
簡單解釋一下,如果檢測到瀏覽器支持MO,則創建一個文本節點,監聽這個文本節點的改動事件,以此來觸發nextTickHandler(也就是DOM更新完畢回調)的執行。后面的代碼中,會執行手工修改文本節點屬性,這樣就能進入到回調函數了。
大體掃了一眼,似乎可以得到實錘了:哦!vue是用MutationObserver監聽DOM更新完畢的!
難道不感覺哪里不對勁嗎?讓我們細細想一下:
-
我們要監聽的是模板中的DOM更新完畢,vue為什么自己創建了一個文本節點來監聽,這有點說不通啊!
-
難道自己創建的文本節點更新完畢,就能代表其他DOM節點更新完畢嗎?這又是什么道理!
看來我們上面得出的結論並不對,這時候就需要講講js的事件循環機制了。
事件循環(Event Loop)
在js的運行環境中,我們這里光說瀏覽器吧,通常伴隨着很多事件的發生,比如用戶點擊、頁面渲染、腳本執行、網絡請求,等等。為了協調這些事件的處理,瀏覽器使用事件循環機制。
簡要來說,事件循環會維護一個或多個任務隊列(task queues),以上提到的事件作為任務源往隊列中加入任務。有一個持續執行的線程來處理這些任務,每執行完一個就從隊列中移除它,這就是一次事件循環了,如下圖所示:

我們平時用setTimeout來執行異步代碼,其實就是在任務隊列的末尾加入了一個task,待前面的任務都執行完后再執行它。
關鍵的地方來了,每次event loop的最后,會有一個UI render步驟,也就是更新DOM。標准為什么這樣設計呢?考慮下面的代碼:
for(let i=0; i<100; i++){
dom.style.left = i + 'px';
}
瀏覽器會進行100次DOM更新嗎?顯然不是的,這樣太耗性能了。事實上,這100次for循環同屬一個task,瀏覽器只在該task執行完后進行一次DOM更新。
那我們的思路就來了:只要讓nextTick里的代碼放在UI render步驟后面執行,豈不就能訪問到更新后的DOM了?
vue就是這樣的思路,並不是用MO進行DOM變動監聽,而是用隊列控制的方式達到目的。那么vue又是如何做到隊列控制的呢?我們可以很自然的想到setTimeout,把nextTick要執行的代碼當作下一個task放入隊列末尾。
然而事情卻沒這么簡單,vue的數據響應過程包含:數據更改->通知Watcher->更新DOM。而數據的更改不由我們控制,可能在任何時候發生。如果恰巧發生在repaint之前,就會發生多次渲染。這意味着性能浪費,是vue不願意看到的。
所以,vue的隊列控制是經過了深思熟慮的(也經過了多次改動)。在這之前,我們還需了解event loop的另一個重要概念,microtask.
microtask
從名字看,我們可以把它稱為微任務。對應的,task隊列中的任務也被叫做macrotask。名字相似,性質可不一樣了。
每一次事件循環都包含一個microtask隊列,在循環結束后會依次執行隊列中的microtask並移除,然后再開始下一次事件循環。
在執行microtask的過程中后加入microtask隊列的微任務,也會在下一次事件循環之前被執行。也就是說,macrotask總要等到microtask都執行完后才能執行,microtask有着更高的優先級。
microtask的這一特性,簡直是做隊列控制的最佳選擇啊!vue進行DOM更新內部也是調用nextTick來做異步隊列控制。而當我們自己調用nextTick的時候,它就在更新DOM的那個microtask后追加了我們自己的回調函數,從而確保我們的代碼在DOM更新后執行,同時也避免了setTimeout可能存在的多次執行問題。
常見的microtask有:Promise、MutationObserver、Object.observe(廢棄),以及nodejs中的process.nextTick.
咦?好像看到了MutationObserver,難道說vue用MO是想利用它的microtask特性,而不是想做DOM監聽?對嘍,就是這樣的。核心是microtask,用不用MO都行的。事實上,vue在2.5版本中已經刪去了MO相關的代碼,因為它是HTML5新增的特性,在iOS上尚有bug。
那么最優的microtask策略就是Promise了,而令人尷尬的是,Promise是ES6新增的東西,也存在兼容問題呀~ 所以vue就面臨一個降級策略。
vue的降級策略
上面我們講到了,隊列控制的最佳選擇是microtask,而microtask的最佳選擇是Promise.但如果當前環境不支持Promise,vue就不得不降級為macrotask來做隊列控制了。
macrotask有哪些可選的方案呢?前面提到了setTimeout是一種,但它不是理想的方案。因為setTimeout執行的最小時間間隔是約4ms的樣子,略微有點延遲。還有其他的方案嗎?
不賣關子了,在vue2.5的源碼中,macrotask降級的方案依次是:setImmediate、MessageChannel、setTimeout.
setImmediate是最理想的方案了,可惜的是只有IE和nodejs支持。
MessageChannel的onmessage回調也是microtask,但也是個新API,面臨兼容性的尷尬...
所以最后的兜底方案就是setTimeout了,盡管它有執行延遲,可能造成多次渲染,算是沒有辦法的辦法了。
總結
以上就是vue的nextTick方法的實現原理了,總結一下就是:
-
vue用異步隊列的方式來控制DOM更新和nextTick回調先后執行
-
microtask因為其高優先級特性,能確保隊列中的微任務在一次事件循環前被執行完畢
-
因為兼容性問題,vue不得不做了microtask向macrotask的降級方案
相關資料:
event loop標准
https://html.spec.whatwg.org/multipage/webappapis.html#event-loops
vue2.5的nextTick更改記錄
https://github.com/vuejs/vue/commit/6e41679a96582da3e0a60bdbf123c33ba0e86b31
源碼解析文章
https://github.com/answershuto/learnVue/blob/master/docs/Vue.js%E5%BC%82%E6%AD%A5%E6%9B%B4%E6%96%B0DOM%E7%AD%96%E7%95%A5%E5%8F%8AnextTick.MarkDown
