在 JavaScript 中使用 microtask (queueMicrotask())

微任務是一個簡短的函式,它在其建立函式或程式退出後執行,並且僅當 JavaScript 執行棧為空時才執行,但會在控制權返回給 使用者代理 用於驅動指令碼執行環境的事件迴圈之前執行。

此事件迴圈可以是瀏覽器的主要事件迴圈,也可以是驅動 Web Worker 的事件迴圈。這使得給定函式可以在不干擾其他指令碼執行的風險下執行,同時也確保微任務在使用者代理有機會對微任務執行的操作做出反應之前執行。

JavaScript PromiseMutation Observer API 都使用微任務佇列來執行其回撥,但在其他時候,將工作推遲到當前事件迴圈結束時再處理也很有用。為了允許第三方庫、框架和 polyfill 使用微任務,queueMicrotask() 方法在 WindowWorkerGlobalScope 介面上公開。

任務與微任務

要正確討論微任務,首先了解 JavaScript 任務是什麼以及微任務與任務有何不同是很有用的。這是一個快速、簡化的解釋,但如果您想了解更多細節,可以閱讀文章 深入瞭解:微任務和 JavaScript 執行時環境 中的資訊。

任務

任務是透過標準機制(例如最初啟動程式、非同步排程事件或觸發間隔或超時)計劃執行的任何內容。這些都會被排程到任務佇列中。

例如,在以下情況下,任務會新增到任務佇列中:

  • 直接執行新的 JavaScript 程式或子程式(例如從控制檯,或透過執行 <script> 元素中的程式碼)。
  • 使用者點選一個元素。然後建立一個任務並執行所有事件回撥。
  • 使用 setTimeout()setInterval() 建立的超時或間隔到達,導致相應的回撥新增到任務佇列中。

驅動程式碼的事件迴圈按它們入隊的順序一個接一個地處理這些任務。任務佇列中最舊的可執行任務將在事件迴圈的單次迭代中執行。之後,微任務將執行,直到微任務佇列為空,然後瀏覽器可能會選擇更新渲染。然後瀏覽器進入事件迴圈的下一次迭代。

微任務

乍一看,微任務和任務之間的差異似乎很小。它們確實相似;兩者都由 JavaScript 程式碼組成,這些程式碼被放置在佇列中並在適當的時間執行。然而,事件迴圈只執行迭代開始時佇列中存在的任務,一個接一個地執行,它處理微任務佇列的方式則大相徑庭。

有兩個關鍵區別

  1. 每次任務退出時,事件迴圈都會檢查任務是否將控制權返回給其他 JavaScript 程式碼。如果沒有,它會執行微任務佇列中的所有微任務。因此,微任務佇列在事件迴圈的每次迭代中都會被多次處理,包括在處理事件和其他回撥之後。
  2. 如果微任務透過呼叫 queueMicrotask() 向佇列新增更多微任務,則這些新新增的微任務會在下一個任務執行之前執行。這是因為事件迴圈會不斷呼叫微任務,直到佇列中沒有剩餘微任務,即使不斷有新的微任務被新增。

警告:由於微任務本身可以入隊更多微任務,並且事件迴圈會持續處理微任務直到佇列為空,因此存在事件迴圈無休止地處理微任務的真實風險。請謹慎對待如何遞迴新增微任務。

使用微任務

在深入討論之前,再次強調大多數開發者即使使用微任務也很少,甚至根本不使用,這一點很重要。它們是現代基於瀏覽器的 JavaScript 開發中高度專業化的功能,允許您排程程式碼以在使用者計算機上等待發生的漫長事件集合中搶佔其他事情。濫用此功能將導致效能問題。

微任務入隊

因此,您通常只在沒有其他解決方案時,或者在建立需要使用微任務來實現其功能的框架或庫時才應該使用微任務。雖然過去有一些技巧可以使微任務入隊(例如透過建立立即解決的 Promise),但新增 queueMicrotask() 方法提供了一種安全且無需技巧的標準方式來引入微任務。

