本篇文章主要是對Vue
中的DOM
異步更新策略和nextTick
機制的解析,須要讀者有必定的Vue
使用經驗而且熟悉掌握JavaScript事件循環模型。
<template> <div> <div ref="test">{{test}}</div> <button @click="handleClick">tet</button> </div> </template>
export default { data () { return { test: 'begin' }; }, methods () { handleClick () { this.test = 'end'; console.log(this.$refs.test.innerText);//打印「begin」 } } }
打印的結果是begin
而不是咱們設置的end
。這個結果足以說明Vue
中DOM
的更新並不是同步。javascript
在Vue
官方文檔中是這樣說明的:html
可能你尚未注意到,Vue
異步執行DOM
更新。只要觀察到數據變化,Vue
將開啓一個隊列,並緩衝在同一事件循環中發生的全部數據改變。若是同一個watcher
被屢次觸發,只會被推入到隊列中一次。這種在緩衝時去除重複數據對於避免沒必要要的計算和DOM
操做上很是重要。而後,在下一個的事件循環「tick
」中,Vue
刷新隊列並執行實際 (已去重的) 工做。
簡而言之,就是在一個事件循環中發生的全部數據改變都會在下一個事件循環的Tick
中來觸發視圖更新,這也是一個「批處理」的過程。(注意下一個事件循環的Tick
有多是在當前的Tick
微任務執行階段執行,也多是在下一個Tick
執行,主要取決於nextTick
函數究竟是使用Promise/MutationObserver
仍是setTimeout
)vue
在Watcher
的源碼中,咱們發現watcher
的update
實際上是異步的。(注:sync
屬性默認爲false
,也就是異步)java
update () { /* istanbul ignore else */ if (this.lazy) { this.dirty = true } else if (this.sync) { /*同步則執行run直接渲染視圖*/ this.run() } else { /*異步推送到觀察者隊列中,下一個tick時調用。*/ queueWatcher(this) } }
queueWatcher(this)
函數的代碼以下:git
/*將一個觀察者對象push進觀察者隊列,在隊列中已經存在相同的id則該觀察者對象將被跳過,除非它是在隊列被刷新時推送*/ export function queueWatcher (watcher: Watcher) { /*獲取watcher的id*/ const id = watcher.id /*檢驗id是否存在,已經存在則直接跳過,不存在則標記哈希表has,用於下次檢驗*/ if (has[id] == null) { has[id] = true if (!flushing) { /*若是沒有flush掉,直接push到隊列中便可*/ queue.push(watcher) } else { ... } // queue the flush if (!waiting) { waiting = true nextTick(flushSchedulerQueue) } } }
這段源碼有幾個須要注意的地方:github
首先須要知道的是watcher
執行update
的時候,默認狀況下確定是異步的,它會作如下的兩件事:web
has
數組中是否有這個watcher
的id
watcher
加入queue
中的,不然不作任何處理。nextTick(flushSchedulerQueue)
中,flushScheduleQueue
函數的做用主要是執行視圖更新的操做,它會把queue
中全部的watcher
取出來並執行相應的視圖更新。nextTick
函數了,下面咱們具體看一下nextTick
到底有什麼用。nextTick
函數其實作了兩件事情,一是生成一個timerFunc
,把回調做爲microTask
或macroTask
參與到事件循環中來。二是把回調函數放入一個callbacks
隊列,等待適當的時機執行。(這個時機和timerFunc
不一樣的實現有關)數組
首先咱們先來看它是怎麼生成一個timerFunc
把回調做爲microTask
或macroTask
的。promise
if (typeof Promise !== 'undefined' && isNative(Promise)) { /*使用Promise*/ var p = Promise.resolve() var logError = err => { console.error(err) } timerFunc = () => { p.then(nextTickHandler).catch(logError) // in problematic UIWebViews, Promise.then doesn't completely break, but // it can get stuck in a weird state where callbacks are pushed into the // microTask queue but the queue isn't being flushed, until the browser // needs to do some other work, e.g. handle a timer. Therefore we can // "force" the microTask queue to be flushed by adding an empty timer. if (isIOS) setTimeout(noop) } } else if (typeof MutationObserver !== 'undefined' && ( isNative(MutationObserver) || // PhantomJS and iOS 7.x MutationObserver.toString() === '[object MutationObserverConstructor]' )) { // use MutationObserver where native Promise is not available, // e.g. PhantomJS IE11, iOS7, Android 4.4 /*新建一個textNode的DOM對象,用MutationObserver綁定該DOM並指定回調函數,在DOM變化的時候則會觸發回調,該回調會進入主線程(比任務隊列優先執行),即textNode.data = String(counter)時便會觸發回調*/ 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) } } else { // fallback to setTimeout /* istanbul ignore next */ /*使用setTimeout將回調推入任務隊列尾部*/ timerFunc = () => { setTimeout(nextTickHandler, 0) } }
值得注意的是,它會按照Promise
、MutationObserver
、setTimeout
優先級去調用傳入的回調函數。前二者會生成一個microTask
任務,然後者會生成一個macroTask
。(微任務和宏任務)瀏覽器
之因此會設置這樣的優先級,主要是考慮到瀏覽器之間的兼容性(IE
沒有內置Promise
)。另外,設置Promise
最優先是由於Promise.resolve().then
回調函數屬於一個微任務,瀏覽器在一個Tick
中執行完macroTask
後會清空當前Tick
全部的microTask
再進行UI
渲染,把DOM
更新的操做放在Tick
執行microTask
的階段來完成,相比使用setTimeout
生成的一個macroTask
會少一次UI
的渲染。
而nextTickHandler
函數,其實才是咱們真正要執行的函數。
function nextTickHandler () { pending = false /*執行全部callback*/ const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } }
這裏的callbacks
變量供nextTickHandler
消費。而前面咱們所說的nextTick
函數第二點功能中「等待適當的時機執行」,其實就是由於timerFunc
的實現方式有差別,若是是Promise\MutationObserver
則nextTickHandler
回調是一個microTask
,它會在當前Tick
的末尾來執行。若是是setTiemout
則nextTickHandler
回調是一個macroTask
,它會在下一個Tick
來執行。
還有就是callbacks
中的成員是如何被push
進來的?從源碼中咱們能夠知道,nextTick
是一個自執行的函數,一旦執行是return
了一個queueNextTick
,因此咱們在調用nextTick
其實就是在調用queueNextTick
這個函數。它的源代碼以下:
return function queueNextTick (cb?: Function, ctx?: Object) { let _resolve /*cb存到callbacks中*/ callbacks.push(() => { if (cb) { try { cb.call(ctx) } catch (e) { handleError(e, ctx, 'nextTick') } } else if (_resolve) { _resolve(ctx) } }) if (!pending) { pending = true timerFunc() } if (!cb && typeof Promise !== 'undefined') { return new Promise((resolve, reject) => { _resolve = resolve }) } }
能夠看到,一旦調用nextTick
函數時候,傳入的function
就會被存放到callbacks
閉包中,而後這個callbacks
由nextTickHandler
消費,而nextTickHandler
的執行時間又是由timerFunc
來決定。
咱們再回來看Watcher
中的一段代碼:
/*將一個觀察者對象push進觀察者隊列,在隊列中已經存在相同的id則該觀察者對象將被跳過,除非它是在隊列被刷新時推送*/ export function queueWatcher (watcher: Watcher) { /*獲取watcher的id*/ const id = watcher.id /*檢驗id是否存在,已經存在則直接跳過,不存在則標記哈希表has,用於下次檢驗*/ if (has[id] == null) { has[id] = true if (!flushing) { /*若是沒有flush掉,直接push到隊列中便可*/ queue.push(watcher) } else { ... } // queue the flush if (!waiting) { waiting = true nextTick(flushSchedulerQueue) } } }
這裏面的nextTick(flushSchedulerQueue)
中的flushSchedulerQueue
函數其實就是watcher
的視圖更新。調用的時候會把它push
到callbacks
中來異步執行。
另外,關於waiting
變量,這是很重要的一個標誌位,它保證flushSchedulerQueue
回調只容許被置入callbacks
一次。
也就是說,默認waiting
變量爲false
,執行一次後waiting
爲true
,後續的this.xxx
不會再次觸發nextTick
的執行,而是把this.xxx
相對應的watcher
推入flushSchedulerQueue
的queue
隊列中。
因此,也就是說DOM
確實是異步更新,可是具體是在下一個Tick
更新仍是在當前Tick
執行microTask
的時候更新,具體要看nextTcik
的實現方式,也就是具體跑的是Promise/MutationObserver
仍是setTimeout
。
附:nextTick
源碼帶註釋,有興趣能夠觀摩一下。
這裏面使用Promise
和setTimeout
來執行異步任務的方式都很好理解,比較巧妙的地方是利用MutationObserver
執行異步任務。MutationObserver
是H5
的新特性,它可以監聽指定範圍內的DOM
變化並執行其回調,它的回調會被看成microTask
來執行,具體參考MDN
。
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) }
能夠看到,經過借用MutationObserver
來建立一個microTask
。nextTickHandler
做爲回調傳入MutationObserver
中。
這裏面建立了一個textNode
做爲觀測的對象,當timerFunc
執行的時候,textNode.data
會發生改變,便會觸發MutatinObservers
的回調函數,而這個函數纔是咱們真正要執行的任務,它是一個microTask
。
注:2.5+
版本的Vue
對nextTick
進行了修改,具體參考下面「版本升級」一節。
nextTick
繼續來看下面的這段代碼:
<div id="example"> <div ref="test">{{test}}</div> <button @click="handleClick">tet</button> </div>
var vm = new Vue({ el: '#example', data: { test: 'begin', }, methods: { handleClick() { this.test = 'end'; console.log('1') setTimeout(() => { // macroTask console.log('3') }, 0); Promise.resolve().then(function() { //microTask console.log('promise!') }) this.$nextTick(function () { console.log('2') }) } } })
在Chrome
下,這段代碼執行的順序的一、二、promise、3
。
可能有同窗會覺得這是一、promise、二、3
,實際上是忽略了一個標誌位pending
。
咱們回到nextTick
函數return
的queueNextTick
能夠發現:
return function queueNextTick (cb?: Function, ctx?: Object) { let _resolve /*cb存到callbacks中*/ callbacks.push(() => { if (cb) { try { cb.call(ctx) } catch (e) { handleError(e, ctx, 'nextTick') } } else if (_resolve) { _resolve(ctx) } }) if (!pending) { pending = true timerFunc() } if (!cb && typeof Promise !== 'undefined') { return new Promise((resolve, reject) => { _resolve = resolve }) } }
這裏面經過對pending
的判斷來檢測是否已經有timerFunc
這個函數在事件循環的任務隊列等待被執行。若是存在的話,那麼是不會再重複執行的。
最後異步執行nextTickHandler
時又會把pending
置爲false
。
function nextTickHandler () { pending = false /*執行全部callback*/ const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } }
因此回到咱們的例子:
handleClick() { this.test = 'end'; console.log('1') setTimeout(() => { // macroTask console.log('3') }, 0); Promise.resolve().then(function() { //microTask console.log('promise!') }); this.$nextTick(function () { console.log('2') }); }
代碼中,this.test = 'end'
必然會觸發watcher
進行視圖的從新渲染,而咱們在文章的Watcher
一節中就已經有提到會調用nextTick
函數,一開始pending
變量確定就是false
,所以它會被修改成true
而且執行timerFunc
。以後執行this.$nextTick
其實仍是調用的nextTick
函數,只不過此時的pending
爲true
說明timerFunc
已經被生成,因此this.$nextTick(fn)
只是把傳入的fn
置入callbacks
之中。此時的callbacks
有兩個function
成員,一個是flushSchedulerQueue
,另一個就是this.$nextTick()
的回調。
所以,上面這段代碼中,在Chrome
下,有一個macroTask
和兩個microTask
。一個macroTask
就是setTimeout
,兩個microTask
:分別是Vue
的timerFunc
(其中前後執行flushSchedulerQueue
和function() {console.log('2')}
)、代碼中的Promise.resolve().then()
。
上面討論的nextTick
實現是2.4
版本如下的實現,2.5
以上版本對於nextTick
的內部實現進行了大量的修改。
首先是從Vue 2.5+
開始,抽出來了一個單獨的文件next-tick.js
來執行它。
在代碼中,有這麼一段註釋:
其大概的意思就是:在Vue 2.4
以前的版本中,nextTick
幾乎都是基於microTask
實現的(具體能夠看文章nextTick
一節),可是因爲microTask
的執行優先級很是高,在某些場景之下它甚至要比事件冒泡還要快,就會致使一些詭異的問題;可是若是所有都改爲macroTask
,對一些有重繪和動畫的場景也會有性能的影響。因此最終nextTick
採起的策略是默認走microTask
,對於一些DOM
的交互事件,如v-on
綁定的事件回調處理函數的處理,會強制走macroTask
。
具體作法就是,在Vue
執行綁定的DOM
事件時,默認會給回調的handler
函數調用withMacroTask
方法作一層包裝,它保證整個回調函數的執行過程當中,遇到數據狀態的改變,這些改變而致使的視圖更新(DOM
更新)的任務都會被推到macroTask
。
function add$1 (event, handler, once$$1, capture, passive) { handler = withMacroTask(handler); if (once$$1) { handler = createOnceHandler(handler, event, capture); } target$1.addEventListener( event, handler, supportsPassive ? { capture: capture, passive: passive } : capture ); } /** * Wrap a function so that if any code inside triggers state change, * the changes are queued using a Task instead of a MicroTask. */ function withMacroTask (fn) { return fn._withTask || (fn._withTask = function () { useMacroTask = true; var res = fn.apply(null, arguments); useMacroTask = false; return res }) }
而對於macroTask
的執行,Vue
優先檢測是否支持原生setImmediate
(高版本IE和Edge支持),不支持的話再去檢測是否支持原生MessageChannel
,若是還不支持的話爲setTimeout(fn, 0)
。
最後,寫一段demo來測試一下:
<div id="example"> <span>{{test}}</span> <button @click="handleClick">change</button> </div>
var vm = new Vue({ el: '#example', data: { test: 'begin', }, methods: { handleClick: function() { this.test = end; console.log('script') this.$nextTick(function () { console.log('nextTick') }) Promise.resolve().then(function () { console.log('promise') }) } } })
在Vue 2.5+
中,這段代碼的輸出順序是script
、promise
、nextTick
,而Vue 2.4
輸出script
、nextTick
、promise
。nextTick
執行順序的差別正好說明了上面的改變。
在Vue 2.4
版本之前使用的MutationObserver
來模擬異步任務。而Vue 2.5
版本之後,因爲兼容性棄用了MutationObserver
。
Vue 2.5+
版本使用了MessageChannel
來模擬macroTask
。除了IE
之外,messageChannel
的兼容性仍是比較可觀的。
const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = flushCallbacks macroTimerFunc = () => { port.postMessage(1) }
可見,新建一個MessageChannel
對象,該對象經過port1
來檢測信息,port2
發送信息。經過port2
的主動postMessage
來觸發port1
的onmessage
事件,進而把回調函數flushCallbacks
做爲macroTask
參與事件循環。
爲何要優先MessageChannel
建立macroTask
而不是setTimeout
?
HTML5
中規定setTimeout
的最小時間延遲是4ms
,也就是說理想環境下異步回調最快也是4ms
才能觸發。
Vue
使用這麼多函數來模擬異步任務,其目的只有一個,就是讓回調異步且儘早調用。而MessageChannel
的延遲明顯是小於setTimeout
的。對比傳送門
看下面的代碼:
<template> <div> <div>{{test}}</div> </div> </template>
export default { data () { return { test: 0 }; }, mounted () { for(let i = 0; i < 1000; i++) { this.test++; } } }
如今有這樣的一種狀況,mounted
的時候test
的值會被++
循環執行1000
次。 每次++
時,都會根據響應式觸發setter->Dep->Watcher->update->run
。 若是這時候沒有異步更新視圖,那麼每次++
都會直接操做DOM
更新視圖,這是很是消耗性能的。 因此Vue
實現了一個queue
隊列,在下一個Tick
(或者是當前Tick
的微任務階段)的時候會統一執行queue
中Watcher
的run
。同時,擁有相同id
的Watcher
不會被重複加入到該queue
中去,因此不會執行1000
次Watcher
的run
。最終更新視圖只會直接將test
對應的DOM
的0
變成1000
。 保證更新視圖操做DOM
的動做是在當前棧執行完之後下一個Tick
(或者是當前Tick
的微任務階段)的時候調用,大大優化了性能。
在操做DOM
節點無效的時候,就要考慮操做的實際DOM
節點是否存在,或者相應的DOM
是否被更新完畢。
好比說,在created
鉤子中涉及DOM
節點的操做確定是無效的,由於此時尚未完成相關DOM
的掛載。解決的方法就是在nextTick
函數中去處理DOM
,這樣才能保證DOM
被成功掛載而有效操做。
還有就是在數據變化以後要執行某個操做,而這個操做須要使用隨數據改變而改變的DOM
時,這個操做應該放進Vue.nextTick
。
以前在作慕課網音樂webApp
的時候關於播放器內核的開發就涉及到了這個問題。下面我把問題簡化:
如今咱們要實現一個需求是點擊按鈕變換audio
標籤的src
屬性來實現切換歌曲。
<div id="example"> <audio ref="audio" :src="url"></audio> <span ref="url"></span> <button @click="changeUrl">click me</button> </div>
const musicList = [ 'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112003137.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3', 'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112002493.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3', 'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112004168.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3' ]; var vm = new Vue({ el: '#example', data: { index: 0, url: '' }, methods: { changeUrl() { this.index = (this.index + 1) % musicList.length this.url = musicList[this.index]; this.$refs.audio.play(); } } });
毫無懸念,這樣確定是會報錯的:
Uncaught (in promise) DOMException: The element has no supported sources.
緣由就在於audio.play()
是同步的,而這個時候DOM
更新是異步的,src
屬性尚未被更新,結果播放的時候src
屬性爲空,就報錯了。
解決辦法就是在play
的操做加上this.$nextTick()
。
this.$nextTick(function() { this.$refs.audio.play(); });
參考連接
https://github.com/youngwind/...