JavaScript 執行模型

本頁介紹了 JavaScript 執行時環境的基本基礎設施。該模型在很大程度上是理論性和抽象的,不包含任何特定於平臺或實現的細節。現代 JavaScript 引擎對所描述的語義進行了大量最佳化。

本頁是參考資料。它假定你已經熟悉其他程式語言(如 C 和 Java)的執行模型。它大量引用了作業系統和程式語言中的現有概念。

引擎和主機

JavaScript 執行需要兩種軟體的協作:JavaScript 引擎宿主環境

JavaScript 引擎實現 ECMAScript (JavaScript) 語言,提供核心功能。它接收原始碼,解析並執行它。然而,為了與外部世界互動,例如產生任何有意義的輸出,與外部資源介面,或實現安全或效能相關的機制,我們需要宿主環境提供的額外環境特定機制。例如,當 JavaScript 在 Web 瀏覽器中執行時,HTML DOM 就是宿主環境。Node.js 是另一個宿主環境,允許 JavaScript 在伺服器端執行。

雖然本參考資料主要關注 ECMAScript 中定義的機制,但我們偶爾也會討論 HTML 規範中定義的機制,這些機制通常被其他宿主環境(如 Node.js 或 Deno)模仿。透過這種方式,我們可以對 Web 及其他地方使用的 JavaScript 執行模型給出一個連貫的描述。

代理執行模型

在 JavaScript 規範中,每個自主執行 JavaScript 的執行者都被稱為代理(agent),它維護其程式碼執行設施。

  • (物件堆):這只是一個名稱,表示一大片(大部分是非結構化的)記憶體區域。它會隨著程式中物件的建立而填充。請注意,在共享記憶體的情況下,每個代理都有自己的堆,其中包含自己的 SharedArrayBuffer 物件的版本,但緩衝區表示的底層記憶體是共享的。
  • 佇列(任務佇列):這在 HTML(以及通常)中被稱為事件迴圈,它使 JavaScript 能夠進行非同步程式設計,同時保持單執行緒。它被稱為佇列是因為它通常是先進先出的:較早的任務在較晚的任務之前執行。
  • (執行上下文棧):這就是所謂的呼叫棧,它允許透過進入和退出函式等執行上下文來轉移控制流。它被稱為棧是因為它是後進先出的。每個任務透過將一個新幀推入(空)棧來進入,並透過清空棧來退出。

這些是三種不同的資料結構,用於跟蹤不同的資料。我們將在以下章節中更詳細地介紹佇列和棧。要了解有關堆記憶體如何分配和釋放的更多資訊,請參閱記憶體管理

每個代理都類似於一個執行緒(請注意,底層實現可能是一個實際的作業系統執行緒,也可能不是)。每個代理可以擁有多個 域(realm)(它們與全域性物件一對一關聯),這些域可以同步地相互訪問,因此需要在一個執行執行緒中執行。代理還具有單一的記憶體模型,指示它是小端序還是大端序,它是否可以同步阻塞,原子操作是否無鎖等。

Web 上的代理可以是以下之一:

換句話說,每個 worker 都會建立自己的代理,而一個或多個視窗可能在同一個代理中——通常是一個主文件及其同源 iframe。在 Node.js 中,有一個類似的概念,稱為worker threads

下圖說明了代理的執行模型:

A diagram consisting of two agents: one HTML page and one worker. Each has its own stack containing execution contexts, heap containing objects, and queue containing jobs.

每個代理擁有一或多個域(realm)。每段 JavaScript 程式碼在載入時都會與一個域關聯,即使從另一個域呼叫,該關聯也保持不變。一個域包含以下資訊:

  • 內建物件的列表,如 ArrayArray.prototype 等。
  • 全域性宣告的變數、globalThis 的值和全域性物件。
  • 模板字面量陣列的快取,因為對同一標記模板字面量表達式的求值總是會導致標籤接收到相同的陣列物件。

在 Web 上,域和全域性物件是一一對應的。全域性物件可以是 WindowWorkerGlobalScopeWorkletGlobalScope。因此,例如,每個 iframe 都在不同的域中執行,儘管它可能與父視窗位於同一個代理中。

在談論全域性物件的身份時,通常會提到域。例如,我們需要 Array.isArray()Error.isError() 等方法,因為在另一個域中構造的陣列將擁有一個與當前域中的 Array.prototype 物件不同的原型物件,因此 instanceof Array 將錯誤地返回 false

