瀏覽器JavaScript執(zhí)行流程以及Node.js中的流程均基于事件循環(huán)。
了解事件循環(huán)的工作方式對于優(yōu)化(有時對于正確的體系結(jié)構(gòu))非常重要。
在本章中,我們首先介紹有關(guān)事物如何工作的理論細(xì)節(jié),然后介紹該知識的實際應(yīng)用。
事件循環(huán)
該事件循環(huán)的概念是很簡單的。有一個無限循環(huán),JavaScript引擎等待任務(wù),執(zhí)行任務(wù),然后休眠,等待更多任務(wù)。
引擎的一般算法:
- 當(dāng)有任務(wù):
- 從最早的任務(wù)開始執(zhí)行它們。
- 休眠直到出現(xiàn)任務(wù),然后轉(zhuǎn)到步驟1。
這是瀏覽頁面時看到的形式化信息。JavaScript引擎大部分時間不執(zhí)行任何操作,僅在腳本/處理程序/事件被激活時才運行。
任務(wù)示例:
-
<script src="...">加載外部腳本時,任務(wù)是執(zhí)行它。 - 用戶移動鼠標(biāo)時,任務(wù)是調(diào)度
mousemove事件并執(zhí)行處理程序。 - 當(dāng)計劃好的時間到了時
setTimeout,任務(wù)是運行其回調(diào)。 - 等等。
設(shè)置任務(wù)-引擎處理它們-然后等待更多任務(wù)(在睡眠時消耗接近零的CPU)。
可能是在引擎繁忙時任務(wù)來了,然后才入隊了。
這些任務(wù)形成一個隊列,即所謂的“宏任務(wù)隊列”(v8術(shù)語):

