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,你可以创建与外部资源交互的系统,这些系统在所有错误条件下都保持强大和健壮,而无需大量样板代码。