此為 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 的執行時機