12k
All articles

JavaScript 终于学会了自我清理

JavaScript 的 Explicit Resource Management 使用 using、await using、DisposableStack 和 SuppressedError,确定性清理文件、锁、socket 和连接。

OpenReplay Team
OpenReplay Team
JavaScript 终于学会了自我清理

JavaScript 全新的显式资源管理特性——由 Symbol.disposeSymbol.asyncDispose 支持的 usingawait using 声明——赋予了该语言对非内存资源(如文件句柄、套接字、锁和数据库连接)进行确定性、基于作用域的清理能力。这与垃圾回收机制截然不同。垃圾回收按照其自身不确定的调度时机回收内存,它从不关闭文件、释放锁,也不取消事件监听器的订阅。而这些正是 using 所清理的资源类型——在作用域退出的那一刻,以可预测的方式完成清理。

如果你一直在手动编写 try/finally 块来关闭连接和释放锁,这一特性将用单个关键字取代那些样板代码。本文将介绍 using 的工作原理、同步/异步的区别、如何编写自定义可释放对象、如何使用 DisposableStack 协调多个资源、如何为尚不支持该特性的库进行适配,以及清理错误如何通过新的 SuppressedError 呈现。(WeakRefFinalizationRegistry 是与 GC 相关的内存工具;这是一套完全不同的机制。)

核心要点

  • using 声明会在所在块退出时调用对象的 [Symbol.dispose]() 方法;await using 则调用 [Symbol.asyncDispose]() 并等待其完成,从而确保返回 Promise 的清理操作在作用域关闭前全部执行完毕。
  • 当多个资源共享同一作用域时,它们将按声明顺序的逆序依次释放,确保依赖资源在其所依赖的资源之前完成清理。
  • 显式资源管理是已完成的 TC39 提案,已在 ES2026 中标准化,并已原生支持于 Chrome 134(V8 13.8)、Firefox 141 和 Node.js 24(V8 13.6);Safari 仍在跟进中——Symbol.dispose 符号已在桌面版 Safari 26.4 中实现,但 iOS 版 Safari 尚未支持。
  • DisposableStack.prototype.move() 将已注册的资源转移到一个新的栈中,并将原栈标记为已释放而不实际释放任何资源——这是在构造函数中安全传递半构建资源的标准模式。
  • 如果在代码已经抛出异常的情况下清理操作也抛出异常,JavaScript 会将两者包装在一个 SuppressedError 中,确保原始错误和清理错误都不会丢失。

try/finally 从未真正解决的清理问题

每个打开的资源——文件句柄、流读取器、数据库连接、锁——都必须被关闭,而语言层面唯一能保证这一点的工具就是 try/finally。它虽然有效,但代码冗长,且可扩展性差。以 Web Streams 读取器为例:如果在读取过程中发生错误,而你忘记在错误传播前调用 releaseLock(),流将保持锁定状态。解决方法是将读取循环包裹在 try 块中,并在 finally 中调用 reader.releaseLock(),以确保锁始终被释放。

对于单个资源,这种模式尚可接受。但当涉及两三个相互依赖的资源时,就会出现嵌套的 finally 块,其中顺序至关重要,且某个清理操作中的异常可能导致其他清理操作被跳过。这种机械性的工作——“这个对象需要清理,在退出时按正确顺序执行,即使在错误路径上也不例外”——正是语言现在为你处理的事情。

using 关键字的工作原理

using 关键字声明一个块作用域内不可重新赋值的绑定(类似 const),其值必须为 nullundefined,或携带 [Symbol.dispose]() 方法的对象;当变量离开作用域时,该方法会自动执行。同步清理使用 using,异步清理(即清理操作返回 Promise 时)使用 await using——后者声明的块作用域变量会被异步释放,其值必须具有 [Symbol.asyncDispose]()[Symbol.dispose]() 方法,且该方法会在变量离开作用域时被调用并等待完成。由于 await using 同样接受普通的同步可释放对象,当你不确定对象属于哪种类型时,可以直接使用它。

import fs from "node:fs/promises";

async function readConfig() {
  await using file = await fs.open("config.json", "r");
  const { buffer } = await file.read();
  return buffer.toString();
  // file[Symbol.asyncDispose]() 在此处被调用并等待完成,覆盖所有退出路径
}

