JavaScript的執行流,無論是瀏覽器還是Node.js,都是基於 事件循環 。
理解事件循環能夠讓我們寫出更可靠的高性能代碼。
讓我們先介紹一下事件循環的原理,然后再來看看實際應用。
事件循環(Event Loop)
事件循環的概念非常簡單。它就是一個無止境的循環,JavaScript引擎等待任務(tasks)出現,然后執行任務,執行完畢后繼續等待任務出現。
JavaScript引擎對事件循環的算法為:
- 當發現任務時:執行任務,從最先進入隊列的任務開始
- 等待其他任務出現,然后執行步驟1。
當瀏覽網頁時,就是以這種方式呈現。JavaScript引擎在大部分時間都處於空閑狀態,僅在被腳本文件、處理函數,事件系統激活時才運行。
比如:
<script src=''>
任務出現 - 引擎處理任務 - 然后等待其他任務出現(空閑時CPU消耗幾乎為0)。
在引擎處理任務的過程中,有可能會有其他任務出現,此時其他任務將會被放進隊列。
這些由任務組成的隊列就叫做“宏任務隊列(macrotasks queue)”( V8 術語):
例如,當引擎正在處理一個 script 文件時,用戶移動鼠標觸發了 mousemove 事件,此時剛好 setTimeout 的回調函數也將執行,這些任務將會形成一個隊列, 如上圖所示。
來自隊列的任務會按照“先進先出”的順序來執行,當瀏覽器處理完畢 script 文件,然后就會執行 mousemove 事件處理函數,然后 setTimeout 回調函數,等等。
到目前為止,還算簡單,對吧?
還有兩條重要的詳細信息:
- 當引擎在處理任務時,頁面絕對不會開始渲染。即使任務會執行很長一段時間。對DOM的修改僅僅在任務處理完畢后才會渲染到頁面上。
- 如果任務處理消耗很長時間,瀏覽器無法處理其他任務,例如用戶事件,那么一段時間后瀏覽器將會彈框提示頁面無響應,並建議關閉頁面。當進行大量的復雜運算或者由於程序錯誤導致死循環時,彈窗就會出現。
這就是事件循環的原理。現在來看看實際應用吧。
使用案例1:分離耗CPU的任務
假如我們有一個耗費CPU的任務。
例如,語法高亮(用於給示例代碼上色)是非常消耗CPU的。為了使代碼高亮,它分析、創建許多已經對代碼進行高亮的元素,將他們添加到文檔 - 這樣的元素越多,消耗的時間越長。
當引擎正在處理語法高亮時,它無法處理其他與DOM相關的事情,例如處理用戶事件。並且可能會帶來一些我們無法接受的頁面“卡住”。
我們可以通過將這些非常消耗CPU的任務分離成一個個的小任務來阻止這種情況。先高亮100行,然后設置一個計時器(延遲0秒)高亮下一個100行,等等。
為了更加簡單地闡述這個過程,讓我們用一個從0到1的計數函數來代替語法高亮。
如果你運行下面的代碼,引擎將會卡住一段時間。對於服務端來說,更加的顯而易見,如果你在瀏覽器中運行,然后點擊頁面中的其他按鈕-你就會發現在計數操作執行完畢之前,其他事件都不會觸發。
let i = 0;
let start = Date.now();
function count() {
// do a heavy job
for (let j = 0; j < 1e9; j++) {
i++;
}
alert("Done in " + (Date.now() - start) + 'ms');
}
count();
瀏覽器甚至可能會顯示出一個“the script takes too long”的警告。
讓我們使用嵌套的 setTimeout 來分離這個任務:
let i = 0;
let start = Date.now();
function count() {
// do a piece of the heavy job (*)
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
} else {
setTimeout(count); // schedule the new call (**)
}
}
count();
現在即使在處理計數的過程中,瀏覽器的事件系統也會是可用的。
單次運行 count() 執行計數的一部分(用*號表示),如果后面還需要執行,則通過計時器再重復執行。
- 先執行的數字:i=1...1000000
- 然后執行的數字:i=1000001..2000000
- 等等...
現在,如果引擎正在執行第一步時,另一個任務(例如 onclick 事件)觸發,這個任務將被放入隊列,然后在第一步執行完畢后、在第二步之前被執行。周期性地執行計數操作能夠使JavaScript引擎有足夠的空閑去做其他事情,比如響應用戶的操作。
使用案例2:進度指示
在瀏覽器腳本中,分離耗費CPU任務的另一個好處是可以顯示進度信息。
通常情況下,只有在當前正在運行的代碼執行完畢后,瀏覽器才會進行渲染。無論其是否消耗很長一段時間。當任務完成以后,瀏覽器才會對DOM操作進行渲染。
不得不說這樣做有它的好處,因為函數執行過程中可能會創建多個元素、一個一個的添加到文檔中並且修改它們的樣式-用戶不會看到任何中間的、沒有完成的狀態。
例如:對i的改變不會立即顯示,而是等到函數執行完畢,所以我們只會看到最后一個值。
<div id="progress"></div>
<script>
function count() {
for (let i = 0; i < 1e6; i++) {
i++;
progress.innerHTML = i;
}
}
count();
</script>
...但是假如我們想在任務執行過程中展示一些額外信息,例如進度條。
如果我們把這些‘重’的任務通過 setTimeout 分離成一個一個的小任務,那么對i的改變會不斷地被渲染出來。
例如:
<div id="progress"></div>
<script>
let i = 0;
function count() {
// do a piece of the heavy job (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e7) {
setTimeout(count);
}
}
count();
</script>
現在, div 元素會顯示不斷增加的i的值,像極了進度條。
使用案例3:在事件以后做些什么
在一個事件處理函數中,我們可能會延緩執行某些操作,直到事件冒泡完成並且被所有事件階段所處理。我們可以通過將某些操作包裹在0秒延遲的 setTimeout 中。
因為原文案例設計一些額外知識( CustomeEvent 自定義事件 ),為了簡單起見,此處為作者提供案例 :
<div id='div'>
<button id='button'>hello</button>
</div>
<script>
div.addEventListener('click', () => {
alert('div!')
});
button.addEventListener('click', () => {
alert('button!');
setTimeout(() => {
alert('click事件已被處理完畢!')
})
})
</script>
宏任務和微任務
除了在上文提到過的宏任務外,還存在着 微任務(microtasks) 。
異步任務需要更准確地管理。所以,ECMA標准定義了一個內部隊列 promiseJobs ,更多地被稱作是“微任務隊列(microtask queue)”(ES8術語)。
如ESMA262中所 定義 :
- 微任務隊列為“先進先出”:最先進入隊列的任務最先執行
- 只有當目前沒有運行其他任何任務時,微任務才會開始執行
簡單來說,當一個 Promise 已就緒,它的 .then/catch/finally 事件處理函數將被放入隊列;它們暫時不會被執行。只有當JS引擎處理完當前的代碼,才會按照順序執行微任務隊列中的任務。
例如:
let promise = Promise.resolve();
promise.then(() => alert("promise done!"));
alert("code finished"); // this alert shows first
執行上面代碼, code finished 將會先顯示,然后是 promise done! 。