棧和執行上下文

我們首先考慮同步程式碼執行。每個 任務 都透過呼叫其關聯的回撥函式來進入。此回撥函式中的程式碼可以建立變數、呼叫函式或退出。每個函式都需要跟蹤自己的變數環境和返回位置。為了處理這個問題,代理需要一個棧來跟蹤執行上下文。執行上下文,通常也稱為棧幀(stack frame),是最小的執行單元。它跟蹤以下資訊:

  • 程式碼評估狀態
  • 包含此程式碼的模組或指令碼、函式(如果適用)以及當前正在執行的生成器
  • 當前
  • 繫結,包括
    • varletconstfunctionclass 等定義的變數。
    • 僅在當前上下文中有效的私有識別符號,如 #foo
    • this 引用

設想一個由以下程式碼定義的單任務程式:

js
function foo(b) {
  const a = 10;
  return a + b + 11;
}

function bar(x) {
  const y = 3;
  return foo(x * y);
}

const baz = bar(7); // assigns 42 to baz
  1. 當任務開始時,第一個幀被建立,其中定義了變數 foobarbaz。它用引數 7 呼叫 bar
  2. bar 呼叫建立了第二個幀,其中包含引數 x 和區域性變數 y 的繫結。它首先執行乘法 x * y,然後用結果呼叫 foo
  3. foo 呼叫建立了第三個幀,其中包含引數 b 和區域性變數 a 的繫結。它首先執行加法 a + b + 11,然後返回結果。
  4. foo 返回時,棧頂幀元素被彈出,呼叫表示式 foo(x * y) 解析為返回值。然後它繼續執行,也就是返回這個結果。
  5. bar 返回時,棧頂幀元素被彈出,呼叫表示式 bar(7) 解析為返回值。這將用返回值初始化 baz
  6. 我們到達了任務原始碼的末尾,因此入口點的棧幀從棧中彈出。棧為空,因此任務被認為已完成。

生成器與重入

當一個幀被彈出時,它不一定永遠消失,因為有時我們需要回到它。例如,考慮一個生成器函式:

js
function* gen() {
  console.log(1);
  yield;
  console.log(2);
}

const g = gen();
g.next(); // logs 1
g.next(); // logs 2

在這種情況下,呼叫 gen() 首先會建立一個被暫停的執行上下文——gen 內部的程式碼尚未執行。生成器 g 在內部儲存這個執行上下文。當前執行的執行上下文仍然是入口點。當呼叫 g.next() 時,gen 的執行上下文被推到棧上,並且 gen 內部的程式碼執行直到 yield 表示式。然後,生成器執行上下文被暫停並從棧中移除,這將控制權返回給入口點。當再次呼叫 g.next() 時,生成器執行上下文被重新推到棧上,並且 gen 內部的程式碼從上次離開的地方恢復執行。

尾呼叫

規範中定義的一種機制是尾呼叫最佳化(PTC)。如果呼叫者在呼叫後除了返回值之外不執行任何操作,則函式呼叫是尾呼叫。

js
function f() {
  return g();
}

在這種情況下,對 g 的呼叫是一個尾呼叫。如果函式呼叫處於尾位置,引擎需要丟棄當前的執行上下文,並將其替換為尾呼叫的上下文,而不是為 g() 呼叫推送一個新的幀。這意味著尾遞迴不受棧大小限制的約束。

js
function factorial(n, acc = 1) {
  if (n <= 1) return acc;
  return factorial(n - 1, n * acc);
}

實際上,丟棄當前幀會導致除錯問題,因為如果 g() 丟擲錯誤,f 將不再在棧上,並且不會出現在棧跟蹤中。目前,只有 Safari (JavaScriptCore) 實現了 PTC,並且他們發明了一些特定的基礎設施來解決可除錯性問題。

閉包

另一個與變數作用域和函式呼叫相關的有趣現象是閉包。每當建立一個函式時,它也會在內部記住當前執行執行上下文的變數繫結。然後,這些變數繫結可以比執行上下文的生命週期更長。

js
let f;
{
  let x = 10;
  f = () => x;
}
console.log(f()); // logs 10

任務佇列和事件迴圈

