Vue非同步更新 - nextTick為什麼要microtask優先?

蒼耳mtjj發表於2019-08-18

Vue原始碼解讀系列篇

一、Vue非同步更新佇列

(1)Vue非同步更新

相信大家都知道,Vue可以做到資料驅動檢視更新,比如我們就簡單寫一個事件如下:

methods: {
    tap() {
        for (let i = 0; i < 10; i++) {
            this.a = i;
        }
        this.b = 666;
    },
},
複製程式碼

當我們觸發這個事件,檢視中的ab肯定會發現一些變化。

那我們思考一下,Vue是如何管理這個變化的過程?比如上面這個案例,a被迴圈了10次,那Vue會去渲染檢視10次嗎?顯然不會,畢竟這個效能代價非常大。畢竟我們只需要a最後一次的賦值。

實際上Vue是非同步更新檢視的,也就是說會等這個tap事件執行完,檢查發現只需要更新ab,然後再一次性更新,避免無效的更新。

Vue官方文件也印證了我們的想法,如下:

Vue 在更新 DOM 時是非同步執行的。只要偵聽到資料變化,Vue 將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料變更。如果同一個 watcher 被多次觸發,只會被推入到佇列中一次。這種在緩衝時去除重複資料對於避免不必要的計算和 DOM 操作是非常重要的。

以上可以詳見Vue官方文件 - 非同步更新佇列

(2)派發更新中的非同步佇列

Vue通知檢視更新,是通過dep.notify,相信你讀到這裡肯定是瞭解Vue響應式原理的。那麼來檢視下dep.notify都做了什麼?耐心點,離真相越來越近了。

// dep.js
notify () {
    const subs = this.subs.slice();
    // 迴圈通知所有watcher更新
    for (let i = 0, l = subs.length; i < l; i++) {
        subs[i].update()
    }
}
複製程式碼

首先迴圈通知所有watcher更新,我們發現watcher執行了update方法。


// watcher.js
update () {
    if (this.lazy) {
        // 如果是計算屬性
        this.dirty = true
    } else if (this.sync) {
        // 如果要同步更新
        this.run()
    } else {
        // 進入更新佇列
        queueWatcher(this)
    }
}
複製程式碼

update方法首先判斷是不是計算屬性或開發者定義了同步更新,這些我們先不看,直接進入正題,進入非同步佇列方法queueWatcher

那麼再來看下queueWatcher,我省略了絕大部分程式碼,畢竟程式碼是枯燥的,為了方便大家理解,都是一些思路性程式碼。

export function queueWatcher (watcher: Watcher) {
    // 獲取watcherid
    const id = watcher.id
    if (has[id] == null) {
        // 保證只有一個watcher,避免重複
        has[id] = true
        
        // 推入等待執行的佇列
        queue.push(watcher)
      
        // ...省略細節程式碼
    }
    // 將所有更新動作放入nextTick中,推入到非同步佇列
    nextTick(flushSchedulerQueue)
}

function flushSchedulerQueue () {
    for (index = 0; index < queue.length; index++) {
        watcher = queue[index]
        watcher.run()
        // ...省略細節程式碼
    }
}
複製程式碼

通過上述程式碼可以看出我們將所有要更新的watcher佇列放入了nextTick中。 nextTick的官方解讀為:

在下次 DOM 更新迴圈結束之後執行延遲迴調。在修改資料之後立即使用這個方法,獲取更新後的 DOM。

這裡的描述其實限制了nextTick的技能,實際上nextTick就是一個非同步方法,也許和你使用的setTimeout沒有太大的區別。

那來看下nextTick的原始碼究竟做了什麼?

二、nextTick原始碼淺析

nextTick原始碼很少,翻來翻去沒幾行,但是我也不打算展開講,因為看程式碼真的很枯燥。 下面的程式碼只有幾行,其實你可以選擇跳過看結論。

// timerFunc就是nextTick傳進來的回撥等... 細節不展開
if (typeof Promise !== 'undefined' && isNative(Promise)) {
    const p = Promise.resolve()
    timerFunc = () => {
        p.then(flushCallbacks)
    }
    isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
    // 當原生 Promise 不可用時,timerFunc 使用原生 MutationObserver
    // MutationObserver不要在意它的功能,其實就是個可以達到微任務效果的備胎
)) {
    timerFunc = () => {
        // 使用 MutationObserver
    }
    isUsingMicroTask = true

} else if (typeof setImmediate !== 'undefined' &&  isNative(setImmediate)) {
    // 如果原生 setImmediate 可用,timerFunc 使用原生 setImmediate
    timerFunc = () => {
        setImmediate(flushCallbacks)
    }
} else {
    // 最後的倔強,timerFunc 使用 setTimeout
    timerFunc = () => {
        setTimeout(flushCallbacks, 0)
    }
}

