写 Node 的时间长了,会对一种代码特别熟悉:
async function processUpload(file) {
const handle = await open(file, 'r');
const lock = await acquireLock(file);
const timer = setInterval(checkProgress, 1000);
try {
const data = await handle.readFile();
return await transform(data);
} finally {
clearInterval(timer);
await lock.release();
await handle.close();
}
}
能跑,但这份清理代码要跟着每一处资源获取走。中间多拿一个资源,就多一行;顺序错了,可能出现「锁还没放、文件句柄已经在等 GC」这种诡异 bug;最要命的是,如果在这段代码里加一句 continue、加一个提前 return,稍微一不留神就绕过了某个清理。
几十年来,这个模式几乎没变过。语言层面能做的,也就是提供「不管怎么离开这个作用域,这些代码都保证执行」的语法。C++ 有 RAII,Java 有 try-with-resources,Python 有 with,C# 有 using。JavaScript 到 2025 年终于也补上了:using 声明(ES2025 的显式资源管理)。
这篇文章从最小例子讲起,把同步版本、异步版本、DisposableStack 三种形态讲完整,然后用几个真实场景串一遍,最后把实际项目里会踩到的坑摊出来。
一、最小的例子:一个可释放的计时器
先看传统写法。我们要统计一段代码的运行时间,用的是 console.time / console.timeEnd:
function measure(label, fn) {
console.time(label);
try {
return fn();
} finally {
console.timeEnd(label);
}
}
用 using 改成这样:
class Timer {
#label;
#start;
constructor(label) {
this.#label = label;
this.#start = performance.now();
}
[Symbol.dispose]() {
const elapsed = performance.now() - this.#start;
console.log(`${this.#label}: ${elapsed.toFixed(2)}ms`);
}
}
function measure(label, fn) {
using _ = new Timer(label);
return fn();
}
看起来写多了?是的,单看这一个函数写多了。但真正的价值在调用侧——每处用 Timer 的地方不再需要把自己包进 try/finally,也不再需要记得「我要调用某个清理函数」。资源一创建,清理就已经确定。
注意那个 _ 变量名。虽然我们从来不用它,但语法要求 using 后面必须跟一个标识符。用 _ 是社区约定,表示「我知道有这个变量,但故意不用」。不要用 unused 之类的名字,会让人误以为它在哪被引用了。
二、Symbol.dispose:一个约定好的方法名
上面那个类的关键,是有一个叫 [Symbol.dispose] 的方法。这个符号是 ES2025 加入的,语言约定的含义就是「释放这个资源」。
任何对象,只要有一个 [Symbol.dispose] 方法,就能被 using 声明接管。不需要继承任何类,不需要实现任何接口,鸭子类型。
const stream = {
buffer: [],
push(chunk) { this.buffer.push(chunk); },
[Symbol.dispose]() {
this.buffer.length = 0;
}
};
function collect() {
using s = stream;
s.push('a');
s.push('b');
// 函数返回时 buffer 自动被清空
}
这里有个不容易看出来的点:using 声明的对象在离开作用域时,会按声明的「反序」依次释放。这一点跟 try/finally 里嵌套的顺序一致——后拿到的资源先还回去。这个顺序在资源之间有依赖时会很重要,比如「必须先关文件再释放目录句柄」。
using a = new Resource('A');
using b = new Resource('B');
using c = new Resource('C');
// 离开作用域时,打印顺序是:C -> B -> A
try/finally 嵌套也一样:最内层的 finally 最先跑。所以使用 using 从传统写法迁移的时候,行为是一致的,不会突然冒出新的顺序问题。
三、await using:异步资源的版本
文件句柄就是个典型的异步资源——fs.promises 的 close() 返回 Promise。这种情况下用 await using。
class FileHandle {
#handle;
#closed = false;
static async open(path) {
return new FileHandle(await open(path, 'r'));
}
constructor(handle) {
this.#handle = handle;
}
async readAll() {
return this.#handle.readFile();
}
async [Symbol.asyncDispose]() {
if (this.#closed) return;
this.#closed = true;
await this.#handle.close();
}
}
async function processUpload(path) {
await using file = await FileHandle.open(path);
return file.readAll();
}
几个关键点。
方法名是 Symbol.asyncDispose,不是 Symbol.dispose。两者不同。如果一个类同时实现了两个符号,await using 会用异步版本,using 会用同步版本。
await using 只能出现在异步函数里。这是显然的——它需要在作用域结束时 await 一次。写在同步函数里会直接抛语法错误。
释放也是反序,而且是被串行 await 的。多个 await using 一起使用,关闭顺序是后声明的先关,每个 await 完成后才处理下一个。这一点很关键:如果你在 asyncDispose 里返回一个永远不 resolve 的 Promise,整个后续清理会挂住。所以清理逻辑里一定不要做耗时太久的事,也不要依赖任何可能已经关闭的东西。
顺便说一个经常出现的疑点。有 await using 之后,函数的返回值是怎么处理的?比如:
async function load(path) {
await using file = await FileHandle.open(path);
return file.readAll();
}
返回的 Promise 会先 await 到结果,然后执行清理,最后才真正把结果给调用者。也就是说,调用者拿到 load(path) 的结果时,文件已经关掉了。这一点和 try/finally 的行为一致。
四、真实案例一:互斥锁
并发控制里有一种简单但常见的写法:一个 Map 装当前被占用的 key,取锁的时候检查、设置,用完了删掉。手写的话,清理逻辑分散在每一处 finally。
class AsyncMutex {
#locked = new Set();
async acquire(key) {
while (this.#locked.has(key)) {
await new Promise(r => setTimeout(r, 10));
}
this.#locked.add(key);
const mutex = this;
return {
[Symbol.dispose]() { mutex.#locked.delete(key); },
[Symbol.asyncDispose]() { mutex.#locked.delete(key); }
};
}
}
调用侧:
async function updateConfig(key) {
await using _ = await mutex.acquire(key);
const current = await readConfig(key);
await writeConfig(key, { ...current, updatedAt: Date.now() });
}
这个写法省掉的东西其实不多——一个 try/finally,加一行 delete——但替换掉的是「易错」。原来那种写法,只要有人新加一个 return 分支绕过了 finally(虽然语法上不可能绕过,但逻辑上可能忘了走完清理),锁就会一直挂着。现在的写法,作用域一离开,释放就发生。
注意这里的对象同时实现了 Symbol.dispose 和 Symbol.asyncDispose。其实只需要 Symbol.asyncDispose 就够,因为 using 遇到只有 asyncDispose 的对象时也能用(只是换个方向的兼容不行——有 dispose 没 asyncDispose 的对象,不能用在 await using 里)。具体走哪条取决于你的调用习惯,本案例里两边都写是为了演示「同一个对象怎么支持两种声明方式」。
五、真实案例二:事件监听的自动解绑
这是我觉得 using 最实用的场景之一。组件挂载的时候 addEventListener,卸载的时候 removeEventListener,忘记配对是前端永恒的事故来源。
class Listeners {
#disposers = [];
on(target, type, handler, options) {
target.addEventListener(type, handler, options);
this.#disposers.push(() =>
target.removeEventListener(type, handler, options)
);
return this;
}
[Symbol.dispose]() {
// 反序解绑,和注册顺序相反
for (let i = this.#disposers.length - 1; i >= 0; i--) {
this.#disposers[i]();
}
this.#disposers.length = 0;
}
}
function setupWidget(root) {
using listeners = new Listeners();
listeners
.on(root.querySelector('.close'), 'click', closeWidget)
.on(window, 'resize', handleResize, { passive: true })
.on(document, 'keydown', handleKey);
// 中间可以穿插任意逻辑、条件、提前返回
// 只要离开这个函数,所有监听都会解绑
}
注意这里用了一个「收集器」的设计——listeners 只是一个容器,负责把多个解绑动作收集起来。Symbol.dispose 里再统一执行。这样一个 using 就管住了 N 个监听,比每个监听都建一个 disposer 要省事。
这个模式还有一个近亲:AbortSignal。事件监听 API 本身就支持 { signal },你可以创建一个 AbortController,在 disposer 里 abort() 一次:
function setupWidget(root) {
const controller = new AbortController();
const { signal } = controller;
root.querySelector('.close').addEventListener('click', closeWidget, { signal });
window.addEventListener('resize', handleResize, { signal, passive: true });
document.addEventListener('keydown', handleKey, { signal });
using _ = { [Symbol.dispose]() { controller.abort(); } };
}
三行变成一行,而且顺序可靠。当然这个方法要 controller 在作用域里,如果监听是在不同阶段渐次加上去的,那还是收集器方案更灵活。两者看场景选。
六、DisposableStack:在同一个 using 里挂多个清理
刚才那个 Listeners 其实在重新发明一个 API。语言里已经给了 DisposableStack:
function setupWidget(root) {
using stack = new DisposableStack();
stack.defer(() => closeWidget());
stack.defer(() => handleResize());
root.querySelector('.close').addEventListener('click', closeWidget);
window.addEventListener('resize', handleResize);
stack.defer(() => {
root.querySelector('.close').removeEventListener('click', closeWidget);
window.removeEventListener('resize', handleResize);
});
}
DisposableStack 上有几个方法:
defer(fn):把清理逻辑挂上去,栈释放时按后进先出的顺序执行。use(value):把一个带[Symbol.dispose]的对象推入栈,栈释放时会调用它的dispose。adopt(value, dispose):给一个不带 dispose 方法的值关联一个释放函数。move():把当前栈里的所有清理项转移到一个新栈,然后清空自己。dispose():手动触发释放。disposed:一个布尔只读属性,表示是否已经释放。
异步版本叫 AsyncDisposableStack,方法名从 defer 变成 defer(但清理函数返回 Promise),dispose 变成 disposeAsync。
async function handleRequest(req) {
await using stack = new AsyncDisposableStack();
const db = await connectDb();
stack.defer(() => db.close());
const lock = await acquireLock(req.userId);
stack.defer(() => lock.release());
return await process(req, db);
}
这个写法的好处是「清理逻辑跟着资源获取走」,而不是堆在结尾。资源在哪里拿的,释放逻辑就在哪里;读代码的时候一眼能看出谁在释放它。同时,退出路径上不需要再包一层 try/finally。
七、几个必须记住的坑
1. 作用域是词法块的,不是函数的
这是最容易搞错的。using 的生命周期绑定到最近的 {},不是函数体。
function f() {
{
using x = new Resource();
}
// 到这里 x 已经释放了
// 这里的 x 已经不可用
}
// 或者更常见的
function g() {
using x = new Resource();
if (something()) {
using y = new Resource();
// y 在这个 if 块结束时释放
}
// y 已经释放,x 还在
}
所以不要以为 using 就一定活到函数末尾。如果你在 if 里写了个 using 想让它活到最后,那会失望的。想让它整个函数有效,就把声明提到函数最外层。
2. 释放异常会掩盖原异常
如果 Symbol.dispose 本身抛异常了,而函数体里也抛了别的异常,那最后抛出去的是 dispose 的异常,原来的会被压掉,通过 error.cause 才能找到。这不是 using 特有的,try/finally 里清理逻辑抛异常也是同样的问题,但 using 让这件事更容易被忽视。
所以:清理逻辑里要尽量不抛异常。如果关文件可能失败,那就 try { await close(); } catch { /* 记录,不抛 */ }。释放是收尾工作,不该因为收尾出了问题就掩盖主线的一点成功或失败。
3. 释放是同步发生的,不是延迟到 GC
这点很多人会误会。using 的释放是确定性的——离开作用域的那一刻就发生,不是等 GC 顺手处理。
function f() {
using x = new Resource();
console.log('before');
}
// 执行顺序:
// 'before'
// x 释放(在 f 返回之前)
这就是它的价值所在。GC 什么时候来不可预测,文件句柄、数据库连接这种 OS 级别的资源,等 GC 是等不起的。using 给你的是「写起来像自动,执行上其实是显式」的东西。
4. 不能用在模块顶层和标准循环的头部
// 模块顶层是语法错误
using x = new Resource();
// for 循环头也是
for (using x = ...; ...; ...) { } // 报错
// 但 for...of 的循环体里可以
for (const item of items) {
using handle = openHandle(item);
// 每次迭代结束都会释放
} // 正确
这个限制在提案里有过反复讨论,最终定成现在这样:using 只能在「块级作用域」里声明,不能出现在模块顶层或者 for 语句的三个表达式位置。第一个原因很清楚——模块顶层的资源生命周期等于整个程序,没意义;第二个是因为 for 头的三个表达式不是词法作用域,没有明确的释放时机。
想在整个模块共享的资源,老老实实写一个 dispose() 函数在 process.on('exit') 里调用,别指望用 using。
5. async dispose 会阻塞后续释放
前面提过,多个 await using 的释放是串行的。这意味着如果一个资源关闭要等 3 秒,那后续所有资源的释放都会往后推 3 秒。如果你的场景里清理速度很敏感,得考虑这一点。
async function f() {
await using db = await connectDb(); // 关闭耗时 100ms
await using cache = await connectCache(); // 关闭耗时 100ms
// 离开作用域:先关 cache(100ms),再关 db(100ms)
// 总共需要 200ms
}
想并发关闭,就不要用 using 逐个声明,而是用 AsyncDisposableStack 加一个自定义的 defer,把 Promise.all 塞进去。这是 using 提供不了的便利,但也说明了它的边界——它适合「清理都很轻」的场景,重清理要么优化,要么自己控制并发。
6. 环境支持
ES2025 的显式资源管理,Node 24 及以上原生支持,主流浏览器的新版本也跟上了。构建流程里如果目标环境较旧(比如要在老 Node 上跑),需要在打包阶段做转换——大多数现代打包器(esbuild、swc、Babel 的对应插件)都已经支持把 using 转成 try/finally 加 Symbol.dispose。所以用之前先确认一下你的构建目标,别在本地跑得好好的,到了 CI 或者预发环境里报语法错误。
7. 别为了清空一个变量而用它
看到过有人这么写:
let connection;
using _ = {
[Symbol.dispose]() { connection = null; }
};
connection = await createConnection();
这只做到了「置空变量」,没做任何真正的清理——连接没有被 close,只是引用被丢了。这是把 using 当成「作用域结束就跑一下」的语法糖在用,其实不是它的目标。真正的清理应该是 connection.close(),而不只是把变量设成 null。
判断标准很简单:dispose 里做的事,应该是「不写的话资源就不会被释放」的事。如果只是变量置空,那交给 GC 更合适,用 using 只是徒增心智负担。
八、什么情况不要用
说了这么多好处,也得说说什么情况保留 try/finally 就好。
只有一处清理、逻辑极简单的时候。比如一个函数里只有一次 clearTimeout,写 using 反而要为了封装去搞一个对象,得不偿失。
清理顺序很复杂的时候。比如「先关 A,然后根据 A 的关闭结果决定要不要关 B,B 关完了通知 C 释放锁」。这种链式依赖写进 using 的 dispose 方法里会非常别扭,用 try/finally 反而能一条线写清楚。
需要根据条件决定要不要清理的时候。using 是一视同仁的——只要声明了就会释放。如果某个资源在特定情况下可以被「接管」(transfer ownership)给别的对象用,那用 DisposableStack 的 move() 方法可以做,但复杂度上来了,不如手动管理。
整体判断标准:清理逻辑是否「顺着资源获取的位置自然成对」。是,就用 using;不是,就继续用 try/finally。两者可以混,代码里出现 using 和 try/finally 并存不丢人。
九、写在最后
显式资源管理解决的,是一个从 JavaScript 诞生起就一直存在的问题:语言没有一个「不管怎么离开这里,都保证执行完」的短语。以前能用的只有 try/finally——功能上够,但作为「资源清理」这个具体场景,它没有把「资源和释放成对出现」这件事表达出来。
using 加进来的东西,与其说是减少几行代码,不如说是把「谁负责释放、什么时候释放」这件事显式地放进语法里。读代码的人不用再往下翻三个屏幕找一个 finally;写代码的人也不用担心某个新加的 return 会绕过清理。
如果手头有一个库或者一段工具代码,里面充斥着「拿资源、try、做事、finally 全释放」这种结构,可以拿它当第一个试点。挑一个场景,把资源包装成一个有 [Symbol.dispose] 的类,试着改一两处调用点。改完对比一下代码量和出错的可能性,就能判断值不值得在项目里推广了。