Node.js 的 FileHandle 对象已实现异步可释放协议,因此无需手动包装即可直接使用。注意这里有两个 await 操作:await fs.open() 等待资源获取并将 Promise 解包为 FileHandle,而 await using 则在变量离开作用域时等待释放操作完成。

当资源之间存在依赖关系时,顺序规则尤为重要。如果一个作用域中包含多个 usingawait using 声明,所有释放操作将按声明顺序的逆序依次执行,无论声明类型如何,且所有操作都保证会运行,类似于 finally 块的行为。

await using db = await openConnection();      // 第二个释放
await using tx = await db.beginTransaction(); // 第一个释放
// tx 在 db 之前完成清理,因此在事务结束时连接仍然有效

关于作用域:using 可用于任何块、函数体、静态初始化块、for 循环头部,以及模块的顶层——但不能用于脚本的顶层,因为脚本作用域会持续存在,释放操作将永远不会触发。await using 在顶层仅适用于模块,因为它依赖顶层 await。符合规范的详细规则记录在 MDN using 参考文档中。

编写自定义可释放对象

任何对象都可以通过实现 [Symbol.dispose]() 方法(用于同步清理)或 [Symbol.asyncDispose]() 方法(用于异步清理)成为可释放对象——无需基类,也无需注册步骤。众所周知的符号本身就是协议:一个对象只要具有 [Symbol.dispose]() 方法就是可释放的using 声明会在初始化器上查找该符号,以确定变量离开作用域时要调用的方法。

以下是贯穿本文其余部分的数据库连接包装器示例:

class DatabaseConnection {
  #client;
  constructor(client) {
    this.#client = client;
  }
  query(sql, params) {
    return this.#client.query(sql, params);
  }
  async [Symbol.asyncDispose]() {
    await this.#client.end();
  }
}

async function getUser(pool, id) {
  await using db = new DatabaseConnection(await pool.acquire());
  return await db.query("SELECT * FROM users WHERE id = $1", [id]);
  // db[Symbol.asyncDispose]() 在此处运行——客户端在所有路径上都会被释放
}

同步释放器的结构相同,只需将 [Symbol.asyncDispose]() 替换为 [Symbol.dispose]()。同步释放器不应返回 Promise,因为 [Symbol.dispose]() 返回的 Promise 不会被 await using 等待;若需声明异步可释放对象,请使用 Symbol.asyncDispose

使用 DisposableStack 协调多个资源

当单个对象拥有多个资源,或在循环中获取资源时,DisposableStackAsyncDisposableStack 可以将它们统一收集,并按逆序一次性全部释放。这两种结构都提供了 use()adopt()defer() 等方法用于添加资源或清理操作,以及 dispose()asyncDispose() 方法用于触发清理。它们本身也携带 [Symbol.dispose]() / [Symbol.asyncDispose](),因此可与 usingawait using 配合使用。

三种注册方法适用于不同场景:

