原發于我的部落格。
前一篇文章已經詳細記述了Vue的核心執行過程。相當于已經搞定了主線劇情。後續的文章都會對其中沒有介紹的細節進行展開。
現在我們就來講講其他支線任務:nextTick和microtask。
Vue的nextTick api的實作部分是Vue裡比較好了解的一部分,與其他部分的代碼也非常的解耦,是以這一塊的相關源碼解析文章很多。我本來也不準備單獨寫部落格細說這部分,但是最近偶然在别人的文章中了解到:
每輪次的event loop中,每次執行一個task,并執行完microtask隊列中的所有microtask之後,就會進行UI的渲染。但是作者似乎對于這個結論也不是很肯定。而我第一反應就是Vue的$nextTick既然用到了MutationObserver(MO的回調放進的是microtask的任務隊列中的),那麼是不是也是出于這個考慮呢?于是我想研究了一遍Vue的$nextTick,就可以了解是不是出于這個目的,也同時看能不能佐證UI Render真的是在microtask隊列清空後執行的。
研究之後的結論:我之前對于$nextTick源碼的了解完全是錯的,以及每輪事件循環執行完所有的microtask,是會執行UI Render的。
task/macrotask和microtask的概念自從去年知乎上有人提出這個問題之後,task和microtask已經被很多同學了解了,我也是當時看到了microtask的内容,現在已經有非常多的中文介紹部落格在介紹這部分的知識,最近這篇火遍掘金、SF和知乎的文章,最後也是考了microtask的概念。如果你沒有看過task/microtask的内容的話,我還是推薦這篇英文部落格,是絕大多數國内部落格的内容來源。
先說nextTick的具體實作
先用120秒介紹MutationObserver: MO是HTML5中的新API,是個用來監視DOM變動的接口。他能監聽一個DOM對象上發生的子節點删除、屬性修改、文本内容修改等等。
調用過程很簡單,但是有點不太尋常:你需要先給他綁回調:
var mo = new MutationObserver(callback)
通過給MO的構造函數傳入一個回調,能得到一個MO執行個體,這個回調就會在MO執行個體監聽到變動時觸發。
這個時候你隻是給MO執行個體綁定好了回調,他具體監聽哪個DOM、監聽節點删除還是監聽屬性修改,你都還沒有設定。而調用他的observer方法就可以完成這一步:
var domTarget = 你想要監聽的dom節點
mo.observe(domTarget, {
characterData: true //說明監聽文本内容的修改。
})
一個需要先說的細節是,MutationObserver的回調是放在microtask中執行的。
ok了,現在這個domTarget上發生的文本内容修改就會被mo監聽到,mo就會觸發你在
new MutationObserver(callback)
中傳入的callback。
現在我們來看Vue.nextTick的源碼:
export const nextTick = (function () {
var callbacks = []
var pending = false
var timerFunc
function nextTickHandler () {
pending = false
// 之是以要slice複制一份出來是因為有的cb執行過程中又會往callbacks中加入内容
// 比如$nextTick的回調函數裡又有$nextTick
// 這些是應該放入到下一個輪次的nextTick去執行的,
// 是以拷貝一份目前的,周遊執行完目前的即可,避免無休止的執行下去
var copies = callbacks.slice(0)
callbacks = []
for (var i = 0; i < copies.length; i++) {
copies[i]()
}
}
/* istanbul ignore if */
// ios9.3以上的WebView的MutationObserver有bug,
//是以在hasMutationObserverBug中存放了是否是這種情況
if (typeof MutationObserver !== 'undefined' && !hasMutationObserverBug) {
var counter = 1
// 建立一個MutationObserver,observer監聽到dom改動之後後執行回調nextTickHandler
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(counter)
// 調用MutationObserver的接口,觀測文本節點的字元内容
observer.observe(textNode, {
characterData: true
})
// 每次執行timerFunc都會讓文本節點的内容在0/1之間切換,
// 不用true/false可能是有的浏覽器對于文本節點設定内容為true/false有bug?
// 切換之後将新值指派到那個我們MutationObserver觀測的文本節點上去
timerFunc = function () {
counter = (counter + 1) % 2
textNode.data = counter
}
} else {
// webpack attempts to inject a shim for setImmediate
// if it is used as a global, so we have to work around that to
// avoid bundling unnecessary code.
// webpack預設會在代碼中插入setImmediate的墊片
// 沒有MutationObserver就優先用setImmediate,不行再用setTimeout
const context = inBrowser
? window
: typeof global !== 'undefined' ? global : {}
timerFunc = context.setImmediate || setTimeout
}
return function (cb, ctx) {
var func = ctx
? function () { cb.call(ctx) }
: cb
callbacks.push(func)
// 如果pending為true, 就其實表明本輪事件循環中已經執行過timerFunc(nextTickHandler, 0)
if (pending) return
pending = true
timerFunc(nextTickHandler, 0)
}
})()
上面這個函數執行過程後生成的那個函數才是nextTick。而這個函數的執行過程就是先初始化pending變量和cb變量,cb用來存放需要執行的回調,pending表示是否把清空回調的nextTickHandler函數加入到異步隊列中。
然後就是建立了一個MO,這個MO監聽了一個新建立的文本節點的文本内容變化,同時監聽到變化時的回調就是nextTickHandler。nextTickHandler周遊cb數組,把需要執行的cb給拿出來一個個執行了。
而最後傳回出去作為nextTick的那個函數就比較簡單了:
function (cb, ctx) {
var func = ctx
? function () { cb.call(ctx) }
: cb
callbacks.push(func)
// 如果pending為true, 就其實表明本輪事件循環中已經執行過timerFunc(nextTickHandler, 0)
if (pending) return
pending = true
timerFunc(nextTickHandler, 0)
}
}
也就是把傳入的回調放入cb數組當中,然後執行
timerFunc(nextTickHandler, 0)
,其實是執行
timerFunc()
,後面傳入的兩參數沒用,在浏覽器不支援MO的情況timerFunc才回退到setTimeout,那倆參數才有效果。timerFunc就是把那個被MO監聽的文本節點改一下它的内容,這樣我改了文本内容,MO就會在目前的所有同步代碼完成之後執行回調,進而執行資料更新到DOM上之後的任務。
我一開始在看這一段代碼時忘記了MutationObserver的回調是在microtask裡執行的。而且當時也還沒有看過Vue的其他源碼,當時的我大體看懂nextTick代碼流程之後,形成了如下的了解,而且覺得似乎完美的解釋了代碼邏輯:
watcher監聽到資料變化之後,會立馬去修改dom,接着使用者書寫的代碼裡的nextTick被執行,而nextTick内部也是去修改DOM(textNode),當這個最後修改的textNode修改完成了,觸發了MutationObserver的回調,那就意味着,前面的DOM修改也已經完成了,是以nextTick向使用者保證的
DOM更新之後再執行使用者的回調
就得以實作了。
Damn,現在看了Batcher的代碼和認真反思了以後,立馬醒悟,上面的想法完完全全就是一坨狗屎,totally shit!
首先,一個普遍的常識是DOM Tree的修改是實時的,而修改的Render到DOM上才是異步的。根本不存在什麼所謂的等待DOM修改完成,任何時候我在上一行代碼裡往DOM中添加了一個元素、修改了一個DOM的textContent,你在下一行代碼裡一定能立馬就讀取到新的DOM,我知道這個理。但是我還是搞不懂我怎麼會産生用
nextTick來保證DOM修改的完成這樣的怪念頭
。可能那天屎吃得有點多了。
其次,我們來看看使用nextTick的真正原因:
Vue在兩個地方用到了上述nextTick:
- Vue.nextTick和Vue.prototype.$nextTick都是直接使用了這個nextTick
- 在batcher中,也就是watcher觀測到資料變化後執行的是
,nextTick(flushBatcherQueue)
則負責執行完成所有的dom更新操作。flushBatcherQueue
Batcher的源碼,我在上一篇文章當中已經詳細的分析了,在這裡我用一張圖來說明它和nextTick的詳細處理過程吧。
假設此時Vue執行個體的模闆為:
<div id="a">{{a}}</div>

