透過程式範例,熟悉 JS 執行流程的關鍵:Event Loop
前言
前陣子回 AppWorks School,擔任模擬面試官,準備非同步題目時,發現對於 Event Loop
的概念有些遺失,尤其是關於 Task(Macrotask)
與 Microtask
的執行順序和流程。加上過往也沒用文字梳理相關知識,因而藉此文整理我對 Event Loop
及相關觀念的理解。
期許閱讀完後,能大致回答下面幾個問題:
- 為何
JavaScript
可以非同步執行任務? - 什麼是
Event Loop
? - 什麼是
Task(Macrotask)
與Microtask
? Event Loop
的運作流程?- 如何避免
Event
處理成本高時,造成的卡頓問題?
最後的段落,還會提供幾題混雜 setTimeout
/ Promise
的範例,來測驗看看是否真正理解程式運作的流程(也是面試常見的考題類型XD)。
接著就先開始理解第一個觀念: Call Stack
。
在 Call Stack 中,一次執行一項任務
JavaScript
是單線程 (Single Thread) 的語言,一次僅能執行一項任務。可以結合 Call Stack(執行堆疊)
的運作來理解這件事情。
Call Stack
或稱作 Execution Stack
是一個紀錄目前程式執行狀態的空間。在 JavaScript
運行時,會將所執行到的任務,先移入到 Call Stack
中最上方,待執行完畢後,才會將該項任務移出。
透過下方這段程式碼的運行,來理解 Call Stack
:
function fn1() {
console.log('fn1');
}
function fn2() {
fn1();
console.log('fn2');
}
function fn3() {
fn2();
console.log('fn3');
}
fn3();
// 印出的順序為 fn1 -> fn2 -> fn3
程式碼運行的步驟如下:
fn3
被呼叫,移入 Stack 最上方執行。- 執行
fn3
時,遇到fn2
並呼叫之,於是將fn2
移入 Stack 最上方執行。 - 執行
fn2
時,遇到fn1
並呼叫之,於是將fn3
移入 Stack 最上方執行。 - 執行
fn1
,印出'fn1'
,fn1
執行完畢,移出 Stack。 - 執行在最上方的
fn2
,印出'fn2'
,fn2
執行完畢,移出 Stack。 - 執行在最上方的
fn3
,印出'fn3'
,fn3
執行完畢,移出 Stack。
p.s. 事實上 Call Stack
第一步該為「執行全域環境 (Global execution context)」其後才會開始堆疊每個 function
的執行環境。
利用 loupe 這套工具,能更加具體、視覺化地理解整個運作流程:
可以看到,當執行到某一行任務時,就會把該任務加入 Call Stack
中。
如果是單純的程式(例如:console.log
),就會立刻被運行完畢,並移出 Call Stack
;
但如果運行到 function
,則需要 function
內全部執行完畢 (return something or undefined) 後,才移出 Call Stack
。
有趣的是,當第一個 function
中又呼叫第二個 function
時,會優先執行「比較晚被呼叫」 的第二個 function
,待第二個執行完後,才會再回到第一個 function
繼續執行,例如:fn1
雖然是最晚被執行的,卻是最早被執行完畢 ; 而 fn3
是最早被執行的,卻是最晚被執行完畢。
從程式運作的 GIF 圖中,可看到 function
是會被堆疊上去的,而最上方的 function
,會最早執行完畢被移出 Call Stack
。
從這個 Call Stack
中,可以發現兩件事:
function
的執行順序遵循「後進先出」(LIFO, Last In First Out)的模式。- 一次只能執行在
Call Stack
中最上方的一個任務。
所以能想到:在單執行緒,一次僅能執行一個任務情況下,假設有任務耗時非常久,例如:網路請求取回資料(XMLHttpRequest
) or setTimeout(fn, 3000)
等等,將會阻塞卡死後面所有任務。
Web APIs,讓同時執行多項任務變成可能
由於 JavaScript
一次僅能做一件任務,所以如果要解決單個任務運行過久的阻塞問題,會需要「其他機制」的協助。
這個其他機制從哪裡來呢?就是從 JavaScript
的「執行環境」提供,執行環境像是 Browser
或 Node.js
等等。
在 Browser
執行環境中,為了解決阻塞問題,有提供 Web APIs
協助處理需時較久的任務,例如:XMLHttpRequest(XHR)
、setTimeout
、setInterval
等等。當遇到這些項目時,會先交給 Browser
處理,進而不會阻塞原本的執行緒,藉此讓原本同時間只能進行一項的任務,變成可以進行多項。
當 Web APIs
協助處理完負責的邏輯後,會回傳待執行的 Callback 任務,Callback 任務不會直接被放回到 Call Stack
中,而是先排入 Callback Queue
中等待。當 Call Stack
為空時,才會將 Callback Queue
中的任務,移入 Call Stack
,並開始執行。
透過 setTimeout
的範例,理解整個運作過程:
function fn1() {
console.log('fn1');
}
function fn2() {
console.log('fn2');
}
function fn3() {
console.log('fn3');
setTimeout(fn1, 1000);
// 1. 執行 setTimeout 時,先丟給 Web API 處理倒數 0.1s 的邏輯。
// 2. 倒數 0.1s 完畢,fn1 Callback 被轉移到 Queue 等待 Stack 清空。
// 3. Stack 清空後,fn1 Callback 被轉移到 Stack 中執行。
fn2();
}
fn3();
// 印出的順序為 fn3 -> fn2 -> fn1
執行步驟如下:
fn3
被呼叫,移入 Stack 中執行。- 印出
'fn3'
,接著執行到setTimeout(fn1, 1000)
。 - 將
fn1
交給 Web API 倒數 0.1s,數完後 fn1 移到 Queue 等待。(不阻塞 Stack) fn3
繼續執行,遇到fn2
,於是將fn2
移入 Stack 最上方執行。- 印出
'fn2'
,fn2
執行完畢,移出 Stack。 fn3
執行完畢,移出 Stack。- 將 Queue 中存在的
fn1
移入 Stack 中執行。 - 印出
'fn1'
,fn1
執行完畢,移出 Stack。
經由程式運作的 GIF 圖能具體看到兩個關鍵:
setTimeout(fn1, 1000)
的倒數 0.1s 的過程,並沒有阻塞其餘Call Stack
中任務的執行,因為是由Web APIs
協助進行,藉此達成多項任務的運行。setTimeout(fn1, 1000)
並非保證fn1
一定會在 0.1s 後執行,因為倒數完 0.1s 後,只是將fn1
排入Callback Queue
等待,直到Call Stack
為空時,才會再將fn1
移入其中執行。因此只能說是「保證會等待至少 0.1s 後,才執行fn1
」。
至此,已可理解為何 JavaScript
是單執行緒 ,執行時,卻可同時進行多項任務。
初探 Event Loop : 究竟是什麼?
其實前面所述之內容,已經包含 Event Loop
概念。
概觀來說,所謂的
Event Loop
,就是事件任務在Call Stack
與Callback Queue
間,非同步執行的循環機制。
這邊僅提及概觀,意思是還有細節的 Task(Macrotask)
、Microtask
尚未說明,會在後續詳細介紹。
需要特別強調,就是 JavaScript
語言本身沒有 Event Loop
,而是要搭配「執行環境」後,才會有 Event Loop
機制。像是 Browser
或 Node.js
的執行環境下,會有各自的 Event Loop
機制。
到此稍微整理重點:
Event Loop
是一種處理非同步任務執行順序的機制。Event Loop
是在 JS 執行環境中才有的機制,例如:有Browser
中的Event Loop
、Node
中的Event Loop
等。Browser Event Loop
會關聯到Call Stack
、Web APIs
、Callback Queue
間的交互作用。- 如果遇到
setTimeout
、XHR
等非同步任務,會交由Web APIs
處理,不阻塞Call Stack
。 Web APIs
處理完非同步邏輯後,會將 Callback 任務丟回Callback Queue
等待。- 當
Call Stack
為空後,就會收到 Callback 任務,並執行之。
- 如果遇到
附上這張 Browser Event Loop
的經典全貌圖,應能大致理解這張圖的意涵。
其中有個兩個特別的補充說明:
- 在
Callback Queue
中,有各種不同類型的Queue
,像是Timer Queue
、Network Queue
等等,因此可以說,在Event Loop
中,可能同時包涵多種類的Queue
。 - Web APIs 並非只有協助耗時較久的任務,還有其他許多任務,像是
DOM event(click, scroll...)
等等,因此如果遇到onClick
等事件,也會進入到Web API
+Callback Queue
+Call Stack
的循環中。
關於第二點,直接用 loupe 操作示意:
可以看到每次點擊 Click 按鈕後,事件會先交由 Web API
,接著再進入到 Callback Queue
與 Call Stack
中,運行 Event Loop
機制。
深入 Event Loop: Task(Macrotask) 與 Microtask
在 Event Loop
的運作中,事件任務其實有兩種型態,分別為 Task(Macrotask) 大型任務
與 Microtask 微任務
。
從這篇 MDN 上的文中,可以得知兩者的定義如下:
Task(Macrotask) 大型任務
A task is any JavaScript code which is scheduled to be run by the standard mechanisms such as initially starting to run a program, an event callback being run, or an interval or timeout being fired. These all get scheduled on the task queue.
包含但不限於這些任務:
- 解析 HTML
- 執行 JavaScript 主線程式 (mainline)、script
- 更換 URL
- setTimeout、setInterval => callback event(傳入的 callback fn 參數)
- 發布 Event 事件 => callback event (onClick、onScroll 等等)
- 獲取網路資源 => callback event (XHR 後的 callback fn)
p.s. Task
其實就是坊間常聽聞的 Macrotask
,本文從此開始也會用 Task
表述大型任務。
這些 Task
被觸發後,會排入特定類別的 Task Queue
中,例如:setTimeout
、setInterval
的 callback 會被排入 Timer Queue
、Event 事件的 callback 會被排入 DOM Event Queue
中。
這種不同類型的 Queue
,可以讓事件迴圈根據不同任務的類型,調整執行的優先權。例如:對於處理使用者輸入,這類強調立即反應的任務,可能就會給予較高的優先權。不過不同瀏覽器實作出來的結果都會不同,因此可以說是由瀏覽器決定何種類型會最先被執行。
意思是,不同類型的大型任務,其處理優先順序,並沒有保證誰先觸發誰就先執行,這都還是要看瀏覽器如何實作。
前面提過的 Callback Queue
其實就是指 Task Queue
,概念圖如下:
Microtask 微任務
A microtask is a short function which is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the script's execution environment.
顧名思義,microtask 就是較為小型的任務,其非同步 callback 不會被放入 Task Queue
中,而是會以 Microtask Queue
處理,包含但不限於:
- Promise then callback (executor 是同步的)
- MutationObserver callback
在此先關注實作上最常用到的 Promise
。
Microtask
通常不會有 Task
那麼耗損效能,會盡量儘早執行,執行的時機,是在一個 Task
執行之後 Call Stack
為空時進行。
還記得先前提過還有些 Event Loop
的細節的任務運作沒介紹嗎?
沒錯,就是 Microtask
的概念,加入後,概念圖如下:
至此,對於 Task
與 Microtask
有初步的理解,接下來要詳細的探討兩者在 Event Loop
中運作循環的流程。
Task(Macrotask) 與 Microtask 的運作流程
這張圖是經典的 Task
與 Microtask
在 Event Loop
中的運作圖,來看看幾個重點:
- 在一次的循環中,首先會先檢查
Task Queue
中,是否有Task
存在, - 如果有
Task
就執行之,沒有就直接進入檢查Microtask Queue
。 - 當進行完一個
Task
後,會進入檢查Microtask Queue
是否有Microtask
的階段。 - 如果有
Microtask
就執行之,並且會將Microtask Queue
中所有Microtask
執行完畢後,才會進入下個render
的階段。 - 如果有需要
render
就渲染,不需要就不執行。接著再回到第一步。
從中可以發現一個關鍵:
單次循環中,只處理一項大型任務 (Task),但是所有微任務 (Microtask) 都會處理完畢。
可由下面這段程式的執行過程來理解:
<script>
console.log('script start');
setTimeout(function () {
console.log('setTimeout callback');
}, 1000);
new Promise(function (resolve, reject) {
console.log('promise 1 resolve');
resolve();
}).then(function () {
console.log('promise 1 callback');
});
new Promise(function (resolve, reject) {
console.log('promise 2 resolve');
resolve();
}).then(function () {
console.log('promise 2 callback');
});
console.log('script end');
</script>
// 印出的順序 => 可先自行思考,接著看完運作流程會有答案。
- 有
script
的Task
存在,於是執行此Task
,開始跑script
。 - 遇到
console.log('script start')
印出script start
。 - 遇到
setTimeout
,交給Web API
非同步倒數,到數完畢後,丟到Task Queue
等待執行時機。 - 遇到
promise 1
,先同步執行executor
印出promise 1 resolve
。 resolve
完畢後,將promise 1
的callback function
丟到Microtask Queue
等待執行時機。- 遇到
promise 2
,先同步執行executor
印出promise 2 resolve
。 resolve
完畢後,將promise 2
的callback function
丟到Microtask Queue
等待執行時機。- 遇到
console.log('script end')
印出script end
。 - 此時
script
這項Task
執行完畢,進入檢查Microtask Queue
是否有待執行項目的時機。 Microtask Queue
有promise 1
與promise 2
兩個callback
,會全部執行完畢,印出promise 1 callback
與promise 2 callback
。- 此時
Microtask Queue
無項目,進入到是否render
,畫面可能更新。 - 結束一輪的循環,從頭開始新一輪循環。
- 檢查
Task Queue
,發現有先前setTimeout
的callback
,執行印出setTimeout callback
。 - 此時
setTimeout callback
這項Task
執行完畢,進入檢查Microtask Queue
是否有待執行項目的時機。 - 此時
Microtask Queue
無項目,進入到是否render
,畫面可能更新。 - 再次循環,發現已無任何任務,結束。
所以印出來的結果會是:
-
第一次循環
- script start
- promise 1 resolve
- promise 2 resolve
- script end
- promise 1 callback
- promise 2 callback
-
第二次循環
- setTimeout callback
雖然 loupe 網站中沒有呈現 Microtask Queue
,依然可視覺化地觀察程式的運作流程:
這個例子蠻重要的,如果能理解,對於 Event Loop
的運作就有大致的理解,如果尚不太懂,可以多看幾次。
如何透過 setTimeout 避免使用者操作卡頓
至少有兩種可能,會導致使用者操作卡頓:
- 某個事件任務觸發頻率過高,導致該事件篩滿
Task Queue
,其他Task
被排擠。 - 某個事件任務執行的處理成本過高,導致
Call Stack
光執行這個Task
就過久。
當然還有其他可能,但先聚焦於這兩種常見的情境。
事件任務觸發頻率過高
最常見的例子,就是 scroll
、mousemove
等事件,這兩個事件在使用者操作的情況下,瘋狂觸發的頻率極高,如果不做特別處理,可能會導致其他 Task
被卡住,無法執行,進而衍生出網頁有問題的狀況。
舉一個情境,下面這段程式中含有 onClick
與 onMousemove
兩種事件:
// 在 Loupe 左下方整個 document 區塊,滑鼠滑動會觸發 mousemove 事件
$.on('document', 'mousemove', function onMousemove() {
console.log('Mousemove Callback Execute');
});
// 在 Loupe 左下方 Click Me 按鈕,點擊後會觸發 click 事件
$.on('button', 'click', function onClick() {
console.log('Click Callback Execute');
});
來看看運行結果:
注意右下 Task Queue
區塊,會發現到,由於一開始滑動到 Click Me 按鈕時,已觸發許多的 mousemove
事件,因此之後無論怎麼點擊按鈕,onClick
事件永遠會在一大群 onMousemove
事件之後,因此 Click Callback Execute
會被 Mousemove Callback Execute
卡住無法執行。
要解決這個問題,可以利用 setTimeout
的方式處理。
當觸發 mousemove
後,並非直接觸發 Mousemove Callback Execute
邏輯,而是先觸發 setTimeout
,讓 Mousemove Callback Execute
先被排入 Web API
後,才會再被排入 Task Queue
。
// 在 Loupe 左下方整個 document 區塊,滑鼠滑動會觸發 mousemove 事件
$.on('document', 'mousemove', function onMousemove() {
// 透過 setTimeout,讓 Click Callback Execute 有機會安插在 Mousemove Callback Execute 之間執行
setTimeout(function timeoutCallback() {
console.log('Mousemove Real Callback Execute');
}, 0);
});
// 在 Loupe 左下方 Click Me 按鈕,點擊後會觸發 click 事件
$.on('button', 'click', function onClick() {
console.log('Click Callback Execute');
});
直接來看運行結果 :
注意 Task Queue
區塊,會發現 onClick
事件,有機會安插在 timeoutCallback
之間執行,意思即為 Click Callback Execute
會在 Mousemove Callback Execute
之間執行,而不會被阻塞在所有的 Mousemove Callback Execute
之後。
因此運用 setTimeout
的非同步概念,是有機會解決(或減緩)第一個問題。
p.s. 關於如何處理這種頻繁觸發的 event,延伸概念為 Debounce 和 Throttle。
事件任務處理成本過高
一般而言,瀏覽器會試著在每秒鐘,更新頁面 60 次,讓畫面流暢反應。換句話說,每 16 ms,更新畫面一次。
而可以看到在 Event Loop
的最後一個階段,正是繪製、更新畫面,因此理想上,一次循環中「 Task
以及產生所有的 Microtask
,都要在 16 ms 內完成」,如此一來,才能安全地保證畫面的運作順暢。
當一個 Task
處理的時間成本過高時,就可能導致使用者操作上卡頓的情況發生,因此如果有這種情況,可以透過拆解 Task
的大小,讓每次執行的 Task
時間成本降低。
在此將舉一個在 忍者 JavaScript 開發技巧探秘第二版 410 頁的範例程式碼,來做說明。
假定有段程式如下,會進行一個時間處理成本高的任務:
const tbody = document.querySelector('tbody');
// 在 tbody 中,1 次建立 20000 個表格列
const rowCount = 20000;
for (let i = 0; i < rowCount; i++) {
const tr = document.createElement('tr');
// 每一個表格列,建立 6 個資料欄,每個欄位包含 1 個文字節點
for (let t = 0; t < 6; i++) {
const td = document.createElement('td');
const tdText = document.createTextNode(`${i}-${t}`);
td.appendChild(tdText);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
這段程式碼,總共要建立幾十萬個 DOM 節點,並還要寫入文字,因此執行成本是很高的,很容易阻礙使用者與頁面進行互動。
因此可以利用 setTimeout
將 Task
拆小,使頁面更流暢地進行繪製或互動。
// 將 20000 切分成 4 個階段執行
const rowCount = 20000;
const devideInto = 4;
const chunkRowCount = rowCount / devideInto;
let iteration = 0;
const tbody = document.querySelector('tbody');
const generateRows = () => {
// 在 tbody 中,1 次建立 5000 個表格列
for (let i = 0; i < chunkRowCount; i++) {
const tr = document.createElement('tr');
// 每一個表格列,建立 6 個資料欄,每個欄位包含 1 個文字節點
for (let t = 0; t < 6; t++) {
const td = document.createElement('td');
const tdText = document.createTextNode(`${i}-${t}`);
td.appendChild(tdText);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
iteration++;
// 如果尚未進行完畢,就再次將 generateRows 轉到 Web API 再丟進 Task Queue
// 透過 setTimeout 讓原本執行 1 次 20000 個的 Task,轉為執行 4 次 5000 個的 Task
if (iteration < devideInto) setTimeout(generateRows, 0);
};
// 啟動 generateRows,將 generateRows 轉到 Web API 再丟進 Task Queue
setTimeout(generateRows, 0);
其執行結果概念差異如下(圖取自書中 412 頁):
最重要的差異在於原本需要長時間才完成的任務,透過 setTimeout
的切分,讓網頁有機會重新繪製,中間也可能可以安插新的任務(由瀏覽器控管),因此避免畫面長時間的卡住。
上述例子中,設定 setTimeout 延遲 0 秒進行,代表的意義並非 0 秒後就會執行,而是至少 0 秒後進行。意思上相近於通知瀏覽器,儘早執行該項 callback Task
。但同時間也賦予瀏覽器能夠在切分的 Task
與 Task
間重新調整的權利(例如:重新繪製畫面)。
總結,回答前言中的那些問題
至此應可回答前言所提到的幾個問題:
一、 為何 JavaScript
可以非同步執行任務?
因為在不同的 執行環境
會有不同的 API
協助非同步任務的運行。
舉例而言,在 Browser
執行環境中,非同步的任務像是 setTimeout
、setInterval
計時器的計時或是 XHR
網路請求等,都會由 Web APIs
提供協助進行處理。因此能讓單執行緒的 JavaScript
在 Browser
運行起來,是能同時間執行多項任務。
二、什麼是 Event Loop
?
Event Loop
是一種在 JavaScript
的執行環境中,處理非同步任務執行順序的機制。
舉例而言,在 Browser
執行環境中,非同步的任務會交由 Web APIs
進行處理,處理完後通常會有 Callback Task
這些 Task
會被丟到 Callback Queue
中等待,直到時機正確,就會被丟到 Call Stack
中執行。
而 Event Loop
就是在處理 Callback Queue
到 Call Stack
間,非同步任務執行順序的機制,其中包括 Task
與 ``Microtask` 的運作流程。
三、什麼是 Task
與 Microtask
?
在 JavaScript
中的任務分為兩種,一種是 Task
大型任務,一種是 Microtask
微任務。
Task
,是一個獨立自主的工作單位,包含著:script 運行
、setTimeout/setInterval callbacl
、DOM event callback
等等。其會被排入 Task Queue
中等待執行。
Microtask
,相較於 Task
較為小型且較不損耗效能,通常要儘早執行,藉此幫助在繪製畫面前,更新完資料狀態。其會被排入 Microtask Queue
中等待執行。
在一次 Event Loop
的循環中,最多只會處理一項 Task
,其餘在 Task Queue
繼續等待,但所有 Microtask
都會被處理完畢,Microtask Queue
會被清空。
四、Event Loop
的運作流程?
在一次的 Event Loop
運作流程中:
- 首先會先檢查
Task Queue
中,是否有Task
存在, - 如果有
Task
就執行之,沒有就直接進入檢查Microtask Queue
。 - 當進行完一個
Task
後,會進入檢查Microtask Queue
是否有Microtask
的階段。 - 如果有
Microtask
就執行之,並且會將Microtask Queue
中所有Microtask
執行完畢後,才會進入下個render
的階段。 - 如果有需要
render
就渲染,不需要就不執行。接著再回到第一步。
五、如何避免 Event
處理成本高時,造成的卡頓問題?
通常有可能是「事件觸發頻率過高」或「事件處理時間成本過高」,這兩種都有機會透過 setTimeout
或其所延伸出的 throttle
或 debounce
解決。
-
事件觸發頻率過高:
setTimeout
可以讓事件的Task
先進入Web APIs
倒數,之後才丟到Task Queue
中,在停留在Web APIs
倒數期間,其他的事件Task
就能夠先行安插進Task Queue
中執行,而不會永遠被卡在最後方。 -
事件處理時間成本過高:
setTimeout
可以讓處理成本高的單一Task
拆分成多個Task
,藉此讓瀏覽器有機會運行重繪畫面或在之間安插其他任務。
總結感想
老實說,Event Loop
還有更多內容或細節可以探討,例如直接去閱讀 HTML 規範文件,但就目前為止的觀念,應該能應付許多非同步的開發情境囉。當然拉,還有面試情境XD
最後下方的內容,就直接看些實際的程式題,試試看回答印出來的結果會是什麼吧。
建議每個題目都可先想想看,再往下滑看答案。
最後來點,promise 與 setTimeout 混雜執行的挑戰
// 印出來的英文結果為何?
function fn1() {
console.log('a');
}
function fn2() {
console.log('b');
}
function fn3() {
console.log('c');
setTimeout(fn1, 0);
new Promise(function (resolve) {
resolve('d');
}).then(function (resolve) {
console.log(resolve);
});
fn2();
}
fn3();
- 一開始運行的
mainline script
本身就是Task
,Task
開始運行。 - 觸發
fn3
開始執行,接著印出c
。 - 觸發
setTimeout
,fn1
會經由Web API
被丟到Task Queue
中。 - 觸發
promise
,console.log(resolve)
被丟到Microtask Queue
中。 - 觸發
fn2
開始執行,接著印出b
。 - 結束主線程的
Task
,開始執行Microtask
,執行console.log(resolve)
,印出d
。 - 進入下一輪 Event Loop,找到
Task Queue
中有fn1
,執行印出a
。
結果為:c
-> b
-> d
-> a
。
// 印出來的英文結果為何?
function fn1() {
console.log('a');
}
function fn2() {
setTimeout(function () {
new Promise(function (resolve) {
console.log('b');
resolve('c');
}).then(function (resolveValue) {
console.log(resolveValue);
});
}, 0);
console.log('d');
}
function fn3() {
console.log('e');
setTimeout(fn1, 0);
new Promise(function (resolve) {
console.log('f');
resolve('g');
}).then(function (resolveValue) {
console.log(resolveValue);
});
fn2();
}
fn3();
這題是上題的延伸,較需特別注意的是 Promise
的 executor
(Promise
的 callback
) 是同步執行,then
的 callback
才會是非同步執行。
結果為:e
-> f
-> d
-> g
-> a
-> b
-> c
。
setTimeout(function onTimeout() {
console.log('timeout callback');
}, 0);
Promise.resolve()
.then(function onFulfillOne() {
console.log('fulfill one');
})
.then(function onFulfillTwo() {
console.log('fulfill two');
});
function innerLog() {
console.log('inner');
}
innerLog();
console.log('outer');
這題轉換了些寫法,但概念與上面相同,值得注意的是 Microtask
(then callback
) 會被全部執行完畢,才會進入下個循環。
結果為:inner
-> outer
-> fulfill one
-> fulfill two
-> timeout callback
。
console.log('script start');
async function asyncOne() {
await asyncTwo();
console.log('async one');
}
async function asyncTwo() {
console.log('async two');
}
asyncOne();
setTimeout(function onTimeout() {
console.log('timeout callback');
}, 0);
new Promise(function (resolve) {
console.log('promise executor');
resolve();
}).then(function onFulfill() {
console.log('fulfill');
});
console.log('script end');
這題特別需要注意的是 Promise
的語法糖 aync
await
,其實蠻單純的,就是在 aync
中,如果「遇到 await
」就是同步進行(類似在 executor
),如果「沒有 await
」就是非同步進行,一樣會被丟進 Microtask Queue
中等待。
結果為: script start
-> async two
-> promise executor
-> script end
-> async one
-> fulfill
-> timeout callback
。
其中 script start
-> async two
-> promise executor
-> script end
是第一個循環中的 Task
階段,async one
-> fulfill
是第一個循環中的 Microtask
階段,timeout callback
是第二個循環中的 Task
階段。
假設上述題目還有不理解的內容,會建議將本文再看過一遍理解看看,或是直接閱讀下方參考文件的部分,或許有更適合你吸收的文章!
參考資料
- 所以說 event loop 到底是什麼玩意兒?| Philip Roberts | JSConf EU
- 我知道你懂 Event Loop,但你了解到多深?
- Day 11 [EventLoop 01] 一次弄懂 Event Loop(徹底解決此類面試問題)
- JS 原力覺醒 Day15 - Macrotask 與 MicroTask
- 忍者 JavaScript 開發技巧探秘第二版:Chapter13 搞懂事件
特別感謝
- 感謝 hikrr 在這則 issue中,提醒我「setTimeout(fn, 1000) 應該是 1s 不是 0.1s」的錯誤之處,已修正之。