複製程式碼

總結就是Promise > MutationObserver > setImmediate > setTimeout

果然和setTimeout沒有太大的區別~

再總結一下優先順序:microtask (jobs) 優先。

nextTick原始碼為什麼要microtask優先?再理解這個問題答案之前,我們還要複習eventLoop知識。

三、eventLoop

(1)任務佇列

用2張圖帶大家簡單回憶一下,但是就不細講了,大家可以自行查詢資料。

eventLoop

  • 我們的同步任務在主執行緒上執行會形成一個執行棧。
  • 如果碰到非同步任務,比如setTimeoutonClick等等的一些操作,我們會將他的執行結果放入佇列,此期間主執行緒不阻塞。
  • 等到主執行緒中的所有同步任務執行完畢,就會通過event loop在佇列裡面從頭開始取,在執行棧中執行 event loop永遠不會斷。
  • 以上的這一整個流程就是Event Loop(事件迴圈機制)。

(2)微任務、巨集任務

eventLoop、微任務、巨集任務

  • 每次執行棧的同步任務執行完畢,就會去任務佇列中取出完成的非同步任務,但是佇列中又分為微任務microtask和巨集任務tasks佇列
  • 等到把所有的微任務microtask都執行完畢,注意是所有的,他才會從巨集任務tasks佇列中取事件。
  • 等到把佇列中的事件取出一個,放入執行棧執行完成,就算一次迴圈結束。
  • 之後event loop還會繼續迴圈,他會再去微任務microtask執行所有的任務,然後再從巨集任務tasks佇列裡面取一個,如此反覆迴圈。

四、nextTick為什麼要儘可能的microtask優先?

簡單的回憶了eventLoop、微任務、巨集任務後,我們還要再丟擲一個結論。

執行順序

我們發現,原來在執行微任務之後還會執行渲染操作!!!(當然並不是每次都會,但至少順序我們是可以肯定的)。

  • 在一輪event loop中多次修改同一dom,只有最後一次會進行繪製。
  • 渲染更新(Update the rendering)會在event loop中的tasksmicrotasks完成後進行,但並不是每輪event loop都會更新渲染,這取決於是否修改了dom和瀏覽器覺得是否有必要在此時立即將新狀態呈現給使用者。如果在一幀的時間內(時間並不確定,因為瀏覽器每秒的幀數總在波動,16.7ms只是估算並不準確)修改了多處dom,瀏覽器可能將變動積攢起來,只進行一次繪製,這是合理的。
  • 如果希望在每輪event loop都即時呈現變動,可以使用requestAnimationFrame

這裡我丟擲結論,原因和理論知識可以看這篇文章 從event loop規範探究javaScript非同步及瀏覽器更新渲染時機 ,這位大神寫的很好。

不知道大家有沒有猜出【nextTick為什麼要儘可能的microtask優先?】 這裡又盜了大神的圖,event loop的大致迴圈過程:

event loops

假設現在執行到某個 task,我們對批量的dom進行非同步修改,我們將此任務插進tasks,也就是用巨集任務實現。

task

顯而易見,這種情況下如果task裡排隊的佇列比較多,同時遇到多次的微任務佇列執行完。那很有可能觸發多次瀏覽器渲染,但是依舊沒有執行我們真正的修改dom任務。

這種情況,不僅會延遲檢視更新,帶來效能問題。還有可能導致檢視上一些詭異的問題。 因此,此任務插進microtasks

Vue非同步更新 - nextTick為什麼要microtask優先?
可以看到如果task佇列如果有大量的任務等待執行時,將dom的變動作為microtasks而不是巨集任務(task)能更快的將變化呈現給使用者。

總結

之所以講這篇文章,是因為在最近在讀Vue的原始碼,我看的是2.6.10, 發現nextTick和2.5版本的實現不太一樣。大家可以看下這位大佬的文章 Vue.js 升級踩坑小記

文章內容基本都是在其他大佬的基礎上進行的理解,講錯的大家可以批評指正~

參考文章

文章有一些結論直接參考其他文章,自己實在是懶得寫啦~~

侵權刪 ^^

相關文章