方法注册内容适用场景
use(resource)已具有释放符号的资源对象本身是可释放的
adopt(value, onDispose)不可释放的值加上清理回调资源具有 close/abort 风格的方法但没有符号
defer(onDispose)独立的清理回调,无资源需要执行任意清理操作(例如 clearInterval
function startWorker() {
  using stack = new DisposableStack();
  const handle = setInterval(poll, 5000);
  stack.defer(() => clearInterval(handle));
  stack.adopt(openSocket(), (s) => s.close());
  // 两个清理操作都会执行,按逆序,在块退出时
}

move() 方法的强大之处不那么直观,但它解决了一个真实的构造安全问题。有时函数作用域还不够——一个类或对象拥有多个资源,这些资源应该像 using 一样分组管理,但作为类字段或闭包存在,这正是 DisposableStackAsyncDisposableStack 的用武之地。DisposableStack.prototype.move() 将所有已注册的资源转移到一个新的栈中,并将原栈标记为已释放,而不实际释放资源,这样你就可以在本地构建资源,并仅在构造完全成功时才将其传递出去:

function openResources() {
  using cleanup = new DisposableStack();
  const a = cleanup.use(openA());
  const b = cleanup.use(openB()); // 如果此处抛出异常,`a` 会自动被释放
  const moved = cleanup.move();   // 成功:所有权转移出去,没有资源被释放
  return moved;                   // 调用方现在负责两者的释放
}

如果 openB() 抛出异常,块退出,cleanup 会释放 a——没有泄漏。如果一切成功,move() 将资源完整地交给调用方。move() 的语义记录在 V8 的显式资源管理说明文档中。

为尚不支持释放协议的库进行适配

你不需要等待库采用 Symbol.dispose 才能将其与 using 配合使用——只需通过 Object.assign 自行附加该符号即可。对于暴露了 close()end() 方法但没有释放符号的库客户端,只需一行代码即可使其变为可释放对象。将 Symbol.asyncDispose 方法赋值到客户端上,意味着你可以在 await using 声明中使用它,也可以通过 AsyncDisposableStack#use() 注册它。如果你日后升级到原生实现了该协议的库版本,赋值操作会触发一个错误,提醒你删除这个临时补丁。

const client = await MongoClient.connect(url);
Object.assign(client, {
  async [Symbol.asyncDispose]() {
    await client.close();
  },
});

async function run() {
  await using db = client; // 现在可以正常释放
  // ...
}

Object.assign 补丁是当前生态系统的过渡方案:大多数第三方客户端尚未提供释放符号,这让你今天就能将它们纳入管理。

在前端和 Node.js 中的应用场景

在客户端,容易泄漏的资源包括事件监听器、IntersectionObserver/ResizeObserver 实例、navigator.locks、已锁定的 Web Streams 以及 IndexedDB 事务——这些都有明确的释放步骤,在错误路径上很容易被遗漏。将它们包装在可释放对象中,可以将其生命周期与作用域绑定。V8 团队的典型示例是流读取器:对一个对象使用 using 声明,其 [Symbol.dispose]() 调用 reader.releaseLock(),这样就完全不需要记住手动释放了。

孤立的观察器和未释放的锁,正是那些在快速本地测试中难以发现的泄漏类型。一个孤立的观察器或未释放的锁,在你每隔几秒就刷新一次的页面上几乎没有影响,但在长时间运行的单页应用会话中,它们会积累成逐渐增长的内存占用和交互卡顿——这种缓慢恶化的问题,往往只有在完整的会话回放中才能发现,而一次性的页面刷新根本无法复现。使用 using 对这些资源进行作用域管理,是从根本上解决此类 bug 的方法。

在服务端,来自 fs/promises 的 Node.js FileHandle 对象以及许多连接类型已原生支持该协议,而像上面的 DatabaseConnection 这样的自定义包装器可以覆盖其余情况。

使用 SuppressedError 处理释放过程中的错误

释放操作可能失败,而且可能在代码已经抛出异常的情况下再次失败——如果没有明确定义的行为,一个错误就会悄无声息地覆盖另一个。JavaScript 通过 SuppressedError 解决了这个问题。释放过程中抛出的所有错误,包括导致作用域退出的初始错误,都会被聚合到一个 SuppressedError 中——较早的异常作为 suppressed 属性,较晚的异常作为 error 属性——并在释放完成后抛出。

因此,如果你的块抛出异常,而释放器也抛出异常,你捕获到的是一个 SuppressedError,其 .error 是释放失败,.suppressed 是原始错误。当多个释放器抛出异常时,它们会嵌套,确保每个失败都可追溯:

try {
  using a = makeDisposableThatThrowsOnDispose("a");
  using b = makeDisposableThatThrowsOnDispose("b");
  throw new Error("body failed");
} catch (e) {
  // e 是一个 SuppressedError;b 先释放,a 后释放,因此
  // e.error 是 a 的释放错误,e.suppressed 嵌套了其余错误
  // (b 的释放错误,然后是原始的 "body failed")
}

SuppressedError 是让 using 在错误频发的代码中也能可靠使用的关键所在。

当前支持情况:已正式发布,并非”即将推出”

显式资源管理是已完成的 TC39 提案,已作为 ES2026 的一部分完成标准化——并非需要到处转译的 Stage 3 实验性特性。截至 2026 年中,各平台支持情况如下:

运行时状态备注
Chrome / Edge / Opera已支持using/await using 自 Chromium 134(V8 13.8)起支持;Symbol.dispose 符号本身自 Chrome 125 起已存在
Node.js已支持Node.js 24(V8 13.6)原生支持 using/await using;符号自 18.18.0 起已存在
Firefox已支持自 Firefox 141 起支持(当前稳定版 152)
Safari部分支持Symbol.dispose 符号已在桌面版 Safari 26.4 中实现;iOS 版 Safari 尚不支持
TypeScript已支持自 5.2 版本起支持 using 语法;当前稳定版 6.0

有一个陷阱值得特别指出:Symbol.dispose 符号已定义,并不意味着引擎能够解析 using 声明。该符号通常比声明语法更早落地——Node.js 早在 v18 系列(18.18.0)就暴露了众所周知的符号,但直到 Node 24 才将声明语法原生化;Chrome 大约在 v125 时发布了该符号,却直到 134 才支持解析 using。因此,在旧版引擎上,你可以引用 Symbol.dispose,但仍会遇到语法错误或”不可释放”的 TypeError——而 Symbol.dispose 的支持表(如上面链接的那个)往往领先于真正的 using 支持。完整的 using 支持是引擎版本的问题,而非 polyfill 的问题。

对于 Safari 和旧版目标环境,请使用转译。TypeScript 需要将编译目标设置为 ES2022 或更低,并在 lib 中配置 "esnext""esnext.disposable"(TypeScript 自版本 5.2 起支持该语法),同时可以使用 core-jsdisposablestack 包对全局对象进行 polyfill。

你一直在手动编写的那些机械性、易出错的清理代码,现在有了语言原语的支持:为任何需要清理的对象添加 [Symbol.dispose][Symbol.asyncDispose],用 usingawait using 声明它,运行时就会在每条退出路径上按正确顺序保证释放操作的执行。不妨从包装那个你最常忘记关闭的资源开始——一个连接、一把锁、一个读取器——让作用域来完成剩下的工作。

常见问题

using 和 await using 有什么区别?

using 声明在块退出时调用对象的同步 Symbol.dispose 方法,而 await using 则调用 Symbol.asyncDispose 并等待其完成,确保返回 Promise 的清理操作在作用域关闭前全部执行完毕。当清理是同步的时,使用 using;当清理返回 Promise 时,使用 await using。由于 await using 同样接受普通的同步可释放对象,当你不确定对象携带哪种释放器时,可以直接使用它。

为什么在 Node 18 上即使 Symbol.dispose 存在,using 也会抛出“不可释放”错误?

Symbol.dispose 符号已定义,并不意味着引擎能够解析 using 声明。Node.js 从 18.18.0 版本起就暴露了众所周知的 Symbol.dispose 和 Symbol.asyncDispose 符号,但完整的 using 和 await using 声明语法直到 Node 24(搭载 V8 13.6)才原生支持。在旧版 Node 上,你可以引用该符号,但在内置句柄上仍会遇到语法错误或 TypeError。完整的 using 支持是引擎版本的问题,而非 polyfill 的问题。

显式资源管理会取代垃圾回收吗?

不会。垃圾回收按照其自身不确定的调度时机回收内存,从不关闭文件、释放锁或取消事件监听器的订阅。显式资源管理以确定性的方式处理这些非内存资源,在作用域退出的那一刻执行清理操作。两种机制解决的是不同的问题。WeakRef 和 FinalizationRegistry 是与 GC 相关的内存工具,而 using 和 await using 则提供对文件句柄、套接字、锁和连接的基于作用域的清理能力。

如何对没有释放方法的库使用 using 关键字?

通过 Object.assign 自行附加该符号。对于暴露了 close 或 end 方法但没有释放符号的客户端,只需一行代码——赋值一个调用现有 close 方法的异步 Symbol.asyncDispose 方法——即可使其变为可释放对象。之后你就可以用 await using 声明该对象,或通过 AsyncDisposableStack 的 use 方法注册它。如果你日后升级到原生实现了该协议的库版本,重新赋值操作会触发一个错误,提醒你移除这个临时补丁。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.