從 Vue-nextTick 探究事件循環中的線程協作機制

一、背景

對 vue 裏的 nextTick() 方法理解不清晰,會導致 api 代碼濫用的現象,我查看了 vue 官網的說明:

Vue.nextTick() 用於在下次 DOM 更新循環結束之後執行延遲迴調。

問題來了,怎麼確定下次 DOM 更新循環結束的時間點呢?

二、Vue.nextTick 源碼探索

先看 Vue.nextTick() 源碼 [1] 的實現方式。next-tick.js 源碼主要包含 callbacks、pending、timerFunc、flushCallbacks 四個變量:

其中最關鍵的是 timerFunc 對於觸發 flushCallbacks 的方法選擇,這裏貼出源碼:

let timerFunc
// 1、優先採用原生Promise觸發flushCallbacks
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    // 在有問題的webView中添加空定時器強制刷新微任務隊列
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
}
// 2、在promise不可用時採用原生MutationObserver生成一個Dom元素觸發flushCallbacks
 else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
}
// 3、採用原生setImmediate來降級處理
 else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
}
// 4、採用setTimeout兜底處理
 else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

上面的這段核心代碼,優先採用了 Promise 保存回調,然後依次採用了 MutationObserver、setImmediate、setTimeout 兜底。下面是 Vue.nextTick 方法的流程圖:

timerFunc 這裏的初始化方式利用了在不同環境下采用 JavaScript 的事件循環(eventLoop)機制做了觸發回調的優雅降級。

三、事件循環機制

JavaScript 運行時,按任務環境不同劃分出了宏任務(macrotask)和微任務(microtask)。宏任務是由宿主環境發起的,宿主環境有瀏覽器、Node,常見的添加宏任務的方法爲 setTimeout、Ajax、I/O、UI 交互事件等;微任務是由語言本身自帶的,常見的添加方法有 Promise.then、MutationObserver 等。

事件循環的執行機制爲:

1、當 js 執行棧中的所有任務的執行過程中若遇到微任務或宏任務,則將其添加到對應隊列中;

2、執行棧中任務順序執行完畢後去檢查微任務隊列是否爲空,不爲空則把任務按先入先出順序依次拉取微任務隊列中方法到 js 執行棧中運行;

3、執行棧以及微任務隊列都清空後去檢查宏任務隊列是否爲空,不爲空把任務按先入先出順序加入當前執行棧;

4、當執行棧執行完畢後,檢查微任務隊列是否爲空,然後檢查宏任務隊列是否爲空,以此循環至微任務隊列、宏任務隊列同時爲空。

四、事件循環中的 Dom 渲染時機

結合上面 nextTick 的源碼可以看出,Vue.nextTick 將回調方法優先使用 Promise.then 放入了當前執行棧的微任務隊列,採用了 setTimeout 放入宏任務隊列兜底。那可以得出微任務是在 dom 更新循環結束後觸發的,爲什麼有這樣的規定呢,**dom 樹更新後什麼時候渲染呢?**帶着這個問題,我做了一個小測試。

document.body.style.background = 'blue';
console.log(1)
setTimeout(() => {
   document.body.style.background = 'yellow';
   console.log(2)
},0)
Promise.resolve().then(()=>{
   document.body.style.background = 'red';
   console.log(3)
})
console.log(4);

上面這段代碼的輸出結果是 1,4,3,2,頁面的變化是由紅色轉黃色,沒有渲染爲藍色,以及沒有由藍轉紅的過程,可以證明渲染是在微任務之後,宏任務之前執行的

然後我在每次打印時加上了對當前 dom 樹的查詢,代碼如下:

document.body.style.background = 'blue';
console.log(1,document.body.style.background)
Promise.resolve().then(()=>{
    document.body.style.background = 'red';
    console.log(2,document.body.style.background)
})
setTimeout(() => {
    document.body.style.background = 'yellow';
    console.log(3,document.body.style.background)
},0)
console.log(4,document.body.style.background);

可以看到 Dom 樹的變化是實時生效的,但對於 Dom 樹的渲染是延遲生效的,並且晚於微任務,早於宏任務。這樣不用頻繁的觸發渲染,而把一輪微任務隊列中 Dom 樹的變化收集起來統一渲染也節省了渲染性能消耗

