JavaScript 資源管理

本指南討論如何在 JavaScript 中進行資源管理。資源管理與記憶體管理不完全相同,後者是一個更高階的主題,通常由 JavaScript 自動處理。資源管理涉及管理由 JavaScript 自動清理的資源。有時,記憶體中存在一些未使用的物件是可以接受的,因為它們不會干擾應用程式邏輯,但資源洩漏通常會導致功能失常或大量記憶體佔用。因此,這並非關於最佳化的可選功能,而是編寫正確程式的核心功能!

注意:雖然記憶體管理和資源管理是兩個獨立的主題,但有時你可以作為最後的手段,利用記憶體管理系統進行資源管理。例如,如果你的 JavaScript 物件代表外部資源的控制代碼,你可以建立一個FinalizationRegistry,在控制代碼被垃圾回收時清理資源,因為此後肯定無法訪問該資源。然而,無法保證終結器一定會執行,因此不建議依賴它來處理關鍵資源。

問題

我們首先看幾個需要管理的資源示例

  • 檔案控制代碼:檔案控制代碼用於讀取和寫入檔案中的位元組。使用完畢後,必須呼叫fileHandle.close(),否則檔案將保持開啟狀態,即使 JS 物件不再可訪問。正如連結的 Node.js 文件所述:

    如果 <FileHandle> 未使用 fileHandle.close() 方法關閉,它將嘗試自動關閉檔案描述符併發出程序警告,有助於防止記憶體洩漏。請不要依賴此行為,因為它可能不可靠且檔案可能未關閉。相反,請始終顯式關閉 <FileHandle>。Node.js 將來可能會更改此行為。

  • 網路連線:某些連線,例如 WebSocketRTCPeerConnection,如果未傳輸訊息,則需要關閉。否則,連線將保持開啟狀態,並且連線池的大小通常非常有限。

  • 流讀取器:如果你不呼叫 ReadableStreamDefaultReader.releaseLock(),流將被鎖定,不允許其他讀取器使用它。

這是一個具體示例,使用可讀流

js
const stream = new ReadableStream({
  start(controller) {
    controller.enqueue("a");
    controller.enqueue("b");
    controller.enqueue("c");
    controller.close();
  },
});

async function readUntil(stream, text) {
  const reader = stream.getReader();
  let chunk = await reader.read();

  while (!chunk.done && chunk.value !== text) {
    console.log(chunk);
    chunk = await reader.read();
  }
  // We forgot to release the lock here
}

readUntil(stream, "b").then(() => {
  const anotherReader = stream.getReader();
  // TypeError: ReadableStreamDefaultReader constructor can only
  // accept readable streams that are not yet locked to a reader
});

這裡,我們有一個發出三個資料塊的流。我們從流中讀取,直到找到字母“b”。當 readUntil 返回時,流只被部分消耗,因此我們應該能夠使用另一個讀取器繼續從其中讀取。然而,我們忘記釋放鎖,所以儘管 reader 不再可用,但流仍被鎖定,我們無法建立另一個讀取器。

在這種情況下,解決方案很簡單:在 readUntil 的末尾呼叫 reader.releaseLock()。但是,仍然存在一些問題

  • 不一致性:不同的資源有不同的釋放方式。例如,我們有 close()releaseLock()disconnect() 等。這種模式不具備通用性。

  • 錯誤處理:如果 reader.read() 呼叫失敗會發生什麼?那麼 readUntil 將終止,並且永遠不會到達 reader.releaseLock() 呼叫。我們可以使用 try...finally 修復此問題

    js
    async function readUntil(stream, text) {
      const reader = stream.getReader();
      try {
        let chunk = await reader.read();
    
        while (!chunk.done && chunk.value !== text) {
          console.log(chunk);
          chunk = await reader.read();
        }
      } finally {
        reader.releaseLock();
      }
    }
    

    但每次你有重要的資源要釋放時,你都必須記住這樣做。

  • 作用域:在上面的例子中,當我們退出 try...finally 語句時,reader 已經關閉,但它在其作用域中仍然可用。這意味著你可能會在它關閉後意外地使用它。

  • 多重資源:如果我們對不同的流有兩個讀取器,我們必須記住釋放它們兩者。這是一個值得稱讚的嘗試

    js
    const reader1 = stream1.getReader();
    const reader2 = stream2.getReader();
    try {
      // do something with reader1 and reader2
    } finally {
      reader1.releaseLock();
      reader2.releaseLock();
    }
    

    然而,這引入了更多的錯誤處理麻煩。如果 stream2.getReader() 丟擲,那麼 reader1 就不會被釋放;如果 reader1.releaseLock() 丟擲錯誤,那麼 reader2 就不會被釋放。這意味著我們實際上必須將每個資源獲取-釋放對都包裝在自己的 try...finally

    js
    const reader1 = stream1.getReader();
    try {
      const reader2 = stream2.getReader();
      try {
        // do something with reader1 and reader2
      } finally {
        reader2.releaseLock();
      }
    } finally {
      reader1.releaseLock();
    }
    