透過引入 queueMicrotask(),可以避免在使用 Promise 建立微任務時出現的怪癖。例如,當使用 Promise 建立微任務時,回撥丟擲的異常會被報告為被拒絕的 Promise,而不是標準異常。此外,建立和銷燬 Promise 會帶來額外的時間和記憶體開銷,而正確將微任務入隊的函式可以避免這些開銷。

將 JavaScript Function 傳遞給 queueMicrotask() 方法,以便在上下文處理微任務時呼叫。根據當前執行上下文,該方法會在 WindowWorker 介面定義的全域性上下文上公開。

js
queueMicrotask(() => {
  /* code to run in the microtask here */
});

微任務函式本身不接受引數,也不返回任何值。

何時使用微任務

在本節中,我們將探討微任務特別有用的場景。通常,它是在 JavaScript 執行上下文的主體退出之後——但在任何事件處理程式、超時和間隔或其他回撥處理之前——捕獲或檢查結果,或執行清理。

這何時有用?

使用微任務的主要原因是確保任務的順序一致,即使結果或資料是同步可用的,同時還能降低操作中使用者可察覺的延遲風險。

在條件使用 Promise 時確保順序

微任務可以用來確保執行順序始終一致的一種情況是,當 Promise 在 if...else 語句(或其他條件語句)的一個分支中使用,但在另一個分支中不使用時。考慮以下程式碼:

js
customElement.prototype.getData = function (url) {
  if (this.cache[url]) {
    this.data = this.cache[url];
    this.dispatchEvent(new Event("load"));
  } else {
    fetch(url)
      .then((result) => result.arrayBuffer())
      .then((data) => {
        this.cache[url] = data;
        this.data = data;
        this.dispatchEvent(new Event("load"));
      });
  }
};

這裡引入的問題是,在 `if...else` 語句的一個分支中使用任務(在影像在快取中可用的情況下),而在 `else` 分支中涉及 Promise,我們遇到了操作順序可能不同的情況;例如,如下所示。

js
element.addEventListener("load", () => console.log("Loaded data"));
console.log("Fetching data…");
element.getData();
console.log("Data fetched");

連續兩次執行此程式碼會得到以下結果。

當資料未快取時

Fetching data…
Data fetched
Loaded data

當資料已快取時

Fetching data…
Loaded data
Data fetched

更糟糕的是,有時元素的 `data` 屬性會被設定,但其他時候它不會在這個程式碼執行完成之前完成。

我們可以透過在 `if` 子句中使用微任務來平衡兩個子句,從而確保這些操作的順序一致

js
customElement.prototype.getData = function (url) {
  if (this.cache[url]) {
    queueMicrotask(() => {
      this.data = this.cache[url];
      this.dispatchEvent(new Event("load"));
    });
  } else {
    fetch(url)
      .then((result) => result.arrayBuffer())
      .then((data) => {
        this.cache[url] = data;
        this.data = data;
        this.dispatchEvent(new Event("load"));
      });
  }
};

這透過在微任務中處理 data 的設定和 load 事件的觸發來平衡這兩個子句(在 if 子句中使用 queueMicrotask(),在 else 子句中使用 fetch() 使用的 Promise)。

批處理操作

您還可以使用微任務將來自各種來源的多個請求收集到一個批次中,從而避免多次呼叫處理相同型別工作可能帶來的開銷。

下面的程式碼片段建立了一個函式,該函式將多個訊息批次儲存到一個數組中,並在上下文退出時使用微任務將它們作為單個物件傳送。

js
const messageQueue = [];

let sendMessage = (message) => {
  messageQueue.push(message);

  if (messageQueue.length === 1) {
    queueMicrotask(() => {
      const json = JSON.stringify(messageQueue);
      messageQueue.length = 0;
      fetch("url-of-receiver", json);
    });
  }
};