五、事件循環中的線程協作

主要負責 Dom 渲染部分的是與 js 線程同處於瀏覽器中渲染進程下的 GUI 渲染線程,下面結合瀏覽器運行機制來描述一下事件循環過程中的線程協作機制,本文大部分瀏覽器相關知識來源於李兵的《瀏覽器工作原理與實踐》這門課

首先,瀏覽器是多進程運行的,如常用的 Chrome 瀏覽器程序運行時包括:1 個瀏覽器主進程、1 個 GPU 進程、1 個網絡進程、多個渲染進程、多個插件進程。

其中,每個標籤頁配置了一個單獨的渲染進程,而渲染進程中包含 js 引擎線程、事件觸發線程、GUI 渲染線程、異步 HTTP 請求線程、定時器觸發線程。而事件循環就是通過渲染進程中各線程的協作,從而讓單線程的 JS 能夠執行異步任務

1、JavaScript 引擎線程,處理頁面與用戶的交互,以及操作 DOM 樹、CSS 樣式樹來給用戶呈現一份動態而豐富的交互體驗和服務器邏輯的交互處理,與 GUI 渲染引擎互斥。

2、GUI 渲染線程,負責渲染瀏覽器界面, 與 JavaScript 引擎線程互斥,當界面需要重繪(Repaint)或由於某種操作引發迴流(reflow)時,該線程就會執行。

3、事件觸發線程,事件觸發時負責把事件添加到待處理隊列的隊尾,等待 JS 引擎的處理。事件類型包括定時任務、AJAX 異步請求、DOM 事件如鼠標點擊等,但由於 JS 的單線程關係所有這些事件都得排隊等待 JS 引擎處理。

4、定時器線程,負責計時並觸發定時。舉例爲 SetTimeout 的實現過程是在使用 SetTimeout 設置定時任務後,會將回調添加在延時執行隊列中,然後用定時器開始計時,計時結束後將延時執行隊列中的回調任務移出到 js 執行隊列中,按 js 執行隊列順序執行。

5、異步 http 請求線程,在 XMLHttpRequest 在連接後是通過瀏覽器新開一個線程請求,將檢測到狀態變更時,如果設置有回調函數,異步線程就產生狀態變更事件放到 JS 引擎的宏任務隊列中等待處理。

將渲染進程中各線程功能和事件循環相結合,可以得到下圖:

六、總結

六、最佳實踐

1、對於 vue 實例跟 dom 雙向綁定的數據更新,需要在 nexttick 的回調後獲取更新後的 dom 元素。

// vue官網api用法說明
// 修改數據
vm.msg = 'Hello'
// DOM 還沒有更新
Vue.nextTick(function () {
  // DOM 更新了
})

這裏在修改 vue 實例的數據後沒有立即更新 dom,這裏是由於 vue 數據的雙向綁定機制導致的,在修改 vm.msg 後會按續觸發 setter()[Object.defineProperty] =》 notify() =》 update() =》 queueWatcher() =》 nextTick。

可以看到修改數據後最終是通過 nextTick 添加了微任務去添加 dom 更新事件,所以必須使用 vue.nextTick 才能獲取到更新後的 dom 元素,並且這裏是還沒有渲染的。這裏就不詳細講 vue 的雙向綁定機制了,感興趣的同學可以去閱讀源碼,上面提到的方法都標記了源文件地址。

2、對於非 vue 雙向綁定的 dom 更新,在處理 dom 更新的語句後面可直接操作更新後的 dom 元素。

3、操作 dom 的多次更新(無論是否使用 vue 雙向綁定)應該放在同一輪事件循環的當前 js 執行棧或微任務中,僅需調用一次渲染線程更新 dom,避免放在下一輪宏任務中。這樣處理可將多次處理 dom 優化爲一次渲染,避免重複渲染,減少性能損失。

注:[1] https://github.com/vuejs/vue/blob/dev/src/core/util/next-tick.js

作者簡介

楊亮,騰訊前端開發工程師,負責 IEG 健康系統相關前端業務,畢業於南京大學軟件學院。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/1FmayxLJX6zQLPcrdTtAgQ