写 Node 的时候有件挺烦人的事:只要代码里出现「拿到资源 → 干活 → 释放资源」这个循环,就会长出嵌套的 try/finally。
举一个实际的例子。导出报表功能,需要开文件句柄、开数据库事务、启一个超时定时器防止任务卡死:
async function exportReport(reportId) {
const timer = setTimeout(() => {
throw new Error('导出超时');
}, 30_000);
const handle = await open('report.csv', 'w');
let tx;
try {
tx = await db.beginTransaction();
try {
const rows = await tx.query('SELECT * FROM reports WHERE id = ?', reportId);
for (const row of rows) {
await handle.write(formatRow(row));
}
await tx.commit();
} finally {
if (tx && !tx.finished) {
await tx.rollback();
}
}
} finally {
clearTimeout(timer);
await handle.close();
}
}
功能全对,但读起来累。三层嵌套,每层都要考虑「走到这一步了吗」「这条路径下需不需要回滚」。三个月后回来看,很难一眼分清业务逻辑和清理逻辑。
今年落地的一个提案,就是专门解决这个模式的。它带来两个新关键字:using 和 await using。
核心机制:Symbol.dispose 与 using 声明
先看最简单的用法。任何对象,只要在原型上挂了一个名为 [Symbol.dispose] 的方法,就能用 using 声明来接管它的生命周期:
class TempDir {
constructor(prefix) {
this.path = fs.mkdtempSync(os.tmpdir() + '/' + prefix);
}
[Symbol.dispose]() {
fs.rmSync(this.path, { recursive: true, force: true });
console.log('已清理临时目录', this.path);
}
}
function process() {
using tmp = new TempDir('report-');
fs.writeFileSync(tmp.path + '/draft.txt', '...');
// 函数返回时,自动调用 tmp[Symbol.dispose]()
}
using 看起来像 const,其实差别不小。它是一条块作用域内的资源声明,超出作用域时,JavaScript 运行时自动调用 tmp[Symbol.dispose]()。
触发时机和栈展开的顺序值得记清楚:
- 正常跑完代码块:立即调用;
return、break、continue、throw:离开当前作用域前调用;- 有多个
using声明时,按声明顺序的逆序调用,最后声明的先释放; - 如果一个 dispose 抛错,它会先记录下来,把剩下的 dispose 全部跑完,最后再抛出。
最后一条是 try/finally 手动写容易做错的地方。多个资源释放中有一个失败,你希望剩下的资源仍然被释放,而不是因为第一个异常直接跳过后面所有释放。
异步资源:await using
Symbol.dispose 是同步的,遇到「关闭文件句柄」「提交事务」这类异步清理就不够用了。这类对象要挂的是 [Symbol.asyncDispose],声明端用 await using:
import { open } from 'node:fs/promises';
class ManagedFile {
constructor(handle) {
this.handle = handle;
}
static async open(path, flags) {
return new ManagedFile(await open(path, flags));
}
async [Symbol.asyncDispose]() {
await this.handle.close();
}
}
async function exportReport() {
await using file = await ManagedFile.open('report.csv', 'w');
await file.handle.write('id,amountn');
}
await using 只能用在 async 函数或模块顶层。每次清理都是异步执行、顺序等待的——第一个资源的 dispose 完成,才轮到第二个。这一点在设计时要考虑清楚:如果几个资源互不依赖,串行释放可能会拉长整体耗时。
还有一点容易忽略:await using 也可以接收只实现了 Symbol.dispose 的对象,它会把这个同步清理包一层 Promise 处理。反过来不行——using 不能接收只有异步 dispose 的对象,会直接抛 TypeError。
案例一:用 using 接管文件句柄
把前面那个导出报表的脚本重写一下。先给数据库事务写个包装类:
class Transaction {
constructor(conn) {
this.conn = conn;
this.finished = false;
}
static async begin(conn) {
const tx = new Transaction(conn);
await conn.query('BEGIN');
return tx;
}
async commit() {
await this.conn.query('COMMIT');
this.finished = true;
}
async [Symbol.asyncDispose]() {
if (!this.finished) {
await this.conn.query('ROLLBACK');
}
}
}
然后主流程可以写成:
async function exportReport(reportId) {
await using file = await ManagedFile.open('report.csv', 'w');
await using tx = await Transaction.begin(db);
const rows = await tx.query('SELECT * FROM reports WHERE id = ?', reportId);
for (const row of rows) {
await file.handle.write(formatRow(row));
}
await tx.commit();
}
和原始版本对比一下:三层嵌套没了,清理逻辑全都收进各自的 [Symbol.asyncDispose] 里,业务代码从头到尾一条直线。
关键点在于 Transaction 里那个 finished 标志。它承担的角色和原来的 if (tx && !tx.finished) 一模一样:如果 commit 成功调用了,dispose 就是空操作;否则回滚。
有人会问:await tx.commit() 这行如果自己就抛错怎么办?commit 里第一句是客户端调用,抛错的时候 this.finished 还是 false,dispose 会去执行 rollback。这个行为是对的——commit 到底有没有生效不确定,回滚一次是安全的兜底。
案例二:让定时器跟着作用域走
前面那个超时定时器在 finally 里 clearTimeout,容易被遗忘。给它做个包装:
function deadline(ms, message = '操作超时') {
let rejectFn;
const timer = setTimeout(() => {
rejectFn?.(new Error(message));
}, ms);
const promise = new Promise((_, reject) => {
rejectFn = reject;
});
return {
signal: promise,
[Symbol.dispose]() {
clearTimeout(timer);
}
};
}
async function withTimeout() {
using guard = deadline(5_000);
await Promise.race([doSlowWork(), guard.signal]);
}
这段代码有意思的地方在于,deadline() 返回的对象同时承担两个角色:既是清理器(管住定时器不要泄漏),也是信号源(超时后让 Promise.race 提前返回错误)。
离开作用域时,无论 doSlowWork() 是正常完成还是超时报错,定时器都会被清掉。如果不用 using,你得在 Promise.race 外面套 try/finally,或者接受一个挂着的定时器。
顺带说一句,如果只是「超时后不想要结果」,而不是「超时后必须报错」,其实用 AbortController + AbortSignal.timeout() 更合适。这个 deadline 是上面场景的简化演示,别当通用工具类照搬。
案例三:把资源集合整个管起来
有时候需要管一批资源,或者资源注册是动态的。这种场景可以自己实现一个 DisposableStack,或者用语言内置的:
async function batchImport(items) {
await using stack = new AsyncDisposableStack();
const conn = await db.connect();
stack.defer(() => conn.close());
for (const item of items) {
const buffer = await open(`/tmp/${item.id}.json`, 'w');
stack.defer(() => buffer.close());
}
// 干活……
// 离开作用域时,stack 里的清理项按「后进先出」顺序执行
}
AsyncDisposableStack 有三个常用的方法:
defer(fn):注册一个清理函数,最灵活;use(resource):注册一个资源,它必须实现Symbol.dispose或Symbol.asyncDispose;adopt(value, fn):注册一个值和对应的清理函数,等价于defer(() => fn(value))但语义更清楚;
还有一个 move() 方法,用来把整栈的清理责任转交给别处。有点像 Rust 里 Box::leak 或者所有权转移的玩法,实际业务里用得不多,遇到具体场景再查就行。
五个实战里踩过的坑
一、dispose 内抛错会替换掉原始异常。如果业务代码里已经抛了个「XX 字段缺失」的错误,然后在 dispose 里又因为清理失败抛了个 IO 错误,最终用户看到的会是 IO 错误。清理逻辑最好自己解决自己的异常,而不是让它冒上去覆盖掉主流程的错。
二、箭头函数里不能用 using。using 是块作用域声明,语法上要求自己所在的作用域是块、函数体或者模块顶层。像 () => using x = ... 这种省略大括号的简写是不合法的,报语法错误。
三、using 声明的对象必须显式实现 dispose。如果对象是通过 JSON 解析或者 Object.create(null) 来的,没有 prototype,就没法挂 Symbol.dispose。这类对象得先包一层。
四、别拿 using 当析构函数用。它只在词法作用域结束时触发,不是「对象不再被引用」就触发。如果写着 using x = createThing() 但把 x 又存到了全局变量里,作用域结束时它照样会被 dispose——因为触发条件是作用域退出,不是 GC。
五、注意 dispose 的同步性。Symbol.dispose 里不要做异步操作。看着 await 一下好像也能跑,但那只是「创建了个 Promise 不管它」,真正的工作会飘到作用域外面去,清理顺序完全乱了。异步清理一律用 Symbol.asyncDispose + await using。
该不该用
看完这些案例,可能会觉得这东西哪里都能用——每次 close() 前面都想加个 using。但冷静下来看,值得引入的场景其实有明确的边界:
适合的:资源生命周期和作用域能对齐的场景。数据库连接、文件句柄、事务、锁、临时目录、定时器,这些东西的开和关都在一个函数里,用 using 就很自然。
不适合的:长生命周期的资源。比如一个 WebSocket 连接,从页面打开连到页面关闭,这种东西要管理的范围是整个应用,不是某个函数。硬塞进 using 只会让代码更别扭。
也不能替代显式的业务语义。事务的「提交」和「回滚」是两个明确的业务动作,不能全靠 dispose 隐式处理。上面案例里写 await tx.commit() 是有意为之,就是为了让业务动作在代码里可见。
末尾补一句关于兼容性
这个特性目前已经在现代浏览器和 Node.js 22+ 里正式可用,TypeScript 5.2 开始也支持 using 和 await using 语法。如果目标运行时比较旧,最直接的替代方案就是老老实实写 try/finally——不优雅但可靠。
把 try/finally 换成 using 这种改动,不追求覆盖率,只把那些嵌套超过两层、或者容易漏掉 finally 的地方挑出来重写就够了。改完再回头读一遍,代码里到底在干什么,会清楚不少。

