你不得不知的Event Loop

mengera888發表於2018-03-14

前言

眾所周知,JavaScript是一門單執行緒語言,雖然在html5中提出了Web-Worker,但這並未改變JavaScript是單執行緒這一核心。可看HTML規範中的這段話:

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.

為了協調事件、使用者互動、指令碼、UI 渲染和網路處理等行為,使用者引擎必須使用event loops。Event Loop包含兩類:一類是基於Browsing Context,一種是基於Worker,二者是獨立執行的。 下面本文用一個例子,著重講解下基於Browsing Context的事件迴圈機制。

來看下面這段JavaScript程式碼:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');
複製程式碼

先猜測一下這段程式碼的輸出順序是什麼,再去瀏覽器控制檯輸入一下,看看實際輸出的順序和你猜測出的順序是否一致,如果一致,那就說明,你對JavaScript的事件迴圈機制還是有一定了解的,繼續往下看可以鞏固下你的知識;而如果實際輸出的順序和你的猜測不一致,那麼本文下面的部分會為你答疑解惑。

任務佇列

所有的任務可以分為同步任務和非同步任務,同步任務,顧名思義,就是立即執行的任務,同步任務一般會直接進入到主執行緒中執行;而非同步任務,就是非同步執行的任務,比如ajax網路請求,setTimeout定時函式等都屬於非同步任務,非同步任務會通過任務佇列(Event Queue)的機制來進行協調。具體的可以用下面的圖來大致說明一下:

alt Event Queue示意圖

同步和非同步任務分別進入不同的執行環境,同步的進入主執行緒,即主執行棧,非同步的進入Event Queue。主執行緒內的任務執行完畢為空,會去Event Queue讀取對應的任務,推入主執行緒執行。 上述過程的不斷重複就是我們說的Event Loop(事件迴圈)。

在事件迴圈中,每進行一次迴圈操作稱為tick,通過閱讀規範可知,每一次tick的任務處理模型是比較複雜的,其關鍵的步驟可以總結如下:

  1. 在此次tick中選擇最先進入佇列的任務(oldest task),如果有則執行(一次)
  2. 檢查是否存在Microtasks,如果存在則不停地執行,直至清空Microtask Queue
  3. 更新render
  4. 主執行緒重複執行上述步驟

可以用一張圖來說明下流程:

alt Event Queue示意圖

這裡相信有人會想問,什麼是microtasks?規範中規定,task分為兩大類, 分別是Macro Task (巨集任務)和Micro Task(微任務), 並且每個巨集任務結束後, 都要清空所有的微任務,這裡的Macro Task也是我們常說的task,有些文章並沒有對其做區分,後面文章中所提及的task皆看做巨集任務(macro task)。

(macro)task主要包含:script(整體程式碼)、setTimeout、setInterval、I/O、UI互動事件、setImmediate(Node.js 環境)

microtask主要包含:Promise、MutaionObserver、process.nextTick(Node.js 環境)

setTimeout/Promise等API便是任務源,而進入任務佇列的是由他們指定的具體執行任務。來自不同任務源的任務會進入到不同的任務佇列。其中setTimeout與setInterval是同源的。

分析示例程式碼

千言萬語,不如就著例子講來的清楚。下面我們可以按照規範,一步步執行解析下上面的例子,先貼一下例子程式碼(免得你往上翻)。

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');
複製程式碼
  1. 整體script作為第一個巨集任務進入主執行緒,遇到console.log,輸出script start
  2. 遇到setTimeout,其回撥函式被分發到巨集任務Event Queue中
  3. 遇到Promise,其then函式被分到到微任務Event Queue中,記為then1,之後又遇到了then函式,將其分到微任務Event Queue中,記為then2
  4. 遇到console.log,輸出script end

至此,Event Queue中存在三個任務,如下表:

巨集任務 微任務
setTimeout then1 then2
  1. 執行微任務,首先執行then1,輸出promise1,然後執行then2,輸出promise2,這樣就清空了所有微任務
  2. 執行setTimeout任務,輸出setTimeout 至此,輸出的順序是:script start, script end, promise1, promise2, setTimeout

so,你猜對了嗎?

看看你掌握了沒

再來一個題目,來做個練習:

console.log('script start');

setTimeout(function() {
  console.log('timeout1');
}, 10);

new Promise(resolve => {
    console.log('promise1');
    resolve();
    setTimeout(() => console.log('timeout2'), 10);
}).then(function() {
    console.log('then1')
})

console.log('script end');
複製程式碼

這個題目就稍微有點複雜了,我們再分析下:

首先,事件迴圈從巨集任務(macrotask)佇列開始,最初始,巨集任務佇列中,只有一個script(整體程式碼)任務;當遇到任務源(task source)時,則會先分發任務到對應的任務佇列中去。所以,就和上面例子類似,首先遇到了console.log,輸出script start; 接著往下走,遇到setTimeout任務源,將其分發到任務佇列中去,記為timeout1; 接著遇到promise,new promise中的程式碼立即執行,輸出promise1,然後執行resolve,遇到setTimeout,將其分發到任務佇列中去,記為timemout2,將其then分發到微任務佇列中去,記為then1; 接著遇到console.log程式碼,直接輸出script end 接著檢查微任務佇列,發現有個then1微任務,執行,輸出then1 再檢查微任務佇列,發現已經清空,則開始檢查巨集任務佇列,執行timeout1,輸出timeout1; 接著執行timeout2,輸出timeout2 至此,所有的都佇列都已清空,執行完畢。其輸出的順序依次是:script start, promise1, script end, then1, timeout1, timeout2

用流程圖看更清晰:

alt Event Queue示意圖

總結

有個小tip:從規範來看,microtask優先於task執行,所以如果有需要優先執行的邏輯,放入microtask佇列會比task更早的被執行。

最後的最後,記住,JavaScript是一門單執行緒語言。

參考文獻

這一次,徹底弄懂 JavaScript 執行機制 Tasks, microtasks, queues and schedules 從一道題淺說 JavaScript 的事件迴圈

相關文章