例如,當(dāng)引擎正忙于執(zhí)行時script,用戶可能會移動鼠標(biāo)mousemove,這=setTimeout可能是由于任務(wù)到期而導(dǎo)致的,等等,這些任務(wù)形成了一個隊列,如上圖所示。
隊列中的任務(wù)將按照“先到先得”的原則進(jìn)行處理。當(dāng)引擎瀏覽器用完成后script,它將處理mousemove事件,然后setTimeout處理程序,依此類推。
到目前為止,很簡單,對吧?
還有兩個細(xì)節(jié):
- 引擎執(zhí)行任務(wù)時永遠(yuǎn)不會進(jìn)行渲染。任務(wù)是否花費很長時間都沒關(guān)系。僅在任務(wù)完成后才繪制對DOM的更改。
- 如果一項任務(wù)花費的時間太長,瀏覽器將無法執(zhí)行其他任務(wù),例如處理用戶事件。因此,過了一會兒,它會發(fā)出類似“頁面無響應(yīng)”的警報,提示您殺死整個頁面的任務(wù)。當(dāng)存在大量復(fù)雜的計算或?qū)е聼o限循環(huán)的編程錯誤時,就會發(fā)生這種情況。
那是理論?,F(xiàn)在,讓我們看看如何應(yīng)用這些知識。
用例1:拆分占用大量CPU的任務(wù)
假設(shè)我們有一個需要CPU的任務(wù)。
例如,語法高亮(用于著色此頁面上的代碼示例)相當(dāng)占用CPU資源。為了突出顯示代碼,它執(zhí)行分析,創(chuàng)建許多彩色元素,然后將它們添加到文檔中-花費大量時間編寫大量文本。
當(dāng)引擎忙于語法高亮顯示時,它無法執(zhí)行其他與DOM相關(guān)的工作,處理用戶事件等。它甚至可能導(dǎo)致瀏覽器“卡頓”甚至“掛起”一小段時間,這是不可接受的。
通過將大任務(wù)分成多個部分,我們可以避免問題。突出顯示前100行,然后為后100行計劃setTimeout(零延遲),依此類推。
為了簡單起見,為了演示這種方法,讓我們開始一個從1到1000000000計數(shù)的函數(shù)。
如果您運行下面的代碼,引擎將“掛起”一段時間。對于明顯可見的服務(wù)器端JS,如果您正在瀏覽器中運行它,則嘗試單擊頁面上的其他按鈕-您會發(fā)現(xiàn)在計數(shù)結(jié)束之前不會處理其他事件。
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();
瀏覽器甚至可能顯示“腳本花費太長時間”的警告。
讓我們使用嵌套setTimeout調(diào)用拆分作業(yè):
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();
現(xiàn)在,瀏覽器界面在“計數(shù)”過程中可以正常使用。
一次運行會count完成一部分工作,然后根據(jù)需要重新安排自身的時間,例如:
- 首次運行計數(shù):
i=1...1000000。 - 第二次運行計數(shù):
i=1000001..2000000。 - …等等。
現(xiàn)在,如果onclick在引擎正忙于執(zhí)行第1部分時出現(xiàn)新的輔助任務(wù)(例如事件),則將其排隊,然后在第1部分完成時在下一部分之前執(zhí)行。count執(zhí)行之間定期返回事件循環(huán),為JavaScript引擎提供足夠的“空氣”以執(zhí)行其他操作,以對其他用戶操作做出反應(yīng)。
值得注意的是,兩種變體(無論是否分配工作)setTimeout在速度上都是可比的??傮w計數(shù)時間沒有太大差異。
為了使它們更接近,讓我們進(jìn)行改進(jìn)。
我們將排程移至的開頭count():
let i = 0;
let start = Date.now();
function count() {
// move the scheduling to the beginning
if (i < 1e9 - 1e6) {
setTimeout(count); // schedule the new call
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
}
}
count();
現(xiàn)在,當(dāng)我們開始count()發(fā)現(xiàn)需要做count()更多的工作時,我們會立即安排工作時間,然后再進(jìn)行這項工作。
如果您運行它,很容易注意到它花費的時間大大減少了。
為什么?
這很簡單:您記得,許多嵌套setTimeout調(diào)用在瀏覽器中的最小延遲為4毫秒。即使我們設(shè)置了0,它4ms(或者更多)。因此,我們計劃得越早–運行速度越快。
最后,我們將需要大量CPU的任務(wù)分成了幾個部分–現(xiàn)在它不會阻塞用戶界面。而且它的整體執(zhí)行時間不會更長。
用例2:進(jìn)度指示
為瀏覽器腳本分配繁重任務(wù)的另一個好處是,我們可以顯示進(jìn)度指示。
如前所述,僅在當(dāng)前運行的任務(wù)完成后才繪制對DOM的更改,而不管它花費多長時間。
一方面,這很棒,因為我們的函數(shù)可能會創(chuàng)建許多元素,將它們一個接一個地添加到文檔中并更改其樣式-訪問者不會看到任何“中間”未完成的狀態(tài)。重要的事情,對不對?
這是演示,在函數(shù)完成之前不會顯示對i的更改,因此我們將僅看到最后一個值:
<div id="progress"></div>
<script>
function count() {
for (let i = 0; i < 1e6; i++) {
i++;
progress.innerHTML = i;
}
}
count();
</script>
…但是我們也可能希望在任務(wù)執(zhí)行過程中顯示一些東西,例如進(jìn)度條。
如果我們使用來將繁重的任務(wù)分成幾部分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 < 1e7) {
setTimeout(count);
}
}
count();
</script>
現(xiàn)在,<div>顯示的是的增加值i,這是一種進(jìn)度條。
用例3:在事件發(fā)生后采取措施
在事件處理程序中,我們可能會決定推遲一些操作,直到事件冒泡并在所有級別上得到處理。我們可以通過將代碼包裝在零延遲setTimeout中來做到這一點。
在分派自定義事件一章中,我們看到了一個示例:自定義事件menu-open是在setTimeout中分派的,因此它在完全處理“ click”事件之后發(fā)生。
menu.onclick = function() {
// ...
// create a custom event with the clicked menu item data
let customEvent = new CustomEvent("menu-open", {
bubbles: true
});
// dispatch the custom event asynchronously
setTimeout(() => menu.dispatchEvent(customEvent));
};
宏任務(wù)和微任務(wù)
微任務(wù)僅來自我們的代碼。它們通常是由Promise創(chuàng)建的:處理程序.then/catch/finally的執(zhí)行成為微任務(wù)。微任務(wù)也被“秘密使用” await,因為它是Promise處理的另一種形式。
還有一個特殊功能queueMicrotask(func),func可在微任務(wù)隊列中排隊等待執(zhí)行。
在每個宏任務(wù)執(zhí)行之后,引擎會立即運行微任務(wù)隊列中的所有任務(wù),然后再運行其他宏任務(wù)或渲染或其他任何操作。**
例如,看一下:
setTimeout(() => alert("timeout"));
Promise.resolve()
.then(() => alert("promise"));
alert("code");
這將是什么順序?
-
code首先顯示,因為它是常規(guī)的同步調(diào)用。 -
promise顯示第二個,因為它.then通過微任務(wù)隊列,并在當(dāng)前代碼之后運行。 -
timeout最后顯示,因為它是一個宏任務(wù)。
更豐富的事件循環(huán)圖片如下所示(順序是從上到下,即:首先是腳本,然后是微任務(wù),渲染等):

