深入:Microtask 和 JavaScript 執行時環境

在除錯或嘗試確定解決與任務和微任務的時間安排相關問題的最佳方法時,瞭解 JavaScript 執行時在幕後的操作方式可能很有用。

JavaScript 本質上是一種單執行緒語言。它設計於這樣一個時代:單執行緒是一個積極的選擇;當時普通大眾很少能接觸到多處理器計算機,而且 JavaScript 預期的程式碼處理量也相對較低。

當然,隨著時間的推移,我們知道計算機已經發展成為功能強大的多核系統,而 JavaScript 已成為計算世界中使用最廣泛的語言之一。大量最流行的應用程式至少部分基於 JavaScript 程式碼。為了支援這一點,有必要找到方法讓專案擺脫單執行緒語言的限制。

從 Web API 新增超時和間隔(setTimeout()setInterval())開始,Web 瀏覽器提供的 JavaScript 環境逐漸發展,以包含強大的功能,實現任務排程、多執行緒應用程式開發等。為了理解 queueMicrotask() 在此處的用武之地,瞭解 JavaScript 執行時在排程和執行程式碼時如何操作會有所幫助。

JavaScript 執行上下文

注意: 此處的細節通常對大多數 JavaScript 程式設計師來說並不重要。此資訊作為微任務為何有用以及它們如何運作的基礎提供;如果您不關心,可以跳過此部分,如果以後發現需要,再回來檢視。

當一段 JavaScript 程式碼執行時,它在一個執行上下文中執行。有三種類型的程式碼會建立新的執行上下文

  • 全域性上下文是為執行程式碼主體而建立的執行上下文;也就是說,任何存在於 JavaScript 函式之外的程式碼。
  • 每個函式都在其自己的執行上下文中執行。這通常被稱為“區域性上下文”。
  • 使用不明智的 eval() 函式也會建立新的執行上下文。

每個上下文字質上是程式碼中的一個作用域級別。當這些程式碼段之一開始執行時,會構建一個新的上下文來執行它;當代碼退出時,該上下文就會被銷燬。考慮下面的 JavaScript 程式

js
const outputElem = document.getElementById("output");

const userLanguages = {
  Mike: "en",
  Teresa: "es",
};

function greetUser(user) {
  function localGreeting(user) {
    let greeting;
    const language = userLanguages[user];

    switch (language) {
      case "es":
        greeting = `¡Hola, ${user}!`;
        break;
      case "en":
      default:
        greeting = `Hello, ${user}!`;
        break;
    }
    return greeting;
  }
  outputElem.innerText += `${localGreeting(user)}\n`;
}

greetUser("Mike");
greetUser("Teresa");
greetUser("Veronica");

這個簡短的程式包含三個執行上下文,其中一些在程式執行過程中會建立和銷燬多次。當每個上下文被建立時,它被放置在執行上下文堆疊上。當它退出時,該上下文將從上下文堆疊中移除。

  • 程式啟動時,建立全域性上下文。

    • 當到達 greetUser("Mike") 時,為 greetUser() 函式建立一個上下文;此執行上下文被推送到執行上下文堆疊上。

      • greetUser() 呼叫 localGreeting() 時,會建立另一個上下文來執行該函式。當此函式返回時,localGreeting() 的上下文將從執行堆疊中移除並銷燬。程式執行將從堆疊中找到的下一個上下文 greetUser() 繼續;此函式將從它離開的地方恢復執行。
      • greetUser() 函式返回,其上下文從堆疊中移除並銷燬。
    • 當到達 greetUser("Teresa") 時,為其建立一個上下文並推送到堆疊上。

      • greetUser() 呼叫 localGreeting() 時,會建立另一個上下文來執行該函式。當此函式返回時,localGreeting() 的上下文將從執行堆疊中移除並銷燬。greetUser() 繼續從它離開的地方執行。
      • greetUser() 函式返回,其上下文從堆疊中移除並銷燬。
    • 當到達 greetUser("Veronica") 時,為其建立一個上下文並推送到堆疊上。

      • greetUser() 呼叫 localGreeting() 時,會建立另一個上下文來執行該函式。當此函式返回時,localGreeting() 的上下文將從執行堆疊中移除並銷燬。
      • greetUser() 函式返回,其上下文從堆疊中移除並銷燬。
  • 主程式退出,其執行上下文從執行堆疊中移除;由於堆疊上沒有剩餘的上下文,程式執行結束。

以這種方式使用執行上下文,每個程式和函式都能夠擁有自己的一組變數和其他物件。每個上下文還跟蹤程式中應該執行的下一行以及對該上下文操作至關重要的其他資訊。透過以這種方式使用上下文和上下文堆疊,可以管理程式操作的許多基本原理,包括區域性變數和全域性變數、函式呼叫和返回等等。

關於遞迴函式的一個特別說明——也就是說,呼叫自身的函式,可能跨越多個深度或遞迴級別:對函式的每次遞迴呼叫都會建立一個新的執行上下文。這允許 JavaScript 執行時跟蹤遞迴的級別以及透過該遞迴返回結果,但也意味著每次函式遞迴時,都需要更多的記憶體來建立新的上下文。

執行吧,JavaScript,執行吧

為了執行 JavaScript 程式碼,執行時引擎維護一組代理來執行 JavaScript 程式碼。每個代理由一組執行上下文、執行上下文堆疊、一個主執行緒、一組用於處理 worker 的任何額外執行緒、一個任務佇列和一個微任務佇列組成。除了主執行緒——一些瀏覽器在多個代理之間共享——代理的每個元件都是該代理獨有的。

