JavaScript Event Loop 機制詳解與 Vue.js 中實踐應用

王下邀月熊發表於2017-09-06

JavaScript Event Loop 機制詳解與 Vue.js 中實踐應用歸納於筆者的現代 JavaScript 開發:語法基礎與實踐技巧系列文章。本文依次介紹了函式呼叫棧、MacroTask 與 MicroTask 執行順序、淺析 Vue.js 中 nextTick 實現等內容;本文中引用的參考資料統一宣告在 JavaScript 學習與實踐資料索引

1. 事件迴圈機制詳解與實踐應用

JavaScript 是典型的單執行緒單併發語言,即表示在同一時間片內其只能執行單個任務或者部分程式碼片。換言之,我們可以認為某個同域瀏覽器上下中 JavaScript 主執行緒擁有一個函式呼叫棧以及一個任務佇列(參考 whatwg 規範);主執行緒會依次執行程式碼,當遇到函式時,會先將函式入棧,函式執行完畢後再將該函式出棧,直到所有程式碼執行完畢。當函式呼叫棧為空時,執行時即會根據事件迴圈(Event Loop)機制來從任務佇列中提取出待執行的回撥並執行,執行的過程同樣會進行函式幀的入棧出棧操作。每個執行緒有自己的事件迴圈,所以每個 Web Worker有自己的,所以它才可以獨立執行。然而,所有同屬一個 origin 的窗體都共享一個事件迴圈,所以它們可以同步交流。

Event Loop(事件迴圈)並不是 JavaScript 中獨有的,其廣泛應用於各個領域的非同步程式設計實現中;所謂的 Event Loop 即是一系列回撥函式的集合,在執行某個非同步函式時,會將其回撥壓入佇列中,JavaScript 引擎會在非同步程式碼執行完畢後開始處理其關聯的回撥。

v2-c82beca9046448eadacd301be25958b3_b

在 Web 開發中,我們常常會需要處理網路請求等相對較慢的操作,如果將這些操作全部以同步阻塞方式執行無疑會大大降低使用者介面的體驗。另一方面,我們點選某些按鈕之後的響應事件可能會導致介面重渲染,如果因為響應事件的執行而阻塞了介面的渲染,同樣會影響整體效能。實際開發中我們會採用非同步回撥來處理這些操作,這種呼叫者與響應之間的解耦保證了 JavaScript 能夠在等待非同步操作完成之前仍然能夠執行其他的程式碼。Event Loop 正是負責執行佇列中的回撥並且將其壓入到函式呼叫棧中,其基本的程式碼邏輯如下所示:

完整的瀏覽器中 JavaScript 事件迴圈機制圖解如下:v2-7200a3fa8127ff6112e2ef84153ff722_b

在 Web 瀏覽器中,任何時刻都有可能會有事件被觸發,而僅有那些設定了回撥的事件會將其相關的任務壓入到任務佇列中。回撥函式被呼叫時即會在函式呼叫棧中建立初始幀,而直到整個函式呼叫棧清空之前任何產生的任務都會被壓入到任務佇列中延後執行;順序的同步函式呼叫則會建立新的棧幀。總結而言,瀏覽器中的事件迴圈機制闡述如下:

  • 瀏覽器核心會在其它執行緒中執行非同步操作,當操作完成後,將操作結果以及事先定義的回撥函式放入 JavaScript 主執行緒的任務佇列中。
  • JavaScript 主執行緒會在執行棧清空後,讀取任務佇列,讀取到任務佇列中的函式後,將該函式入棧,一直執行直到執行棧清空,再次去讀取任務佇列,不斷迴圈。
  • 當主執行緒阻塞時,任務佇列仍然是能夠被推入任務的。這也就是為什麼當頁面的 JavaScript 程式阻塞時,我們觸發的點選等事件,會在程式恢復後依次執行。

2. 函式呼叫棧與任務佇列

變數作用域與提升一節中我們介紹過所謂執行上下文(Execution Context)的概念,在 JavaScript 程式碼執行過程中,我們可能會擁有一個全域性上下文,多個函式上下文或者塊上下文;每個函式呼叫都會創造新的上下文與區域性作用域。而這些執行上下文堆疊就形成了所謂的執行上下文棧(Execution Context Stack),便如上文介紹的 JavaScript 是單執行緒事件迴圈機制,同時刻僅會執行單個事件,而其他事件都在所謂的執行棧中排隊等待:v2-aadcf08b8e28cbe6222830662acfc8ba_b

而從 JavaScript 記憶體模型的角度,我們可以將記憶體劃分為呼叫棧(Call Stack)、堆(Heap)以及佇列(Queue)等幾個部分:v2-d9c61ba849fd0c7c139937ab62caa14c_b

其中的呼叫棧會記錄所有的函式呼叫資訊,當我們呼叫某個函式時,會將其引數與區域性變數等壓入棧中;在執行完畢後,會彈出棧首的元素。而堆則存放了大量的非結構化資料,譬如程式分配的變數與物件。佇列則包含了一系列待處理的資訊與相關聯的回撥函式,每個 JavaScript 執行時都必須包含一個任務佇列。當呼叫棧為空時,執行時會從佇列中取出某個訊息並且執行其關聯的函式(也就是建立棧幀的過程);執行時會遞迴呼叫函式並建立呼叫棧,直到函式呼叫棧全部清空再從任務佇列中取出訊息。換言之,譬如按鈕點選或者 HTTP 請求響應都會作為訊息存放在任務佇列中;需要注意的是,僅當這些事件的回撥函式存在時才會被放入任務佇列,否則會被直接忽略。

