瀏覽器 JavaScript 執行流程,以及 Node.js,是基於一個事件迴圈。
了解事件迴圈如何運作對於最佳化很重要,有時對於正確的架構也很重要。
在本章節中,我們首先介紹事情如何運作的理論細節,然後了解該知識的實際應用。
事件迴圈
事件循環的概念非常簡單。有一個無窮迴圈,JavaScript 引擎會在其中等待任務、執行任務,然後進入休眠狀態,等待更多任務。
引擎的一般演算法
- 在有任務時
- 執行任務,從最舊的任務開始執行。
- 休眠直到出現任務,然後轉到 1。
這是我們在瀏覽網頁時所看到的形式化。JavaScript 引擎大部分時間都不會執行任何操作,只有在腳本/處理常式/事件啟動時才會執行。
任務範例
- 當外部腳本
<script src="...">
載入時,任務就是執行它。 - 當使用者移動滑鼠時,任務就是發送
mousemove
事件並執行處理常式。 - 當預定的
setTimeout
到時間時,任務就是執行其回呼函式。 - …等等。
任務已設定 — 引擎會處理它們 — 然後等待更多任務(同時處於休眠狀態且幾乎不消耗任何 CPU)。
引擎忙碌時可能會出現任務,然後會將其排入佇列。
任務會形成一個佇列,稱為「巨任務佇列」(v8 術語)
例如,當引擎忙碌於執行 script
時,使用者可能會移動滑鼠導致 mousemove
,而 setTimeout
可能到時間,等等,這些任務會形成一個佇列,如上圖所示。
佇列中的任務會依「先到先服務」的原則處理。當引擎瀏覽器完成 script
時,它會處理 mousemove
事件,然後處理 setTimeout
處理常式,等等。
到目前為止,很簡單,對吧?
再補充兩個細節
- 當引擎執行任務時,絕不會進行渲染。任務花費多長時間並不重要。只有在任務完成後才會繪製 DOM 的變更。
- 如果任務花費太長的時間,瀏覽器就無法執行其他任務,例如處理使用者事件。因此,一段時間後,它會發出「網頁沒有回應」之類的警告,建議終止任務並關閉整個網頁。當有大量複雜的計算或導致無窮迴圈的程式設計錯誤時,就會發生這種情況。
那是理論。現在讓我們看看如何應用這些知識。
用例 1:分割耗費大量 CPU 的任務
假設我們有一個耗費大量 CPU 的任務。
例如,語法高亮(用於為本頁的程式碼範例著色)非常耗費 CPU。為了高亮程式碼,它會執行分析、建立許多有顏色的元素,並將它們新增到文件中 — 對於大量的文字,這會花費很多時間。
當引擎忙於語法高亮時,它無法執行其他與 DOM 相關的工作、處理使用者事件等。它甚至可能導致瀏覽器「打嗝」或「暫停」一會兒,這是不可接受的。
我們可以透過將大型任務拆分成小塊來避免問題。先高亮前 100 行,然後為下一個 100 行排程 setTimeout
(零延遲),以此類推。
為了展示此方法,為了簡化起見,我們不進行文字高亮,而是採用一個從 1
到 1000000000
的計數函式。
如果你執行以下程式碼,引擎將會「暫停」一段時間。對於伺服器端 JS 來說,這很明顯,如果你在瀏覽器中執行它,請嘗試按一下頁面上的其他按鈕,你會發現直到計數結束,其他事件才會被處理。
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
呼叫來拆分工作
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
。 - …等等。
現在,如果在引擎忙於執行第 1 部分時出現新的次要任務(例如 onclick
事件),它會被排入佇列,然後在第 1 部分完成後執行,在下一部分之前執行。在 count
執行之間定期返回事件迴圈,只為 JavaScript 引擎提供足夠的「空間」來執行其他操作,以回應其他使用者動作。
值得注意的是,兩種變體(使用 setTimeout
拆分工作與不拆分工作)在速度上是相當的。整體計數時間沒有太大的差異。
為了讓它們更接近,我們來做一些改進。
我們將排程移到 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();
現在,當我們開始 count()
並且看到我們需要更多 count()
時,我們會立即排程,在執行工作之前。
如果你執行它,很容易注意到它花費的時間顯著減少。
為什麼?
這很簡單:如你所知,對於許多巢狀 setTimeout
呼叫,瀏覽器中的最小延遲為 4 毫秒。即使我們設定為 0
,它也是 4 毫秒
(或更多一點)。因此,我們越早排程,執行速度就越快。
最後,我們將耗費 CPU 的任務拆分成小部分,現在它不會阻擋使用者介面。而且它的整體執行時間並不會長很多。
用例 2:進度指示
為瀏覽器腳本拆分繁重任務的另一個好處是,我們可以顯示進度指示。
如前所述,DOM 的變更只會在目前執行的任務完成後才會繪製,無論花費多長時間。
一方面,這很好,因為我們的函式可能會建立許多元素,將它們逐一新增到文件並變更其樣式,訪客不會看到任何「中間」的未完成狀態。這很重要,對吧?
以下是範例,對 i
的變更在函式結束前不會顯示,所以我們只會看到最後一個值
<div id="progress"></div>
<script>
function count() {
for (let i = 0; i < 1e6; i++) {
i++;
progress.innerHTML = i;
}
}
count();
</script>
…但我們也可能想要在任務期間顯示一些東西,例如進度條。
如果我們使用 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>
現在 <div>
會顯示 i
的遞增值,類似於進度條。
用例 3:在事件後執行某些操作
在事件處理常式中,我們可能會決定延後一些動作,直到事件冒泡並在所有層級上都已處理完畢。我們可以透過將程式碼包覆在零延遲的 setTimeout
中來執行此操作。
在章節 發送自訂事件 中,我們看過一個範例:自訂事件 menu-open
會在 setTimeout
中發送,以便在「click」事件完全處理完畢後才會發生。
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));
};
巨任務和微任務
除了本章中所描述的 巨任務 之外,還有 微任務,這在章節 微任務 中有提到。
微任務僅來自我們的程式碼。它們通常是由 Promise 建立的:執行 .then/catch/finally
處理常式會變成微任務。微任務也會在 await
的「幕後」使用,因為它是 Promise 處理的另一種形式。
還有一個特殊函式 queueMicrotask(func)
,它會將 func
排入微任務佇列中執行。
在每個 巨任務 之後,引擎會立即執行 微任務 佇列中的所有任務,然後才會執行任何其他巨任務、渲染或其他任何事情。
例如,請看
setTimeout(() => alert("timeout"));
Promise.resolve()
.then(() => alert("promise"));
alert("code");
這裡的順序會是什麼?
code
會先顯示,因為它是常規同步呼叫。promise
會第二個顯示,因為.then
會通過微任務佇列,並在目前程式碼之後執行。timeout
會最後顯示,因為它是巨任務。
更豐富的事件迴圈圖如下所示(順序由上至下,也就是:先執行指令碼,然後是微任務、渲染等等)
在任何其他事件處理、渲染或任何其他巨任務發生之前,所有微任務都會完成。
這很重要,因為它保證了應用程式環境在微任務之間基本上是相同的(沒有滑鼠座標變更、沒有新的網路資料等)。
如果我們想要非同步執行函式(在目前程式碼之後),但在變更渲染或處理新事件之前,我們可以使用 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>
摘要
一個更詳細的事件迴圈演算法(儘管與規格相比仍然簡化)
- 從巨任務佇列中取出並執行最舊的任務(例如「指令碼」)。
- 執行所有微任務
- 當微任務佇列不為空時
- 取出並執行最舊的微任務。
- 當微任務佇列不為空時
- 如有任何變更,請呈現。
- 如果巨任務佇列為空,請等到巨任務出現。
- 跳到步驟 1。
若要排程新的巨任務
- 使用零延遲的
setTimeout(f)
。
這可以用於將一個大型計算密集型任務拆分成多個部分,讓瀏覽器能夠對使用者事件做出反應,並顯示它們之間的進度。
此外,在事件處理常式中使用,以便在事件完全處理後(冒泡完成)排程動作。
若要排程新的微任務
- 使用
queueMicrotask(f)
。 - 承諾處理常式也會經過微任務佇列。
在微任務之間沒有 UI 或網路事件處理:它們會一個接著一個立即執行。
因此,有人可能想要 queueMicrotask
來非同步執行函式,但位於環境狀態中。
對於不應封鎖事件迴圈的長時間繁重計算,我們可以使用 Web Workers。
這是執行另一個平行執行緒中程式碼的方法。
Web Workers 可以與主程序交換訊息,但它們有自己的變數和自己的事件迴圈。
Web Workers 無法存取 DOM,因此它們主要用於計算,以同時使用多個 CPU 核心。
留言
<code>
標籤,對於多行 - 將它們包裝在<pre>
標籤中,對於超過 10 行 - 使用沙箱 (plnkr,jsbin,codepen…)