在這裡,我們更詳細地瞭解執行時如何運作。

事件迴圈

每個代理都由一個事件迴圈驅動,該事件迴圈會反覆處理。在每次迭代中,它最多執行一個掛起的 JavaScript 任務,然後是任何掛起的微任務,然後執行任何必要的渲染和繪製,然後再次迴圈。

您的網站或應用程式程式碼與 Web 瀏覽器本身的使用者介面在相同的執行緒中執行,共享相同的事件迴圈。這就是主執行緒,除了執行您網站的主程式碼體之外,它還處理接收和分派使用者和其他事件、渲染和繪製 Web 內容等等。

因此,事件迴圈驅動著瀏覽器中發生的一切,因為它與使用者互動有關,但更重要的是,它負責排程和執行在其執行緒中執行的每段程式碼。

有三種類型的事件迴圈

視窗事件迴圈

視窗事件迴圈是驅動所有共享類似源的視窗的事件迴圈(儘管對此還有進一步的限制,如下所述)。

Worker 事件迴圈

Worker 事件迴圈是驅動 worker 的事件迴圈;這包括所有形式的 worker,包括基本的Web WorkersShared WorkersService Workers。Worker 保持在一個或多個獨立於“主”程式碼的代理中;瀏覽器可以使用單個事件迴圈來處理給定型別的所有 worker,或者可以使用多個事件迴圈來處理它們。

Worklet 事件迴圈

Worklet 事件迴圈是用於驅動執行給定代理的 worklet 程式碼的代理的事件迴圈。這包括 WorkletAudioWorklet 型別的 worklet。

從同一載入的多個視窗可能在同一事件迴圈上執行,每個視窗都將任務排入事件迴圈,以便它們的任務輪流使用處理器,一個接一個。請記住,在 Web 術語中,“視窗”實際上是指“Web 內容執行的瀏覽器級容器”,包括實際的視窗、選項卡或框架。

在特定情況下,具有共同源的視窗之間可以共享事件迴圈,例如

  • 如果一個視窗打開了另一個視窗,它們很可能會共享一個事件迴圈。
  • 如果一個視窗實際上是 <iframe> 中的容器,它很可能與包含它的視窗共享一個事件迴圈。
  • 在多程序 Web 瀏覽器實現中,這些視窗碰巧共享相同的程序。

具體細節可能因瀏覽器而異,取決於它們的實現方式。

任務與微任務

任務是任何透過標準機制(例如最初開始執行指令碼、非同步分派事件等)安排執行的內容。除了使用事件之外,您還可以使用 setTimeout()setInterval() 將任務排隊。

任務佇列和微任務佇列之間的區別很簡單但非常重要

  • 當事件迴圈的新迭代開始時,執行時會從任務佇列中執行下一個任務。在此迭代開始後新增到佇列中的後續任務和任務將不會執行,直到下一次迭代
  • 每當任務退出且執行上下文堆疊為空時,微任務佇列中的所有微任務都會依次執行。不同之處在於微任務的執行會持續到佇列為空為止——即使在此期間安排了新的微任務。換句話說,微任務可以排隊新的微任務,這些新的微任務將在下一個任務開始執行之前以及當前事件迴圈迭代結束之前執行。

問題

由於您的程式碼與瀏覽器的使用者介面在相同的執行緒中執行,並使用相同的事件迴圈,如果您的程式碼阻塞或進入無限迴圈,瀏覽器本身就會停滯。即使是緩慢的效能,無論是由於錯誤還是由於您的程式碼正在進行復雜的工作,都可能導致使用者遭受緩慢的瀏覽器。

當多個程式和這些程式中的多個程式碼物件開始嘗試同時工作時,再加上瀏覽器也需要處理器時間——更不用說渲染和繪製網站及其自身 UI、處理使用者事件等的時間了——如今一切都太容易堵塞了。

解決方案

使用Web Workers,它允許主指令碼在新執行緒中執行其他指令碼,有助於緩解這個問題。設計良好的網站或應用程式使用 Workers 執行任何複雜或耗時的操作,讓主執行緒在更新、佈局和渲染網頁之外儘可能少地工作。

透過使用非同步 JavaScript 技術(例如Promise)來進一步緩解這個問題,它允許主程式碼在等待請求結果的同時繼續執行。然而,在更基礎層面執行的程式碼——例如構成庫或框架的程式碼——可能需要一種方式來安排程式碼在安全的時間執行,同時仍在主執行緒上執行,而不依賴於任何單個請求或任務的結果。

微任務是解決這個問題的另一種方案,它提供了更精細的訪問程度,使得在事件迴圈的下一次迭代開始之前就可以安排程式碼執行,而不是必須等到下一次迭代。

微任務佇列已經存在了一段時間,但它歷史上僅在內部用於驅動 Promise 等事物。新增 queueMicrotask(),將其暴露給 Web 開發人員,建立了一個統一的微任務佇列,無論何時需要在 JavaScript 執行上下文堆疊上沒有執行上下文時安全地安排程式碼執行,都會使用它。跨多個例項以及所有瀏覽器和 JavaScript 執行時,標準化的佇列機制意味著這些微任務將以相同的順序可靠地執行,從而避免可能難以發現的錯誤。

另見