當呼叫 sendMessage() 時,指定的訊息首先被推送到訊息佇列陣列中。然後事情變得有趣起來。

如果剛剛新增到陣列中的訊息是第一條,我們會將一個微任務入隊,該微任務將傳送一個批次。微任務將像往常一樣在 JavaScript 執行路徑到達頂層時執行,就在執行回撥之前。這意味著在此期間對 sendMessage() 的任何進一步呼叫都會將其訊息推送到訊息佇列中,但由於在新增微任務之前進行了陣列長度檢查,因此不會有新的微任務入隊。

然後,當微任務執行時,它有一個可能包含許多訊息的陣列等待處理。它首先使用 JSON.stringify() 方法將其編碼為 JSON。之後,不再需要陣列的內容,因此我們清空 messageQueue 陣列。最後,我們使用 fetch() 方法將 JSON 字串傳送到伺服器。

這使得在事件迴圈的同一次迭代中對 sendMessage() 的每次呼叫都可以將其訊息新增到相同的 fetch() 操作中,而不會潛在地導致其他任務(例如超時等)延遲傳輸。

伺服器將收到 JSON 字串,然後大概會對其進行解碼並處理結果陣列中找到的訊息。

示例

簡單微任務示例

在這個簡單的例子中,我們看到入隊一個微任務會導致微任務的回撥在該頂級指令碼主體執行完成後執行。

JavaScript

在以下程式碼中,我們看到呼叫了 queueMicrotask() 以排程一個微任務執行。此呼叫被 log()(一個用於向螢幕輸出文字的自定義函式)的呼叫所包圍。

js
log("Before enqueueing the microtask");
queueMicrotask(() => {
  log("The microtask has run.");
});
log("After enqueueing the microtask");

結果

超時和微任務示例

在此示例中,超時計劃在零毫秒後(或“儘快”)觸發。這演示了排程新任務(例如使用 setTimeout())與使用微任務時“儘快”的含義之間的區別。

JavaScript

在以下程式碼中,我們看到呼叫了 queueMicrotask() 以排程一個微任務執行。此呼叫被 log()(一個用於向螢幕輸出文字的自定義函式)的呼叫所包圍。

下面的程式碼安排了一個在零毫秒後發生的超時,然後將一個微任務入隊。這被 log() 呼叫包圍以輸出更多訊息。

js
const callback = () => log("Regular timeout callback has run");

const urgentCallback = () => log("*** Oh noes! An urgent callback has run!");

log("Main program started");
setTimeout(callback, 0);
queueMicrotask(urgentCallback);
log("Main program exiting");

結果

請注意,首先出現主程式體記錄的輸出,然後是微任務的輸出,最後是超時的回撥。這是因為當處理主程式執行的任務退出時,微任務佇列會在超時回撥所在的任務佇列之前處理。為了幫助理清思路,請記住任務和微任務儲存在單獨的佇列中,並且微任務先執行。

來自函式的微任務

此示例在前面的示例基礎上略作擴充套件,添加了一個執行某些工作的函式。此函式使用 queueMicrotask() 來排程一個微任務。從中獲得的重要一點是,微任務不是在函式退出時處理,而是在主程式退出時處理。

JavaScript

主程式程式碼如下。這裡的 doWork() 函式呼叫了 queueMicrotask(),但微任務仍然要等到整個程式退出時才會被觸發,因為那時任務退出並且執行棧上沒有其他內容。

js
const callback = () => log("Regular timeout callback has run");

const urgentCallback = () => log("*** Oh noes! An urgent callback has run!");

const doWork = () => {
  let result = 1;

  queueMicrotask(urgentCallback);

  for (let i = 2; i <= 10; i++) {
    result *= i;
  }
  return result;
};

log("Main program started");
setTimeout(callback, 0);
log(`10! equals ${doWork()}`);
log("Main program exiting");

結果

另見