您可以看到,呼叫 releaseLock 這樣看似無害的任務,很快就會導致巢狀的樣板程式碼。這就是 JavaScript 為資源管理提供整合語言支援的原因。

usingawait using 宣告

我們的解決方案是兩種特殊的變數宣告:usingawait using。它們類似於 const,但只要資源是可處置的,它們就會在變數超出作用域時自動釋放資源。以上面的相同示例,我們可以將其重寫為:

js
{
  using reader1 = stream1.getReader();
  using reader2 = stream2.getReader();

  // do something with reader1 and reader2

  // Before we exit the block, reader1 and reader2 are automatically released
}

注意:在撰寫本文時,ReadableStreamDefaultReader 未實現可處置協議。這是一個假設的示例。

首先,請注意程式碼周圍額外的花括號。這為 using 宣告建立了一個新的塊作用域。用 using 宣告的資源在超出 using 的作用域時會自動釋放,在這種情況下,無論是因為所有語句都已執行,還是因為某個地方遇到了錯誤或 return/break/continue,都會在退出塊時釋放。

這意味著 using 只能在具有明確生命週期的作用域中使用——也就是說,它不能在指令碼的頂層使用,因為指令碼頂層的變數在頁面所有未來的指令碼中都處於作用域內,這實際上意味著如果頁面永不解除安裝,資源就永遠無法釋放。但是,你可以在模組的頂層使用它,因為模組作用域在模組執行完畢時結束。

現在我們知道 using 何時進行清理。但它是如何完成的呢?using 要求資源實現可處置協議。如果物件具有 [Symbol.dispose]() 方法,則它是可處置的。此方法在沒有引數的情況下被呼叫以執行清理。例如,在讀取器的情況下,[Symbol.dispose] 屬性可以是 releaseLock 的簡單別名或包裝器

js
// For demonstration
class MyReader {
  // A wrapper
  [Symbol.dispose]() {
    this.releaseLock();
  }
  releaseLock() {
    // Logic to release resources
  }
}

// OR, an alias
MyReader.prototype[Symbol.dispose] = MyReader.prototype.releaseLock;

透過可處置協議,using 可以以一致的方式處置所有資源,而無需瞭解資源的型別。

每個作用域都帶有一個與其關聯的資源列表,按宣告順序排列。當作用域退出時,資源將按反向順序處置,透過呼叫它們的 [Symbol.dispose]() 方法。例如,在上面的示例中,reader1reader2 之前宣告,因此 reader2 首先被處置,然後是 reader1。在嘗試處置某個資源時丟擲的錯誤不會阻止其他資源的處置。這與 try...finally 模式一致,並尊重資源之間可能存在的依賴關係。

await usingusing 非常相似。語法告訴你某個地方會發生 await——不是在資源宣告時,而是在它被處置時。await using 要求資源是非同步可處置的,這意味著它有一個 [Symbol.asyncDisposable]() 方法。此方法在沒有引數的情況下被呼叫,並返回一個 Promise,該 Promise 在清理完成時解決。這在清理是非同步的情況下很有用,例如 fileHandle.close(),在這種情況下,處置的結果只能非同步得知。

js
{
  await using fileHandle = open("file.txt", "w");
  await fileHandle.write("Hello");

  // fileHandle.close() is called and awaited
}