代理是一個執行緒,這意味著直譯器一次只能處理一條語句。當代碼全部是同步的時候,這沒問題,因為我們總能取得進展。但如果程式碼需要執行非同步操作,那麼除非該操作完成,否則我們無法取得進展。然而,如果這會暫停整個程式,那將對使用者體驗造成損害——JavaScript 作為一種 Web 指令碼語言的性質要求它永不阻塞。因此,處理非同步操作完成的程式碼被定義為回撥。一旦操作完成,此回撥會定義一個任務,該任務被放置到任務佇列中——或者,用 HTML 術語來說,是事件迴圈。

每次,代理都會從佇列中拉取一個任務並執行它。當任務執行時,它可能會建立更多工,這些任務會新增到佇列的末尾。任務也可以透過非同步平臺機制的完成來新增,例如計時器、I/O 和事件。當為空時,任務被認為是完成的;然後,從佇列中拉取下一個任務。任務可能不會以統一的優先順序拉取——例如,HTML 事件迴圈將任務分為兩類:宏任務(tasks)微任務(microtasks)。微任務具有更高的優先順序,微任務佇列在拉取任務佇列之前被清空。有關更多資訊,請查閱 HTML 微任務指南。如果任務佇列為空,代理會等待更多工被新增。

“執行到完成”

每個任務在處理其他任務之前都完全處理。這在推斷程式時提供了一些很好的特性,包括無論何時函式執行,它都不能被搶佔,並且會在任何其他程式碼執行(並可能修改函式操作的資料)之前完全執行。這與 C 不同,例如,在 C 中,如果一個函式在一個執行緒中執行,它可能會在任何時候被執行時系統停止,以在另一個執行緒中執行其他程式碼。

例如,考慮以下示例:

js
const promise = Promise.resolve();
let i = 0;
promise.then(() => {
  i += 1;
  console.log(i);
});
promise.then(() => {
  i += 1;
  console.log(i);
});

在此示例中,我們建立了一個已經解析的 promise,這意味著附加到它的任何回撥都將立即作為任務進行排程。這兩個回撥似乎會導致競態條件,但實際上,輸出是完全可預測的:12 將按順序記錄。這是因為每個任務在下一個任務執行之前都會執行到完成,所以總的順序始終是 i += 1; console.log(i); i += 1; console.log(i);,而不是 i += 1; i += 1; console.log(i); console.log(i);

這種模式的一個缺點是,如果一個任務完成時間過長,Web 應用程式將無法處理使用者互動,如點選或滾動。瀏覽器透過“指令碼執行時間過長”對話方塊來緩解這個問題。一個好的做法是使任務處理時間短,如果可能,將一個任務分解成多個任務。

永不阻塞

事件迴圈模型提供的另一個重要保證是 JavaScript 執行永不阻塞。I/O 處理通常透過事件和回撥執行,因此當應用程式等待 IndexedDB 查詢返回或 fetch() 請求返回時,它仍然可以處理其他事情,例如使用者輸入。非同步操作完成後執行的程式碼總是作為回撥函式提供(例如,promise 的 then() 處理程式、setTimeout() 中的回撥函式或事件處理程式),它定義了一個在操作完成後新增到任務佇列的任務。

當然,“永不阻塞”的保證要求平臺 API 本質上是非同步的,但存在一些遺留的例外,如 alert() 或同步 XHR。為了確保應用程式的響應能力,通常認為避免使用它們是最佳實踐。

代理叢集和記憶體共享

多個代理可以透過記憶體共享進行通訊,形成一個代理叢集。當且僅當代理可以共享記憶體時,它們才屬於同一個叢集。兩個代理叢集之間沒有內建機制可以交換任何資訊,因此它們可以被視為完全隔離的執行模型。

在建立代理(例如透過派生 worker)時,有一些標準可以判斷它是否與當前代理在同一叢集中,或者是否建立了一個新叢集。例如,以下全域性物件對都在同一個代理叢集中,因此可以相互共享記憶體:

  • 一個 Window 物件和它建立的一個專用 worker。
  • 一個 worker(任何型別)和它建立的一個專用 worker。
  • 一個 Window 物件 A 和 A 建立的同源 iframe 元素的 Window 物件。
  • 一個 Window 物件和開啟它的同源 Window 物件。
  • 一個 Window 物件和它建立的一個 Worklet。

