摘要:本篇文章主要是對中的異步更新策略和機制的解析,需要讀者有一定的使用經驗并且熟悉掌握事件循環模型。這個結果足以說明中的更新并非同步。二是把回調函數放入一個隊列,等待適當的時機執行。通過的主動來觸發的事件,進而把回調函數作為參與事件循環。
本篇文章主要是對Vue中的DOM異步更新策略和nextTick機制的解析,需要讀者有一定的Vue使用經驗并且熟悉掌握JavaScript事件循環模型。引入:DOM的異步更新
{{test}}
export default { data () { return { test: "begin" }; }, methods () { handleClick () { this.test = "end"; console.log(this.$refs.test.innerText);//打印“begin” } } }
打印的結果是begin而不是我們設置的end。這個結果足以說明Vue中DOM的更新并非同步。
在Vue官方文檔中是這樣說明的:
可能你還沒有注意到,Vue異步執行DOM更新。只要觀察到數據變化,Vue將開啟一個隊列,并緩沖在同一事件循環中發生的所有數據改變。如果同一個watcher被多次觸發,只會被推入到隊列中一次。這種在緩沖時去除重復數據對于避免不必要的計算和DOM操作上非常重要。然后,在下一個的事件循環“tick”中,Vue刷新隊列并執行實際 (已去重的) 工作。
簡而言之,就是在一個事件循環中發生的所有數據改變都會在下一個事件循環的Tick中來觸發視圖更新,這也是一個“批處理”的過程。(注意下一個事件循環的Tick有可能是在當前的Tick微任務執行階段執行,也可能是在下一個Tick執行,主要取決于nextTick函數到底是使用Promise/MutationObserver還是setTimeout)
Watcher隊列在Watcher的源碼中,我們發現watcher的update其實是異步的。(注:sync屬性默認為false,也就是異步)
update () { /* istanbul ignore else */ if (this.lazy) { this.dirty = true } else if (this.sync) { /*同步則執行run直接渲染視圖*/ this.run() } else { /*異步推送到觀察者隊列中,下一個tick時調用。*/ queueWatcher(this) } }
queueWatcher(this)函數的代碼如下:
/*將一個觀察者對象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) } } }
這段源碼有幾個需要注意的地方:
首先需要知道的是watcher執行update的時候,默認情況下肯定是異步的,它會做以下的兩件事:
判斷has數組中是否有這個watcher的id
如果有的話是不需要把watcher加入queue中的,否則不做任何處理。
這里面的nextTick(flushSchedulerQueue)中,flushScheduleQueue函數的作用主要是執行視圖更新的操作,它會把queue中所有的watcher取出來并執行相應的視圖更新。
核心其實是nextTick函數了,下面我們具體看一下nextTick到底有什么用。
nextTicknextTick函數其實做了兩件事情,一是生成一個timerFunc,把回調作為microTask或macroTask參與到事件循環中來。二是把回調函數放入一個callbacks隊列,等待適當的時機執行。(這個時機和timerFunc不同的實現有關)
首先我們先來看它是怎么生成一個timerFunc把回調作為microTask或macroTask的。
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的實現方式有差異,如果是PromiseMutationObserver則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進行了修改,具體參考下面“版本升級”一節。
關于Vue暴露的全局nextTick繼續來看下面的這段代碼:
{{test}}
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下,這段代碼執行的順序的1、2、promise、3。
可能有同學會以為這是1、promise、2、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來執行它。
microTask與macroTask在代碼中,有這么一段注釋:
其大概的意思就是:在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來測試一下:
{{test}}
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執行順序的差異正好說明了上面的改變。
MessageChannel在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 VS setTimeout為什么要優先MessageChannel創建macroTask而不是setTimeout?
HTML5中規定setTimeout的最小時間延遲是4ms,也就是說理想環境下異步回調最快也是4ms才能觸發。
Vue使用這么多函數來模擬異步任務,其目的只有一個,就是讓回調異步且盡早調用。而MessageChannel的延遲明顯是小于setTimeout的。對比傳送門
為什么要異步更新視圖看下面的代碼:
{{test}}
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屬性來實現切換歌曲。
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/...
https://github.com/answershut...
https://juejin.im/post/5a1af8...
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/107544.html
摘要:而和的延遲明顯是小于的。因為的事件機制是通過事件隊列來調度執行,會等主進程執行空閑后進行調度,所以先回去等待所有的進程執行完成之后再去一次更新。因為首先觸發了,導致觸發了的,從而將更新操作進入的事件隊列。這種情況會導致順序成為了。 背景 我們先來看一段Vue的執行代碼: export default { data () { return { msg: 0 ...
摘要:而和的延遲明顯是小于的。因為的事件機制是通過事件隊列來調度執行,會等主進程執行空閑后進行調度,所以先回去等待所有的進程執行完成之后再去一次更新。因為首先觸發了,導致觸發了的,從而將更新操作進入的事件隊列。這種情況會導致順序成為了。 背景 我們先來看一段Vue的執行代碼: export default { data () { return { msg: 0 ...
摘要:核心的異步延遲函數,用于異步延遲調用函數優先使用原生原本支持更廣,但在的中,觸摸事件處理程序中觸發會產生嚴重錯誤的,回調被推入隊列但是隊列可能不會如期執行。 淺析 Vue 2.6 中的 nextTick 方法。 事件循環 JS 的 事件循環 和 任務隊列 其實是理解 nextTick 概念的關鍵。這個網上其實有很多優質的文章做了詳細介紹,我就簡單過過了。 以下內容適用于瀏覽器端 JS,...
摘要:我們發現默認是使用異步執行更新。優先使用,在不存在的情況下使用,這兩個方法的回調函數都會在中執行,它們會比更早執行,所以優先使用。是最后的一種備選方案,它會將回調函數加入中,等到執行。 寫在前面 因為對Vue.js很感興趣,而且平時工作的技術棧也是Vue.js,這幾個月花了些時間研究學習了一下Vue.js源碼,并做了總結與輸出。文章的原地址:https://github.com/ans...
閱讀 2751·2021-11-22 13:54
閱讀 2688·2021-10-14 09:42
閱讀 3987·2021-09-28 09:47
閱讀 2162·2021-09-03 10:28
閱讀 1203·2021-07-26 23:38
閱讀 2557·2019-08-30 15:54
閱讀 2639·2019-08-29 16:35
閱讀 1426·2019-08-29 15:42