在執(zhí)行任何其他事件處理或呈現(xiàn)或執(zhí)行任何其他宏任務(wù)之前,所有微任務(wù)都已完成。
這很重要,因為它可以確保微任務(wù)之間的應(yīng)用程序環(huán)境基本相同(沒有鼠標(biāo)坐標(biāo)更改,沒有新的網(wǎng)絡(luò)數(shù)據(jù)等)。
如果我們想異步執(zhí)行一個函數(shù)(在當(dāng)前代碼之后),但是在呈現(xiàn)更改或處理新事件之前,可以使用進(jìn)行調(diào)度queueMicrotask。
這是一個帶有“計數(shù)進(jìn)度條”的示例,與之前顯示的示例類似,但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>
概括
更詳細(xì)的事件循環(huán)算法(盡管與規(guī)范相比仍簡化了):
- 從宏任務(wù)隊列中出隊并運行最早的任務(wù)(例如“腳本”)。
- 執(zhí)行所有微任務(wù):
- 當(dāng)微任務(wù)隊列不為空時:
- 出隊并運行最早的微任務(wù)。
- 當(dāng)微任務(wù)隊列不為空時:
- 渲染更改(如果有)。
- 如果宏任務(wù)隊列為空,請等待直到出現(xiàn)宏任務(wù)。
- 轉(zhuǎn)到步驟1。
要安排新的宏任務(wù):
- 使用零延遲
setTimeout(f)。
這可以用于將繁重的計算任務(wù)分解為多個部分,以使瀏覽器能夠?qū)τ脩羰录龀龇磻?yīng)并顯示它們之間的進(jìn)度。
另外,在事件處理程序中用于安排事件完全處理(冒泡完成)后的操作。
安排新的微任務(wù)
- 使用
queueMicrotask(f)。 - 諾言處理程序還會通過微任務(wù)隊列。
微任務(wù)之間沒有UI或網(wǎng)絡(luò)事件處理:它們立即一個接一個地運行。
因此,您可能想queueMicrotask異步執(zhí)行功能,但要在環(huán)境狀態(tài)下執(zhí)行。
Web Worker
對于不應(yīng)該阻塞事件循環(huán)的長時間繁瑣的計算,我們可以使用Web Workers。
這是在另一個并行線程中運行代碼的方式。
Web Workers可以與主進(jìn)程交換消息,但是它們具有自己的變量和事件循環(huán)。
Web Worker沒有訪問DOM的權(quán)限,因此它們對于同時使用多個CPU內(nèi)核的計算非常有用。