因為 await using 需要執行 await,所以它只允許在允許 await 的上下文中使用,這包括 async 函式內部和模組中的頂層 await

資源是按順序清理的,而不是併發的:一個資源的 [Symbol.asyncDispose]() 方法的返回值將在下一個資源的 [Symbol.asyncDispose]() 方法被呼叫之前被 await

需要注意的一些事項

  • usingawait using選擇性加入的。如果你使用 letconstvar 宣告你的資源,則不會發生自動處置,就像任何其他不可處置的值一樣。
  • usingawait using 要求資源是可處置的(或非同步可處置的)。如果資源分別沒有 [Symbol.dispose]()[Symbol.asyncDispose]() 方法,你將在宣告行收到 TypeError。但是,資源可以是 nullundefined,允許你條件性地獲取資源。
  • const 類似,usingawait using 變數不能被重新賦值,儘管它們所持有的物件的屬性可以被改變。然而,[Symbol.dispose]()/[Symbol.asyncDispose]() 方法在宣告時就已經儲存,因此在聲明後改變該方法不會影響清理。
  • 當將作用域與資源生命週期混淆時,會出現一些陷阱。有關幾個示例,請參見using

DisposableStackAsyncDisposableStack 物件

usingawait using 是特殊的語法。語法很方便,隱藏了大量的複雜性,但有時你需要手動操作。

一個常見的例子是:如果你不想在當前作用域結束時處置資源,而是在稍後的作用域中處置呢?考慮這種情況

js
let reader;
if (someCondition) {
  reader = stream.getReader();
} else {
  reader = stream.getReader({ mode: "byob" });
}

如我們所說,using 類似於 const:它必須被初始化且不能被重新賦值,所以你可能會嘗試這樣做

js
if (someCondition) {
  using reader = stream.getReader();
} else {
  using reader = stream.getReader({ mode: "byob" });
}

然而,這意味著所有邏輯都必須寫在 ifelse 內部,導致大量重複。我們想要做的是在一個作用域中獲取和註冊資源,但在另一個作用域中處置它。為此,我們可以使用一個 DisposableStack,它是一個包含可處置資源集合且自身可處置的物件

js
{
  using disposer = new DisposableStack();
  let reader;
  if (someCondition) {
    reader = disposer.use(stream.getReader());
  } else {
    reader = disposer.use(stream.getReader({ mode: "byob" }));
  }
  // Do something with reader
  // Before scope exit, disposer is disposed, which disposes reader
}

你可能有一個尚未實現可處置協議的資源,因此它將被 using 拒絕。在這種情況下,你可以使用 adopt()

js
{
  using disposer = new DisposableStack();
  // Suppose reader does not have the [Symbol.dispose]() method,
  // then it cannot be used with using.
  // However, we can manually pass a disposer function to disposer.adopt
  const reader = disposer.adopt(stream.getReader(), (reader) =>
    reader.releaseLock(),
  );
  // Do something with reader
  // Before scope exit, disposer is disposed, which disposes reader
}

你可能有一個處置操作要執行,但它不“繫結”到任何特定資源。也許你只是想在同時開啟多個連線時記錄一條訊息,例如“所有資料庫連線已關閉”。在這種情況下,你可以使用 defer()

js
{
  using disposer = new DisposableStack();
  disposer.defer(() => console.log("All database connections closed"));
  const connection1 = disposer.use(openConnection());
  const connection2 = disposer.use(openConnection());
  // Do something with connection1 and connection2
  // Before scope exit, disposer is disposed, which first disposes connection1
  // and connection2 and then logs the message
}

你可能想進行有條件的處置——例如,只在發生錯誤時處置已宣告的資源。在這種情況下,你可以使用 move() 來保留否則將被處置的資源。

js
class MyResource {
  #resource1;
  #resource2;
  #disposables;
  constructor() {
    using disposer = new DisposableStack();
    this.#resource1 = disposer.use(getResource1());
    this.#resource2 = disposer.use(getResource2());
    // If we made it here, then there were no errors during construction and
    // we can safely move the disposables out of `disposer` and into `#disposables`.
    this.#disposables = disposer.move();
    // If construction failed, then `disposer` would be disposed before reaching
    // the line above, disposing `#resource1` and `#resource2`.
  }
  [Symbol.dispose]() {
    this.#disposables.dispose(); // Dispose `#resource2` and `#resource1`.
  }
}