譬如對於如下的程式碼塊:

其對應的函式呼叫圖(整理自這裡)為:v2-2f0ff24c78ff508e4eae0cabe3ea6b58_b

這裡還值得一提的是,Promise.then 是非同步執行的,而建立 Promise 例項 (executor) 是同步執行的,譬如下述程式碼:

我們可以參考 Promise 規範中有關於 promise.then 的部分:

規範要求,onFulfilled 必須在執行上下文棧(Execution Context Stack) 只包含 平臺程式碼(platform code) 後才能執行。平臺程式碼指引擎,環境,Promise 實現程式碼等。實踐上來說,這個要求保證了 onFulfilled 的非同步執行(以全新的棧),在 then 被呼叫的這個事件迴圈之後。

3. MacroTask(Task) 與 MicroTask(Job)

在面試中我們常常會碰到如下的程式碼題,其主要就是考校 JavaScript 不同任務的執行先後順序:

我們在前文中已經介紹過 JavaScript 的主執行緒在遇到非同步呼叫時,這些非同步呼叫會立刻返回某個值,從而讓主執行緒不會在此處阻塞。而真正的非同步操作會由瀏覽器執行,主執行緒則會在清空當前呼叫棧後,按照先入先出的順序讀取任務佇列裡面的任務。而 JavaScript 中的任務又分為 MacroTask 與 MicroTask 兩種,在 ES2015 中 MacroTask 即指 Task,而 MicroTask 則是指代 Job。典型的 MacroTask 包含了 setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI rendering 等,MicroTask 包含了 process.nextTick, Promises, Object.observe, MutationObserver 等。 二者的關係可以圖示如下:v2-a24e582fda37065755f10bd4dc5a3dc0_b

參考 whatwg 規範 中的描述:一個事件迴圈(Event Loop)會有一個或多個任務佇列(Task Queue,又稱 Task Source),這裡的 Task Queue 就是 MacroTask Queue,而 Event Loop 僅有一個 MicroTask Queue。每個 Task Queue 都保證自己按照回撥入隊的順序依次執行,所以瀏覽器可以從內部到JS/DOM,保證動作按序發生。而在 Task 的執行之間則會清空已有的 MicroTask 佇列,在 MacroTask 或者 MicroTask 中產生的 MicroTask 同樣會被壓入到 MicroTask 佇列中並執行。參考如下程式碼:

上述程式碼中首個 TaskQueue 即為 foo(),foo() 又呼叫了 bar() 構建了新的 TaskQueue,bar() 呼叫之後 foo() 又產生了 MicroTask 並被壓入了唯一的 MicroTask 佇列。我們最後再總計下 JavaScript MacroTask 與 MicroTask 的執行順序,當執行棧(call stack)為空的時候,開始依次執行:

《這一段在我筆記裡也放了好久,無法確定是否拷貝的。。。如果有哪位發現請及時告知。。。(*ฅ́˘ฅ̀*)♡》

  1. 把最早的任務(task A)放入任務佇列
  2. 如果 task A 為null (那任務佇列就是空),直接跳到第6步
  3. 將 currently running task 設定為 task A
  4. 執行 task A (也就是執行回撥函式)
  5. 將 currently running task 設定為 null 並移出 task A
  6. 執行 microtask 佇列
  • a: 在 microtask 中選出最早的任務 task X
  • b: 如果 task X 為null (那 microtask 佇列就是空),直接跳到 g
  • c: 將 currently running task 設定為 task X
  • d: 執行 task X
  • e: 將 currently running task 設定為 null 並移出 task X
  • f: 在 microtask 中選出最早的任務 , 跳到 b
  • g: 結束 microtask 佇列
  1. 跳到第一步

4. 淺析 Vue.js 中 nextTick 的實現

在 Vue.js 中,其會非同步執行 DOM 更新;當觀察到資料變化時,Vue 將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料改變。如果同一個 watcher 被多次觸發,只會一次推入到佇列中。這種在緩衝時去除重複資料對於避免不必要的計算和 DOM 操作上非常重要。然後,在下一個的事件迴圈“tick”中,Vue 重新整理佇列並執行實際(已去重的)工作。Vue 在內部嘗試對非同步佇列使用原生的 Promise.then 和 MutationObserver,如果執行環境不支援,會採用 setTimeout(fn, 0) 代替。

《因為本人失誤,原來此處內容拷貝了 zhihu.com/question/5536 這個回答,造成了侵權,深表歉意,已經刪除,後續我會在 github 連結上重寫本段》

而當我們希望在資料更新之後執行某些 DOM 操作,就需要使用 nextTick 函式來新增回撥:

在元件內使用 vm.$nextTick() 例項方法特別方便,因為它不需要全域性 Vue ,並且回撥函式中的 this 將自動繫結到當前的 Vue 例項上:

src/core/util/env

5. 延伸閱讀

相關文章