仔細跟蹤了代碼執行過程我們會發現,真正的去周遊watcher,批處理更新是在microtask中執行的,而且使用者在修改資料後自己執行的
nextTick(cb)
也會在此時執行cb,他們都是在同一個microtask中執行。根本就不是我最開始想的那樣,把回調放在以後的事件循環中去執行。
同時,上面這個過程也深切的揭露出Vue nextTick的本質,我不是想要MO來幫我真正監聽DOM更改,我隻是想要一個異步API,用來在目前的同步代碼執行完畢後,執行我想執行的異步回調。
之是以要這樣,是因為使用者的代碼當中是可能多次修改資料的,而每次修改都會同步通知到所有訂閱該資料的watcher,而立馬執行将資料寫到DOM上是肯定不行的,那就隻是把watcher加入數組。等到目前task執行完畢,所有的同步代碼已經完成,那麼這一輪次的資料修改就已經結束了,這個時候我可以安安心心的去将對監聽到依賴變動的watcher完成資料真正寫入到DOM上的操作,這樣即使你在之前的task裡改了一個watcher的依賴100次,我最終隻會計算一次value、改DOM一次。一方面省去了不必要的DOM修改,另一方面将DOM操作聚集,可以提升DOM Render效率。
那為什麼一定要用MutationObserver呢?不,并沒有一定要用MO,隻要是microtask都可以。在最新版的Vue源碼裡,優先使用的就是
Promise.resolve().then(nextTickHandler)
來将異步回調放入到microtask中(MO在IOS9.3以上的WebView中有bug),沒有原生Promise才用MO。
這充分說明了microtask才是nextTick的本質,MO什麼的隻是個備胎,要是有比MO優先級更高、浏覽器相容性更好的microtask,那可能就分分鐘把MO拿下了。
那問題又來了,為什麼一定要microtask?task可以嗎?(macrotask和task是一回事哈,HTML5标準裡甚至都沒有macrotask這個詞)。
哈,現在剛好有個例子,Vue一開始曾經改過nextTick的實作。我們來看看這兩個jsFiddle:jsfiddle1和jsfiddle2。
兩個fiddle的實作一模一樣,就是讓那個絕對定位的黃色元素起到一個fixed定位的效果:綁定scroll事件,每次滾動的時候,計算目前滾動的位置并更改到那個絕對定位元素的top屬性上去。大家自己試試滾動幾下,對比下效果,你就會發現第一個fiddle中的黃元素是穩定不動的,fixed很好。而後一個fiddle中就有問題了,黃色元素上下晃動,似乎跟不上我們scroll的節奏,總要慢一點,雖然最後停下滾動時位置是對的。
上述兩個例子其實是在這個issue中找到的,第一個jsfiddle使用的版本是Vue 2.0.0-rc.6,這個版本的nextTick實作是采用了MO,而後因為IOS9.3的WebView裡的MO有bug,于是尤雨溪更改了實作,換成了
window.postMessage
,也就是後一個fiddle所使用的Vue 2.0.0-rc.7。後來尤雨溪了解到
window.postMessage
是将回調放入的macrotask 隊列。這就是問題的根源了。
HTML中的UI事件、網絡事件、HTML Parsing等都是使用的task來完成,是以每次scroll事件觸發後,在目前的task裡隻是完成了把watcher加入隊列和把清空watcher的flushBatcherQueue作為異步回調傳入nextTick。
如果nextTick使用的是microtask,那麼在task執行完畢之後就會立即執行所有microtask,那麼flushBatcherQueue(真正修改DOM)便得以在此時立即完成,而後,目前輪次的microtask全部清理完成時,執行UI rendering,把重排重繪等操作真正更新到DOM上(後文會細說)。(注意,頁面的滾動效果并不需要重繪哈。重繪是當你修改了UI樣式、DOM結構等等,頁面将樣式呈現出來,别暈了。)
如果nextTick使用的是task,那麼會在目前的task和所有microtask執行完畢之後才在以後的某一次task執行過程中處理flushBatcherQueue,那個時候才真正執行各個指令的修改DOM操作,但那時為時已晚,錯過了多次觸發重繪、渲染UI的時機。而且浏覽器内部為了更快的響應使用者UI,内部可能是有多個task queue的:
For example, a user agent could have one task queue for mouse and key events (the user interaction task source), and another for everything else. The user agent could then give keyboard and mouse events preference over other tasks three quarters of the time, keeping the interface responsive but not starving other task queues, and never processing events from any one task source out of order.
而UI的task queue的優先級可能更高,是以對于尤雨溪采用的
window.postMessage
,甚至可能已經多次執行了UI的task,都沒有執行
window.postMessage
的task,也就導緻了我們更新DOM操作的延遲。在重CPU計算、UI渲染任務情況下,這一延遲達到issue觀測到的100毫秒到1秒的級别是完全課可能的。是以,使用task來實作nextTick是不可行的,而尤雨溪也撤回了這一次的修改,後續的nextTick實作中,依然是使用的Promise.then和MO。
task microtask和每輪event loop之後的UI Render
我最近認真閱讀了一下HTML5規範,還是來說一說task和microtask處理完成之後的UI渲染過程,講一下每次task執行和所有microtask執行完畢後使如何完成UI Render的。
先上HTML标準原文:
比較典型的task有如下這些
Events
Dispatching an Event object at a particular EventTarget object is often done by a dedicated task. Not all events are dispatched using the task queue, many are dispatched during other tasks.
Parsing
The HTML parser tokenizing one or more bytes, and then processing any resulting tokens, is typically a task.
Callbacks
Calling a callback is often done by a dedicated task.
Using a resource
When an algorithm fetches a resource, if the fetching occurs in a non-blocking fashion then the processing of the resource once some or all of the resource is available is performed by a task.
Reacting to DOM manipulation
Some elements have tasks that trigger in response to DOM manipulation, e.g. when that element is inserted into the document.
此外,還包括setTimeout, setInterval, setImmediate, window.postMessage等等。
上述Reacting to DOM manipulation并不是說你執行DOM操作時就會把這個DOM操作的執行當成一個task。是那些異步的reacting會被當做task。
HTML5标準:task、microtask和UI render的具體執行過程如下:
An event loop must continually run through the following steps for as long as it exists:
1.Select the oldest task on one of the event loop's task queues, if any, ignoring, in the case of a browsing context event loop, tasks whose associated Documents are not fully active. The user agent may pick any task queue. If there is no task to select, then jump to the microtasks step below.
2.Set the event loop's currently running task to the task selected in the previous step.
3.Run: Run the selected task.
4.Set the event loop's currently running task back to null.
5.Remove the task that was run in the run step above from its task queue.
6.Microtasks: Perform a microtask checkpoint. //這裡會執行所有的microtask
7.Update the rendering: If this event loop is a browsing context event loop (as opposed to a worker event loop), then run the following substeps.
7.1 Let now be the value that would be returned by the Performance object's now() method.
7.2 Let docs be the list of Document objects associated with the event loop in question, sorted arbitrarily except that the following conditions must be met:
7.3 If there are top-level browsing contexts B that the user agent believes would not benefit from having their rendering updated at this time, then remove from docs all Document objects whose browsing context's top-level browsing context is in B.
7.4 If there are a nested browsing contexts B that the user agent believes would not benefit from having their rendering updated at this time, then remove from docs all Document objects whose browsing context is in B.
7.5 For each fully active Document in docs, run the resize steps for that Document, passing in now as the timestamp. [CSSOMVIEW]
7.6 For each fully active Document in docs, run the scroll steps for that Document, passing in now as the timestamp. [CSSOMVIEW]
7.7 For each fully active Document in docs, evaluate media queries and report changes for that Document, passing in now as the timestamp. [CSSOMVIEW]
7.8 For each fully active Document in docs, run CSS animations and send events for that Document, passing in now as the timestamp. [CSSANIMATIONS]
7.9 For each fully active Document in docs, run the fullscreen rendering steps for that Document, passing in now as the timestamp. [FULLSCREEN]
7.10 For each fully active Document in docs, run the animation frame callbacks for that Document, passing in now as the timestamp.
7.11 For each fully active Document in docs, run the update intersection observations steps for that Document, passing in now as the timestamp. [INTERSECTIONOBSERVER]
7.12 For each fully active Document in docs, update the rendering or user interface of that Document and its browsing context to reflect the current state.
8.If this is a worker event loop (i.e. one running for a WorkerGlobalScope), but there are no tasks in the event loop's task queues and the WorkerGlobalScope object's closing flag is true, then destroy the event loop, aborting these steps, resuming the run a worker steps described in the Web workers section below.
9.Return to the first step of the event loop.
解釋一下:第一步,從多個task queue中的一個queue裡,挑出一個最老的task。(因為有多個task queue的存在,使得浏覽器可以完成我們前面說的,優先、高頻率的執行某些task queue中的任務,比如UI的task queue)。
然後2到5步,執行這個task。
第六步, Perform a microtask checkpoint. ,這裡會執行完microtask queue中的所有的microtask,如果microtask執行過程中又添加了microtask,那麼仍然會執行新添加的microtask,當然,這個機制好像有限制,一輪microtask的執行總量似乎有限制(1000?),數量太多就執行一部分留下的以後再執行?這裡我不太确定。
第七步,Update the rendering:
7.2到7.4,目前輪次的event loop中關聯到的document對象會保持某些特定順序,這些document對象都會執行需要執行UI render的,但是并不是所有關聯到的document都需要更新UI,浏覽器會判斷這個document是否會從UI Render中獲益,因為浏覽器隻需要保持60Hz的重新整理率即可,而每輪event loop都是非常快的,是以沒必要每個document都Render UI。
7.5和7.6 run the resize steps/run the scroll steps不是說去執行resize和scroll。每次我們scoll的時候視口或者dom就已經立即scroll了,并把document或者dom加入到 pending scroll event targets中,而run the scroll steps具體做的則是周遊這些target,在target上觸發scroll事件。run the resize steps也是相似的,這個步驟是觸發resize事件。
7.8和7.9 後續的media query, run CSS animations and send events等等也是相似的,都是觸發事件,第10步和第11步則是執行我們熟悉的requestAnimationFrame回調和IntersectionObserver回調(第十步還是挺關鍵的,raf就是在這執行的!)。
7.12 渲染UI,關鍵就在這了。
第九步 繼續執行event loop,又去執行task,microtasks和UI render。
更新:找到一張圖,不過着重說明的是整個event loop,沒有細說UI render。