以下全域性物件對不在同一個代理叢集中,因此無法共享記憶體:

  • 一個 Window 物件和它建立的一個共享 worker。
  • 一個 worker(任何型別)和它建立的一個共享 worker。
  • 一個 Window 物件和它建立的一個服務 worker。
  • 一個 Window 物件 A 和 A 建立的 iframe 元素的 Window 物件,該 iframe 與 A 不能同源。
  • 任何兩個沒有 opener 或祖先關係的 Window 物件。即使這兩個 Window 物件同源,也成立。

有關精確的演算法,請查閱 HTML 規範

跨代理通訊和記憶體模型

如前所述,代理透過記憶體共享進行通訊。在 Web 上,記憶體透過 postMessage() 方法共享。使用 Web Workers 指南提供了相關概述。通常,資料僅按值傳遞(透過結構化克隆),因此不涉及任何併發複雜性。要共享記憶體,必須傳送一個 SharedArrayBuffer 物件,該物件可以由多個代理同時訪問。一旦兩個代理透過 SharedArrayBuffer 共享訪問同一記憶體,它們就可以透過 Atomics 物件同步執行。

有兩種訪問共享記憶體的方式:透過普通記憶體訪問(非原子)和透過原子記憶體訪問。後者是順序一致的(這意味著叢集中所有代理都同意事件存在嚴格的總序),而前者是無序的(這意味著不存在排序);JavaScript 不提供其他排序保證的操作。

規範為處理共享記憶體的程式設計師提供了以下指導:

我們建議程式保持無資料競爭,即確保不可能在同一記憶體位置上同時進行非原子操作。無資料競爭的程式具有交錯語義,其中每個代理的評估語義中的每一步都相互交錯。對於無資料競爭的程式,無需理解記憶體模型的細節。這些細節不太可能建立有助於更好地編寫 ECMAScript 的直覺。

更一般地,即使程式存在資料競爭,它也可能具有可預測的行為,只要原子操作不涉及任何資料競爭,並且所有競爭操作都具有相同的訪問大小。避免原子操作涉及競爭的最簡單方法是確保原子操作和非原子操作使用不同的記憶體單元,並且不同大小的原子訪問不會同時訪問相同的記憶體單元。實際上,程式應該儘可能將共享記憶體視為強型別。你仍然不能依賴競爭的非原子訪問的排序和時間,但如果記憶體被視為強型別,則競爭的訪問不會“撕裂”(它們的值的位不會混淆)。

併發與確保向前進展

當多個代理協作時,永不阻塞的保證並非總是成立。代理可能會被阻塞或暫停,等待另一個代理執行某些操作。這與在同一代理中等待 promise 不同,因為它會停止整個代理,並且在此期間不允許任何其他程式碼執行——換句話說,它無法實現向前進展

為了防止死鎖,對何時以及哪些代理可能被阻塞有一些嚴格的限制。

  • 每個具有專用執行執行緒的未阻塞代理最終都會向前進展。
  • 在一組共享執行執行緒的代理中,一個代理最終會向前進展。
  • 代理不會導致另一個代理被阻塞,除非透過提供阻塞的顯式 API。
  • 只有某些代理可以被阻塞。在 Web 上,這包括專用 worker 和共享 worker,但不包括同源視窗或 service worker。

在外部暫停或終止的情況下,代理叢集確保其代理的活躍度保持一定程度的完整性。

  • 代理可能會在不知情或不配合的情況下暫停或恢復。例如,離開視窗可能會暫停程式碼執行但保留其狀態。然而,不允許代理叢集部分停用,以避免代理因另一個代理停用而餓死。例如,共享 worker 從來不會與建立者視窗或其他專用 worker 處於同一個代理叢集中。這是因為共享 worker 的生命週期獨立於文件:如果一個文件在其專用 worker 持有鎖時停用,那麼共享 worker 將被阻止獲取鎖,直到專用 worker 被重新啟用(如果會的話)。與此同時,其他試圖從其他視窗訪問共享 worker 的 worker 將會餓死。
  • 同樣,代理可能會因叢集外部因素而終止。例如,作業系統或使用者關閉瀏覽器程序,或者瀏覽器強制終止某個代理因為它使用了太多資源。在這種情況下,叢集中的所有代理都會被終止。(規範還允許第二種策略,即一個 API 允許叢集中至少一個剩餘成員識別終止和被終止的代理,但這在 Web 上尚未實現。)

規範

規範
ECMAScript® 2026 語言規範
ECMAScript® 2026 語言規範
HTML

另見