Promise函數永遠會按照這個順序執行。
如果是鏈式的 .then/catch/finally ,那么會異步地執行每一項。也就是說,先將他們放入微任務隊列,然后等待當前代碼執行完畢,並且微任務隊列中前面的任務執行完畢,然后執行。
如果需要按照順序執行呢?如果確保 promise done 先顯示,然后才是 code finished 呢?
只需要通過 .then 來將它們依次放入隊列:
Promise.resolve()
.then(() => alert("promise done!"))
.then(() => alert("code finished"));
微任務來自於我們的代碼。通常是通過Promise創建, .then/catch/finally 的處理函數成為一個微任務。同樣, await 函數也適用,它是另一種Promise的處理方式。
另外,通過 queueMicrotask(func) 函數可以將 func 這個函數放入微任務隊列(目前IE還不支持)。
在每一個宏任務執行完畢后,引擎會立即執行微任務隊列中的所有任務,然后繼續執行其他宏任務或渲染DOM操作。
例如:
setTimeout(() => alert("timeout"));
Promise.resolve()
.then(() => alert("promise"));
alert("code");
上面的代碼中彈框將會按照什么順序顯示呢?
- 先顯示 code ,因為它是一個普通的同步函數;
- 然后顯示 promise ,因為 .then 處於微任務隊列中,所以當當前宏任務執行完畢就會執行;
- 最后顯示 timeout ,因為它是另一個宏任務。
完整的事件循環流程:(從上往下,腳本文件(宏任務) -> 微任務 -> 渲染操作 -> 重復流程...)
在任何其他的事件處理函數、渲染操作或其他宏任務執行之前,所有的微任務都會執行完畢。
如果我們希望去異步地執行一個函數(當前代碼執行完畢后),但是在DOM操作被渲染之前,或者其他事件處理函數、宏任務執行之前,可以通過 queueMicrotask 來設置。
另一個進度指示條的例子:和上文中提到的那個類似,但是在這里用的是 queueMicrotask ,而不是 setTimeout 。在每一個宏任務執行完畢后都會進行渲染操作,就好像是同步代碼:
<div id="progress"></div>
<script>
let i = 0;
function count() {
// do a piece of the heavy job (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e6) {
queueMicrotask(count);
}
}
count();
</script>
總結
更多關於事件循環算法的詳細信息(和 事件循環定義 來比,仍然是很簡單的):
- 宏任務中最先進入的任務最先出列並且執行(例如腳本文件);
- 執行所有微任務:當微任務隊列不為空時:微任務隊列中最先進入的任務出列並且執行
- 執行渲染操作(如果有對DOM進行修改的話);
- 如果宏任務隊列為空的話,等待宏任務出現;
- 執行步驟1。
如果要設置一個新的宏任務:
- 使用0秒延遲 setTimeout(f)
當把一個涉及大量運算的任務分離成一個一個的小任務時,設置新的宏任務就能夠使得瀏覽器能夠對用戶的操作做出響應並展示進度。
同樣可用於事件處理函數,當事件被完全處理完畢(事件冒泡完畢)后執行一個操作。
如果要設置一個新的微任務:
queueMicrotask(f)
Promise
在微任務隊列處理期間,任何UI或者網絡事件都不會被處理,微任務隊列中的所有任務會一個一個立即執行。
所以我們可能會用 queueMicrotask 去異步執行一個函數,但是當前的環境狀態(environment state)還沒有被改變。
Web Workers
如果不想阻塞事件循環,在涉及到非常大的復雜運算時,可以使用 Web Workers 。
通過並行線程的方式來運行代碼
Web Workers能夠與主過程交換信息,但它擁有自己的變量、事件循環
Web Workers不能訪問DOM,所以在進行計算時同時使用多核CPU是非常有用的。