此為 event loop 系列文章 - 第 4 篇:
- Javascript 中的 event loop 及瀏覽器渲染機制
- 從程式碼角度來看 event loop
- 使用原生的 queueMicrotask 處理微任務
- Vue.nextTick() 中的 event loop
前言
這篇文章想藉由閱讀 Vue.nextTick() 的源碼來看 event loop 的使用
Vue.nextTick 的使用方式
官方文件寫明 Vue.nextTick() 是拿來等待下一次 DOM 更新的方法,因為 Vue 在每次響應式數據改變後是異步去更新 DOM,所以如果在數據改變後,馬上獲取 DOM 的資料會是舊的,這時就需要用到 Vue.nextTick() 獲取更新後的 DOM
1 | <script setup> |
Vue.nextTick 的源碼分析
目前最新版(2024/02) 的 Vue.nextTick 源碼 如下(移除掉一些註解方便整體閱讀):
1 | import { noop } from 'shared/util' |
nextTick 函式
第 57-79 行就是我們實際在用的 nextTick 函式,傳入的參數有兩個,cb 是 DOM 更新後才執行的 callback,ctx 為了傳遞 this 的指向
第 59-69 行將傳入的 cb 放入 callbacks 的佇列裡,等待後續執行
第 70-73 行用一個 pending 的變數控制,讓多次呼叫 nextTick 函式時,timerFunc 可以在同一次的 更新時機(tick) 中執行所有的 callbacks
什麼是更新時機(tick)?
在 Vue 中定義了一個叫做 tick 的專有名詞,指的是某一個特定的時間下 Vue 用來執行響應式資料改變、DOM 更新等邏輯,tick 執行的時機會根據之後將提到的 timerFunc 函式判斷是要用 event loop 中的 宏任務 (macrotask) 或是 微任務 (microtask) 方式執行。
第 74-78 行讓 nextTick 函式可以單純回傳 Promise,如此一來不用傳 cb 也可以使用
- 使用 callback 方式
1 | console.log(document.getElementById('counter').textContent) // 更新 DOM 前 |
- 不使用 callback 方式
1 | console.log(document.getElementById('counter').textContent) // 更新 DOM 前 |
callbacks & flushCallbacks - 負責執行 callback
在 nextTick 中丟入的 cb 參數,會放入 callbacks 佇列裡,等待下一次適當的 更新時機(nextTick) 後,才真正執行 cb 函式。而真正執行 cb 的地方就是 flushCallbacks 函式
1 | const callbacks = [] |
timerFunc - 決定用哪種 event loop 方式決定更新時機(nextTick)
什麼是下一次適當的 更新時機(nextTick) 呢?在 Vue 中使用了 timerFunc 這個變數去做判斷,以下這段程式碼會根據不同的瀏覽器去做兼容控制,我們可以看到 timerFunc 的優先順序為: Promise => MutationObserver => setImmediate => setTimeout,也就是說 nextTick 中傳入的 callback 會優先以 微任務 (microtask) 的方式執行,如果真的不行最後才會降級成 setTimeout 的 宏任務 (macrotask)
1 | let timerFunc |
為什麼優先以微任務方式執行?
在之前系列文提到每一輪的 event loop 會挑出一個 宏任務 (macrotask) 執行,接著執行 微任務佇列 (microtask queue) 中的所有 微任務 (microtask) ,然後再進行 UI 的畫面渲染。
在 Vue 中的響應式資料改變,都有可能會修改 DOM 改變畫面,而畫面的改變當然希望是越即時越好,這部分如果使用 setTimeout 這種 宏任務 (macrotask) 執行 Vue 的更新邏輯,每一幀渲染前都只能執行一個 宏任務 (macrotask) ,這樣一定很容易遇到畫面不即時的問題,所以 Vue 在處理資料更新以及 DOM 的修改才優先以 微任務 (microtask) 方式執行,這樣在當輪的瀏覽器渲染畫面前資料都已經更新了。
nextTick 問題解析
1. pending 變數的作用?
1 | const callbacks = [] |
pending 的初始值為 false,在一開始使用 nextTick 的時候會設為 true,然後在 flushCallbacks 中 (callbacks 佇列全部執行前) 會設為 false,這樣可以讓多次 nextTick 中加入的 cb,在同一次的 更新時機(nextTick) 中一次全部執行完
範例:
1 | function cb1() {} |
由於有 pending 變數的控制,第 5 行執行後 callbacks = [cb1, cb2],但 timerFunc() 一樣只會執行一次。接著第 10 行將 flushCallbacks 加入 微任務佇列 (microtask queue) ,等待之後從 微任務佇列 (microtask queue) 挑出 flushCallbacks 時,cb1, cb2 就可以在同一次的 更新時機(nextTick) 中一併執行
2. 為什麼需要複製 callbacks 陣列?
1 | function flushCallbacks () { |
在實際執行 callback 前會先將整個 callbacks 陣列複製,原因是當 nextTick 中的 callback 使用到巢狀的 nextTick 時,需要讓父層與子層的 callback 在不同次的 更新時機(nextTick) 執行
範例:
1 | nextTick(function cb1() { |
cb1 的子層巢狀用到了 cb2,如果不複製 callbacks 陣列的話,cb2 也會被加入到當輪要執行的 callbacks 陣列裡,導致 cb1 與 cb2 都在同一次的 更新時機(nextTick) 中執行,而複製了 callbacks 陣列後,flushCallbacks 會將這一次該執行完的 callbacks 都跑完,而 cb2 被加入到的是下一次的 callbacks 陣列,也就是在下一次的 更新時機(nextTick) 才會執行
參考資料
- vue中$nextTick的实现原理
- 面试官:Vue中的$nextTick有什么作用?
- Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心!
- Vue异步更新机制以及$nextTick原理
- MutationObserver characterData usage without childList
講解 MutationObserver 中的 characterData 的作用
- Understanding setImmediate()
- The Node.js Event Loop, Timers, and process.nextTick()
- Event Loop 運行機制解析 - Node.js 篇
瀏覽器中的 setImmediate 只有已廢棄的 IE 支援,基本上現在 setImmediate 只會在 nodejs 中被使用,以上三篇文章簡介了 Node.js 的 event loop,以及 setImmediate 的執行時機