AsyncDisposableStack 類似於 DisposableStack,但用於非同步可處置資源。它的 use() 方法期望一個非同步可處置物件,它的 adopt() 方法期望一個非同步清理函式,而它的 dispose() 方法期望一個非同步回撥。它提供了一個 [Symbol.asyncDispose]() 方法。如果你有同步和非同步資源的混合,你仍然可以向它傳遞同步資源。

DisposableStack 的參考資料包含更多示例和詳細資訊。

錯誤處理

資源管理功能的一個主要用例是確保資源始終被處置,即使發生錯誤也是如此。讓我們探討一些複雜的錯誤處理場景。

我們從以下程式碼開始,透過使用 using,它可以健壯地應對錯誤

js
async function readUntil(stream, text) {
  // Use `using` instead of `await using` because `releaseLock` is synchronous
  using reader = stream.getReader();
  let chunk = await reader.read();

  while (!chunk.done && chunk.value !== text) {
    console.log(chunk.toUpperCase());
    chunk = await reader.read();
  }
}

假設 chunk 結果為 null。那麼 toUpperCase() 將丟擲 TypeError,導致函式終止。在函式退出之前,stream[Symbol.dispose]() 被呼叫,它釋放了流上的鎖。

js
const stream = new ReadableStream({
  start(controller) {
    controller.enqueue("a");
    controller.enqueue(null);
    controller.enqueue("b");
    controller.enqueue("c");
    controller.close();
  },
});

readUntil(stream, "b")
  .catch((e) => console.error(e)) // TypeError: chunk.toUpperCase is not a function
  .then(() => {
    const anotherReader = stream.getReader();
    // Successfully creates another reader
  });

因此,using 不會吞噬任何錯誤:所有發生的錯誤仍然會被丟擲,但資源會在丟擲之前關閉。現在,如果資源清理本身也丟擲錯誤會發生什麼?讓我們使用一個更刻意的例子

js
class MyReader {
  [Symbol.dispose]() {
    throw new Error("Failed to release lock");
  }
}

function doSomething() {
  using reader = new MyReader();
  throw new Error("Failed to read");
}

try {
  doSomething();
} catch (e) {
  console.error(e); // SuppressedError: An error was suppressed during disposal
}

doSomething() 呼叫中生成了兩個錯誤:一個是在 doSomething 期間丟擲的錯誤,另一個是因為第一個錯誤導致在處置 reader 期間丟擲的錯誤。這兩個錯誤一起丟擲,因此你捕獲到的是一個 SuppressedError。這是一個特殊的錯誤,它包裝了兩個錯誤:error 屬性包含後面的錯誤,suppressed 屬性包含較早的錯誤,該錯誤被後面的錯誤“抑制”。

如果我們將多個資源,並且它們在處置期間丟擲錯誤(這種情況應該極其罕見——處置失敗本身就很少見!),那麼每個較早的錯誤都會被較晚的錯誤抑制,形成一個抑制錯誤的鏈。

js
class MyReader {
  [Symbol.dispose]() {
    throw new Error("Failed to release lock on reader");
  }
}

class MyWriter {
  [Symbol.dispose]() {
    throw new Error("Failed to release lock on writer");
  }
}

function doSomething() {
  using reader = new MyReader();
  using writer = new MyWriter();
  throw new Error("Failed to read");
}

try {
  doSomething();
} catch (e) {
  console.error(e); // SuppressedError: An error was suppressed during disposal
  console.error(e.suppressed); // SuppressedError: An error was suppressed during disposal
  console.error(e.error); // Error: Failed to release lock on reader
  console.error(e.suppressed.suppressed); // Error: Failed to read
  console.error(e.suppressed.error); // Error: Failed to release lock on writer
}
  • reader 最後釋放,所以它的錯誤是最新的,因此抑制了其他所有錯誤:它顯示為 e.error
  • writer 首先釋放,所以它的錯誤比原始的退出錯誤晚,但比 reader 錯誤早:它顯示為 e.suppressed.error
  • 關於“Failed to read”的原始錯誤是最早的錯誤,所以它顯示為 e.suppressed.suppressed

