2023 年 7 月 9 日

事件迴圈:微任務和巨任務

瀏覽器 JavaScript 執行流程,以及 Node.js,是基於一個事件迴圈

了解事件迴圈如何運作對於最佳化很重要,有時對於正確的架構也很重要。

在本章節中,我們首先介紹事情如何運作的理論細節,然後了解該知識的實際應用。

事件迴圈

事件循環的概念非常簡單。有一個無窮迴圈,JavaScript 引擎會在其中等待任務、執行任務,然後進入休眠狀態,等待更多任務。

引擎的一般演算法

  1. 在有任務時
    • 執行任務,從最舊的任務開始執行。
  2. 休眠直到出現任務,然後轉到 1。

這是我們在瀏覽網頁時所看到的形式化。JavaScript 引擎大部分時間都不會執行任何操作,只有在腳本/處理常式/事件啟動時才會執行。

任務範例

  • 當外部腳本 <script src="..."> 載入時,任務就是執行它。
  • 當使用者移動滑鼠時,任務就是發送 mousemove 事件並執行處理常式。
  • 當預定的 setTimeout 到時間時,任務就是執行其回呼函式。
  • …等等。

任務已設定 — 引擎會處理它們 — 然後等待更多任務(同時處於休眠狀態且幾乎不消耗任何 CPU)。

引擎忙碌時可能會出現任務,然後會將其排入佇列。

任務會形成一個佇列,稱為「巨任務佇列」(v8 術語)

例如,當引擎忙碌於執行 script 時,使用者可能會移動滑鼠導致 mousemove,而 setTimeout 可能到時間,等等,這些任務會形成一個佇列,如上圖所示。

佇列中的任務會依「先到先服務」的原則處理。當引擎瀏覽器完成 script 時,它會處理 mousemove 事件,然後處理 setTimeout 處理常式,等等。

到目前為止,很簡單,對吧?

再補充兩個細節

  1. 當引擎執行任務時,絕不會進行渲染。任務花費多長時間並不重要。只有在任務完成後才會繪製 DOM 的變更。
  2. 如果任務花費太長的時間,瀏覽器就無法執行其他任務,例如處理使用者事件。因此,一段時間後,它會發出「網頁沒有回應」之類的警告,建議終止任務並關閉整個網頁。當有大量複雜的計算或導致無窮迴圈的程式設計錯誤時,就會發生這種情況。

那是理論。現在讓我們看看如何應用這些知識。

用例 1:分割耗費大量 CPU 的任務

假設我們有一個耗費大量 CPU 的任務。

例如,語法高亮(用於為本頁的程式碼範例著色)非常耗費 CPU。為了高亮程式碼,它會執行分析、建立許多有顏色的元素,並將它們新增到文件中 — 對於大量的文字,這會花費很多時間。

當引擎忙於語法高亮時,它無法執行其他與 DOM 相關的工作、處理使用者事件等。它甚至可能導致瀏覽器「打嗝」或「暫停」一會兒,這是不可接受的。

我們可以透過將大型任務拆分成小塊來避免問題。先高亮前 100 行,然後為下一個 100 行排程 setTimeout(零延遲),以此類推。

為了展示此方法,為了簡化起見,我們不進行文字高亮,而是採用一個從 11000000000 的計數函式。

如果你執行以下程式碼,引擎將會「暫停」一段時間。對於伺服器端 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 執行會完成工作的一部分 (*),然後在需要時重新排程自己 (**)

  1. 第一次執行計數:i=1...1000000
  2. 第二次執行計數:i=1000001..2000000
  3. …等等。

現在,如果在引擎忙於執行第 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");

這裡的順序會是什麼?

  1. code 會先顯示,因為它是常規同步呼叫。
  2. promise 會第二個顯示,因為 .then 會通過微任務佇列,並在目前程式碼之後執行。
  3. 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. 巨任務佇列中取出並執行最舊的任務(例如「指令碼」)。
  2. 執行所有微任務
    • 當微任務佇列不為空時
      • 取出並執行最舊的微任務。
  3. 如有任何變更,請呈現。
  4. 如果巨任務佇列為空,請等到巨任務出現。
  5. 跳到步驟 1。

若要排程新的巨任務

  • 使用零延遲的 setTimeout(f)

這可以用於將一個大型計算密集型任務拆分成多個部分,讓瀏覽器能夠對使用者事件做出反應,並顯示它們之間的進度。

此外,在事件處理常式中使用,以便在事件完全處理後(冒泡完成)排程動作。

若要排程新的微任務

  • 使用 queueMicrotask(f)
  • 承諾處理常式也會經過微任務佇列。

在微任務之間沒有 UI 或網路事件處理:它們會一個接著一個立即執行。

因此,有人可能想要 queueMicrotask 來非同步執行函式,但位於環境狀態中。

Web Workers

對於不應封鎖事件迴圈的長時間繁重計算,我們可以使用 Web Workers

這是執行另一個平行執行緒中程式碼的方法。

Web Workers 可以與主程序交換訊息,但它們有自己的變數和自己的事件迴圈。

Web Workers 無法存取 DOM,因此它們主要用於計算,以同時使用多個 CPU 核心。

任務

重要性:5
console.log(1);

setTimeout(() => console.log(2));

Promise.resolve().then(() => console.log(3));

Promise.resolve().then(() => setTimeout(() => console.log(4)));

Promise.resolve().then(() => console.log(5));

setTimeout(() => console.log(6));

console.log(7);

主控台輸出為:1 7 3 5 2 6 4。

任務相當簡單,我們只需要知道微任務和巨任務佇列如何運作。

讓我們逐步了解發生了什麼事。

console.log(1);
// The first line executes immediately, it outputs `1`.
// Macrotask and microtask queues are empty, as of now.

setTimeout(() => console.log(2));
// `setTimeout` appends the callback to the macrotask queue.
// - macrotask queue content:
//   `console.log(2)`

Promise.resolve().then(() => console.log(3));
// The callback is appended to the microtask queue.
// - microtask queue content:
//   `console.log(3)`

Promise.resolve().then(() => setTimeout(() => console.log(4)));
// The callback with `setTimeout(...4)` is appended to microtasks
// - microtask queue content:
//   `console.log(3); setTimeout(...4)`

Promise.resolve().then(() => console.log(5));
// The callback is appended to the microtask queue
// - microtask queue content:
//   `console.log(3); setTimeout(...4); console.log(5)`

setTimeout(() => console.log(6));
// `setTimeout` appends the callback to macrotasks
// - macrotask queue content:
//   `console.log(2); console.log(6)`

console.log(7);
// Outputs 7 immediately.

總之,

  1. 數字17會立即顯示,因為簡單的 console.log 呼叫不會使用任何佇列。
  2. 然後,在主程式碼流程完成後,微任務佇列會執行。
    • 它有以下指令:console.log(3); setTimeout(...4); console.log(5)
    • 數字35會顯示,而 setTimeout(() => console.log(4)) 會將 console.log(4) 呼叫新增到巨任務佇列的結尾。
    • 巨任務佇列現在為:console.log(2); console.log(6); console.log(4)
  3. 在微任務佇列變為空後,巨任務佇列會執行。它會輸出264

最後,我們有輸出:1 7 3 5 2 6 4

教學課程地圖

留言

在留言前請先閱讀…
  • 如果您有改善建議 - 請 提交 GitHub 問題 或提交拉取請求,而不是留言。
  • 如果您無法理解文章中的某些內容 - 請詳細說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,對於多行 - 將它們包裝在 <pre> 標籤中,對於超過 10 行 - 使用沙箱 (plnkrjsbincodepen…)