JavaScript 資源管理
本指南討論如何在 JavaScript 中進行資源管理。資源管理與記憶體管理不完全相同,後者是一個更高階的主題,通常由 JavaScript 自動處理。資源管理涉及管理不由 JavaScript 自動清理的資源。有時,記憶體中存在一些未使用的物件是可以接受的,因為它們不會干擾應用程式邏輯,但資源洩漏通常會導致功能失常或大量記憶體佔用。因此,這並非關於最佳化的可選功能,而是編寫正確程式的核心功能!
注意:雖然記憶體管理和資源管理是兩個獨立的主題,但有時你可以作為最後的手段,利用記憶體管理系統進行資源管理。例如,如果你的 JavaScript 物件代表外部資源的控制代碼,你可以建立一個FinalizationRegistry,在控制代碼被垃圾回收時清理資源,因為此後肯定無法訪問該資源。然而,無法保證終結器一定會執行,因此不建議依賴它來處理關鍵資源。
問題
我們首先看幾個需要管理的資源示例
-
檔案控制代碼:檔案控制代碼用於讀取和寫入檔案中的位元組。使用完畢後,必須呼叫
fileHandle.close(),否則檔案將保持開啟狀態,即使 JS 物件不再可訪問。正如連結的 Node.js 文件所述:如果
<FileHandle>未使用fileHandle.close()方法關閉,它將嘗試自動關閉檔案描述符併發出程序警告,有助於防止記憶體洩漏。請不要依賴此行為,因為它可能不可靠且檔案可能未關閉。相反,請始終顯式關閉<FileHandle>。Node.js 將來可能會更改此行為。 -
網路連線:某些連線,例如
WebSocket和RTCPeerConnection,如果未傳輸訊息,則需要關閉。否則,連線將保持開啟狀態,並且連線池的大小通常非常有限。 -
流讀取器:如果你不呼叫
ReadableStreamDefaultReader.releaseLock(),流將被鎖定,不允許其他讀取器使用它。
這是一個具體示例,使用可讀流
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修復此問題jsasync 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已經關閉,但它在其作用域中仍然可用。這意味著你可能會在它關閉後意外地使用它。 -
多重資源:如果我們對不同的流有兩個讀取器,我們必須記住釋放它們兩者。這是一個值得稱讚的嘗試
jsconst 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中jsconst reader1 = stream1.getReader(); try { const reader2 = stream2.getReader(); try { // do something with reader1 and reader2 } finally { reader2.releaseLock(); } } finally { reader1.releaseLock(); }
您可以看到,呼叫 releaseLock 這樣看似無害的任務,很快就會導致巢狀的樣板程式碼。這就是 JavaScript 為資源管理提供整合語言支援的原因。
using 和 await using 宣告
我們的解決方案是兩種特殊的變數宣告:using 和 await using。它們類似於 const,但只要資源是可處置的,它們就會在變數超出作用域時自動釋放資源。以上面的相同示例,我們可以將其重寫為:
{
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 的簡單別名或包裝器
// 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]() 方法。例如,在上面的示例中,reader1 在 reader2 之前宣告,因此 reader2 首先被處置,然後是 reader1。在嘗試處置某個資源時丟擲的錯誤不會阻止其他資源的處置。這與 try...finally 模式一致,並尊重資源之間可能存在的依賴關係。
await using 與 using 非常相似。語法告訴你某個地方會發生 await——不是在資源宣告時,而是在它被處置時。await using 要求資源是非同步可處置的,這意味著它有一個 [Symbol.asyncDisposable]() 方法。此方法在沒有引數的情況下被呼叫,並返回一個 Promise,該 Promise 在清理完成時解決。這在清理是非同步的情況下很有用,例如 fileHandle.close(),在這種情況下,處置的結果只能非同步得知。
{
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。
需要注意的一些事項
using和await using是選擇性加入的。如果你使用let、const或var宣告你的資源,則不會發生自動處置,就像任何其他不可處置的值一樣。using和await using要求資源是可處置的(或非同步可處置的)。如果資源分別沒有[Symbol.dispose]()或[Symbol.asyncDispose]()方法,你將在宣告行收到TypeError。但是,資源可以是null或undefined,允許你條件性地獲取資源。- 與
const類似,using和await using變數不能被重新賦值,儘管它們所持有的物件的屬性可以被改變。然而,[Symbol.dispose]()/[Symbol.asyncDispose]()方法在宣告時就已經儲存,因此在聲明後改變該方法不會影響清理。 - 當將作用域與資源生命週期混淆時,會出現一些陷阱。有關幾個示例,請參見
using。
DisposableStack 和 AsyncDisposableStack 物件
using 和 await using 是特殊的語法。語法很方便,隱藏了大量的複雜性,但有時你需要手動操作。
一個常見的例子是:如果你不想在當前作用域結束時處置資源,而是在稍後的作用域中處置呢?考慮這種情況
let reader;
if (someCondition) {
reader = stream.getReader();
} else {
reader = stream.getReader({ mode: "byob" });
}
如我們所說,using 類似於 const:它必須被初始化且不能被重新賦值,所以你可能會嘗試這樣做
if (someCondition) {
using reader = stream.getReader();
} else {
using reader = stream.getReader({ mode: "byob" });
}
然而,這意味著所有邏輯都必須寫在 if 或 else 內部,導致大量重複。我們想要做的是在一個作用域中獲取和註冊資源,但在另一個作用域中處置它。為此,我們可以使用一個 DisposableStack,它是一個包含可處置資源集合且自身可處置的物件
{
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()。
{
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()。
{
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() 來保留否則將被處置的資源。
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,它可以健壯地應對錯誤
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]() 被呼叫,它釋放了流上的鎖。
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 不會吞噬任何錯誤:所有發生的錯誤仍然會被丟擲,但資源會在丟擲之前關閉。現在,如果資源清理本身也丟擲錯誤會發生什麼?讓我們使用一個更刻意的例子
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 屬性包含較早的錯誤,該錯誤被後面的錯誤“抑制”。
如果我們將多個資源,並且它們都在處置期間丟擲錯誤(這種情況應該極其罕見——處置失敗本身就很少見!),那麼每個較早的錯誤都會被較晚的錯誤抑制,形成一個抑制錯誤的鏈。
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 就會被撤銷。
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() 拒絕並且函式丟擲,那麼控制器中止並取消所有掛起的請求。
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;
}
陷阱
資源處置語法提供了許多強大的錯誤處理保證,確保無論發生什麼,資源都始終被清理,但你仍然可能會遇到一些陷阱
- 忘記使用
using或await using。資源管理語法只在你需要時提供幫助,但如果你忘記使用它,它不會發出任何警告!不幸的是,事發前沒有好的方法可以防止這種情況,因為沒有語法線索表明某個東西是可處置的資源,即使是可處置的資源,你可能也想在不自動處置的情況下宣告它們。你可能需要一個型別檢查器結合一個 linter 來捕獲這些問題,例如 typescript-eslint(該功能仍在規劃中)。 - 使用後釋放。通常,
using語法確保資源在超出作用域時被釋放,但有許多方法可以在繫結變數之外持久化值。JavaScript 沒有像 Rust 那樣的所有權機制,所以你可以宣告一個不使用using的別名,或者將資源儲存在閉包中等等。using參考中包含許多此類陷阱的示例。同樣,在複雜的控制流中沒有好的方法可以正確檢測到這一點,所以你需要小心。
資源管理功能並非靈丹妙藥。它確實比手動呼叫處置方法有所改進,但它還不夠智慧,無法防止所有資源管理錯誤。你仍然需要小心並理解你正在使用的資源的語義。
總結
以下是資源管理系統的關鍵元件
- 用於自動資源處置的
using和await using宣告。 - 資源要實現的可處置和非同步可處置協議,分別使用
Symbol.dispose和Symbol.asyncDispose。 - 在
using和await using不適用的情況下使用的DisposableStack和AsyncDisposableStack物件。
透過正確使用這些 API,你可以建立與外部資源互動的系統,這些系統在所有錯誤條件下都保持強大和健壯,而無需大量樣板程式碼。