示例

自動釋放物件 URL

在以下示例中,我們建立一個指向 Blob 的物件 URL(在實際應用程式中,此 Blob 將從某個地方獲取,例如檔案或 fetch 響應),以便我們可以將 Blob 作為檔案下載。為了防止資源洩漏,我們必須在不再需要物件 URL 時(即下載成功開始時)使用 URL.revokeObjectURL() 釋放它。由於 URL 本身只是一個字串,因此不實現可處置協議,我們不能直接使用 using 宣告 url;因此,我們建立一個 DisposableStack 作為 url 的處置器。一旦 disposer 超出作用域,即 link.click() 完成或某個地方發生錯誤時,物件 URL 就會被撤銷。

js
const downloadButton = document.getElementById("download-button");
const exampleBlob = new Blob(["example data"]);

downloadButton.addEventListener("click", () => {
  using disposer = new DisposableStack();
  const link = document.createElement("a");
  const url = disposer.adopt(
    URL.createObjectURL(exampleBlob),
    URL.revokeObjectURL,
  );

  link.href = url;
  link.download = "example.txt";
  link.click();
});

自動取消進行中的請求

在下面的例子中,我們使用 Promise.all() 併發地獲取一個資源列表。Promise.all() 在一個請求失敗後立即失敗並拒絕結果 Promise;然而,其他掛起的請求會繼續執行,儘管它們的結果對程式來說是不可訪問的。為了避免這些剩餘的請求不必要地消耗資源,我們需要在 Promise.all() 解決時自動取消進行中的請求。我們使用 AbortController 實現取消,並將其 signal 傳遞給每個 fetch() 呼叫。如果 Promise.all() 成功,那麼函式正常返回並且控制器中止,這是無害的,因為沒有掛起的請求需要取消;如果 Promise.all() 拒絕並且函式丟擲,那麼控制器中止並取消所有掛起的請求。

js
async function getAllData(urls) {
  using disposer = new DisposableStack();
  const { signal } = disposer.adopt(new AbortController(), (controller) =>
    controller.abort(),
  );

  // Fetch all URLs in parallel
  // Automatically cancel any incomplete requests if any request fails
  const pages = await Promise.all(
    urls.map((url) =>
      fetch(url, { signal }).then((response) => {
        if (!response.ok)
          throw new Error(
            `Response error: ${response.status} - ${response.statusText}`,
          );
        return response.text();
      }),
    ),
  );
  return pages;
}

陷阱

資源處置語法提供了許多強大的錯誤處理保證,確保無論發生什麼,資源都始終被清理,但你仍然可能會遇到一些陷阱

  • 忘記使用 usingawait using。資源管理語法只在你需要時提供幫助,但如果你忘記使用它,它不會發出任何警告!不幸的是,事發前沒有好的方法可以防止這種情況,因為沒有語法線索表明某個東西是可處置的資源,即使是可處置的資源,你可能也想在不自動處置的情況下宣告它們。你可能需要一個型別檢查器結合一個 linter 來捕獲這些問題,例如 typescript-eslint該功能仍在規劃中)。
  • 使用後釋放。通常,using 語法確保資源在超出作用域時被釋放,但有許多方法可以在繫結變數之外持久化值。JavaScript 沒有像 Rust 那樣的所有權機制,所以你可以宣告一個不使用 using 的別名,或者將資源儲存在閉包中等等。using 參考中包含許多此類陷阱的示例。同樣,在複雜的控制流中沒有好的方法可以正確檢測到這一點,所以你需要小心。

資源管理功能並非靈丹妙藥。它確實比手動呼叫處置方法有所改進,但它還不夠智慧,無法防止所有資源管理錯誤。你仍然需要小心並理解你正在使用的資源的語義。

總結

以下是資源管理系統的關鍵元件

透過正確使用這些 API,你可以建立與外部資源互動的系統,這些系統在所有錯誤條件下都保持強大和健壯,而無需大量樣板程式碼。