我们有个夜间跑的报表导出任务,逻辑不复杂:拿一把分布式锁,从连接池取一个连接,开事务,写临时 CSV,提交,清理。
代码大概是这样:
async function exportReport(userId) {
const lock = await redis.acquireLock(`export:${userId}`);
try {
const conn = await pool.acquire();
try {
const tx = await conn.beginTransaction();
try {
const tmp = await openTempFile();
try {
const rows = await collectRows(tx, userId);
await writeCsv(tmp, rows);
await tx.commit();
} finally {
await tmp.close();
}
} catch (e) {
await tx.rollback();
throw e;
} finally {
conn.release();
}
} finally {
// 这里还得判断事务有没有提交,没提交就回滚
if (!tx.committed) {
await tx.rollback();
}
}
} finally {
await lock.release();
}
}
缩进到最里面那行 await writeCsv 的时候,前面已经铺了十六个空格。整个函数里真正的业务逻辑只有三行,剩下全是清理。
某次重构的时候,有人加了一个提前返回:
if (rows.length === 0) {
return { count: 0 };
}
加在 collectRows 后面。逻辑上没问题——没数据就不生成报表。但这一句 return 让外面的几层 finally 里的 catch 分支失效了,事务对象没有被显式回滚,锁也要等超时才能释放。第二天早上任务一直卡着,一开始谁也没想到是这里。
这种 bug 的类型是:清理逻辑和业务逻辑耦合在同一个控制流里。只要中间插进新的出口(return、throw、break),就得回头把每一条清理路径重新走一遍。代码越长,越容易漏。
JavaScript 现在的解决方案叫显式资源管理(Explicit Resource Management)。核心是 using 声明。
一、支持情况和检测方式
这块能力落地时间不算太久,各个运行时的进度也不太一样。目前 Chrome 134+ 和 Node.js 24+ 已经原生支持,Firefox 和 Safari 还在跟进。
检测方式:
console.log(typeof Symbol.dispose);
// "symbol" —— 有的话说明运行时支持协议
console.log(typeof Symbol.asyncDispose);
// "symbol"
但光有 Symbol 还不够。真正的语法支持要靠 using 声明能不能被解析:
try {
new Function('using x = null;');
console.log('支持 using 语法');
} catch {
console.log('需要使用编译器降级');
}
TypeScript 5.2 开始支持 using,并且可以向下编译到 ES5。Babel 也有对应的插件。所以即使目标运行时还不支持,也可以先用起来,代价是编译产物会大一点。
二、Symbol.dispose:一个约定
整个特性的地基就是一个符号方法。任何对象只要有这个方法,就表示”我能被清理”:
const resource = {
data: [],
[Symbol.dispose]() {
this.data.length = 0;
console.log('已清理');
},
};
方法名不重要,符号才是关键。这套设计跟 Symbol.iterator 的思路完全一致——用一个众所周知的符号作为协议入口,任何对象都能实现它,不需要继承特定的基类。
写一个真实一点的例子。假设我们有一个需要手动关闭的日志写入器:
class LogWriter {
#file;
constructor(path) {
this.#file = fs.openSync(path, 'a');
}
write(line) {
fs.writeSync(this.#file, line + 'n');
}
[Symbol.dispose]() {
fs.closeSync(this.#file);
this.#file = null;
}
}
有了这个方法,就可以用 using 声明了。
三、using 声明
{
using writer = new LogWriter('./app.log');
writer.write('服务启动');
if (someCondition) {
return; // 即使从这里返回,writer 也会被关闭
}
writer.write('处理完成');
}
// 出了这个块,Symbol.dispose 被调用
几个要点。
作用域和生命周期
using 声明的变量是块级作用域,和 let、const 一样。区别在于,当执行离开这个块的时候——不管是正常结束、return、throw 还是 break——引擎会自动调用它的 Symbol.dispose。
编译器的做法是把块体包进一个 try/finally。所以从语义上讲,using 就是 try/finally 的语法糖。但这段糖的价值在于:你不需要自己去维护 finally 里的内容,也不会因为加了新的出口而忘记它。
多个资源按倒序清理
{
using a = makeA();
using b = makeB();
using c = makeC();
}
// 清理顺序:c → b → a
后声明的先清理,和你在 try/finally 里嵌套的自然顺序一致(最内层的 finally 最先跑)。这个顺序是有意义的——如果 c 依赖 b 提供的状态,那么先清理 c 是安全的。
null 和 undefined 会被跳过
{
using cache = config.useCache ? new Cache() : null;
// 如果 cache 是 null,不会报错,也不会调用任何 dispose
}
这个规则让”资源可能创建失败”的场景好写很多,不需要额外的判空。
不能重新赋值
using conn = pool.acquire();
conn = pool.acquire(); // SyntaxError
和 const 一样的限制。这是必要的——如果允许换引用,那原来的那个资源就没人管了。
四、await using:异步清理
有些资源的清理本身就是异步的。比如关闭数据库连接需要发一个 QUIT 包,释放分布式锁需要发一次网络请求。对于这种情况,用 await using:
async function job() {
await using lock = await redis.lock('report:nightly');
await doWork();
// 函数结束前,会 await lock 的异步清理方法
}
对应的协议符号是 Symbol.asyncDispose:
class RedisLock {
#key;
#client;
constructor(client, key) {
this.#client = client;
this.#key = key;
}
async [Symbol.asyncDispose]() {
await this.#client.del(this.#key);
}
}
这里有个容易搞混的地方:await using 接受两种协议中的任意一种。如果对象只实现了 Symbol.dispose(同步版本),await using 也能用,只是不会真的等待它。反过来,只有 Symbol.asyncDispose 的对象放在不加 await 的 using 里会直接报错。
// 可以
await using a = { [Symbol.dispose]() {} };
// 报错:对象只有异步清理方法
using b = { [Symbol.asyncDispose]() { return Promise.resolve(); } };
所以选择很简单:清理是同步的就写 Symbol.dispose,是异步的就写 Symbol.asyncDispose,调用方根据声明选对应的关键字。
五、DisposableStack:动态资源
using 声明的限制是它必须在编译期就能确定变量名。如果资源的数量是运行时才知道的,或者需要在方法执行过程中陆续注册,就得用 DisposableStack。
async function setupUserSession(userId) {
await using stack = new AsyncDisposableStack();
const session = await sessionStore.create(userId);
stack.use(session);
const lock = await redis.lock(`user:${userId}`);
stack.use(lock);
for (const id of await getSubscriptions(userId)) {
stack.use(await subscription.open(id));
}
await doWork();
// 函数结束时,子订阅、锁、会话按倒序清理
}
AsyncDisposableStack 本身实现了 Symbol.asyncDispose,所以可以配合 await using 用。它内部维护一个已注册资源的列表,清理的时候倒序处理。
四个注册方法
use(value)——注册一个实现了 dispose 协议的对象,返回它本身,所以可以链式调用。
adopt(value, onDispose)——注册一个普通值,并给它配一个清理函数。这个在包装第三方库的时候特别有用,因为那些资源通常没有实现 dispose 协议:
using stack = new DisposableStack();
const handle = legacyLib.open('some-resource');
stack.adopt(handle, (h) => legacyLib.close(h));
const watcher = fs.watch('./src');
stack.adopt(watcher, (w) => w.close());
defer(fn)——注册一个纯粹的回调,没有对应的资源对象。适合”需要在结束时执行某个动作,但手里没有对象”的场景:
using stack = new DisposableStack();
stack.defer(() => console.timeEnd('pipeline'));
console.time('pipeline');
// 无论中间发生什么,计时器都会被结束
move()——把当前栈里所有已注册的资源转移到一个新栈,原来的栈被清空。这个方法是给”我需要把资源所有权交给别人”的场景用的。比如一个工厂函数把创建好的资源交给调用方管理:
function createResources() {
using stack = new DisposableStack();
stack.use(resourceA());
stack.use(resourceB());
// 转移给调用方,本函数结束时不要再清理
return stack.move();
}
手动触发清理
const stack = new DisposableStack();
stack.use(heavyResource());
// 提前释放,不用等到作用域结束
stack.dispose();
// 之后再注册新资源会报错
stack.disposed; // true
这个能力适合那种”资源在某个中间点就该释放,但函数还要继续跑很久”的场景。
六、案例一:数据库事务包装
回到开头那个导出脚本。先写一个事务包装:
class Transaction {
#conn;
#settled = false;
constructor(conn) {
this.#conn = conn;
}
execute(sql, params) {
return this.#conn.execute(sql, params);
}
async commit() {
if (this.#settled) return;
this.#settled = true;
await this.#conn.execute('COMMIT');
}
async [Symbol.asyncDispose]() {
if (this.#settled) return;
// 没有显式提交过,说明出错了或者忘了提交,一律回滚
this.#settled = true;
await this.#conn.execute('ROLLBACK');
}
}
注意这里的默认策略是回滚优先。因为 dispose 的时候拿不到”块体是否正常结束”这个信息,所以唯一安全的选择是:除非调用方显式调用了 commit(),否则一律回滚。
这个设计在 JS 里是有意为之的。Go 的 defer 也面临同样的问题;Rust 里靠 Drop 也不区分正常退出和 panic。结论是:不要把”提交”和”释放”这两个动作合并成一个。
现在重写导出函数:
async function exportReport(userId) {
await using lock = await redis.lock(`export:${userId}`);
await using conn = await pool.acquire();
await using tx = await conn.transaction();
await using tmp = await openTempFile();
const rows = await collectRows(tx, userId);
if (rows.length === 0) {
return { count: 0 }; // 锁、连接、临时文件都会自动释放
}
await writeCsv(tmp, rows);
await tx.commit();
await lock.result(rows.length);
return { count: rows.length };
}
从二十五行的嵌套结构压到了十行左右。那个提前 return 也不再是陷阱——它和正常结束走的是同一条清理路径。
七、案例二:测试夹具的自动恢复
写测试的时候经常需要临时改一些全局状态:替换 console.log、切到假的时间、覆写环境变量、加上 db 连接的 mock。这些改动必须在测试结束时还原,否则会污染后面跑的用例。
以前的样子:
test('解析失败要重试三次', async () => {
const originalLog = console.log;
const originalNow = Date.now;
const originalEnv = process.env.NODE_ENV;
console.log = jest.fn();
Date.now = () => 1700000000000;
process.env.NODE_ENV = 'test';
try {
await expect(parseWithRetry(mockInput)).rejects.toThrow();
expect(console.log).toHaveBeenCalledTimes(3);
} finally {
console.log = originalLog;
Date.now = originalNow;
process.env.NODE_ENV = originalEnv;
}
});
三个资源,六行 finally。每个测试都要重复一遍。
用 using 重写:
function mockGlobal(target, key, value) {
const original = target[key];
target[key] = value;
return {
[Symbol.dispose]() {
target[key] = original;
},
};
}
function mockEnv(key, value) {
const original = process.env[key];
process.env[key] = value;
return {
[Symbol.dispose]() {
if (original === undefined) {
delete process.env[key];
} else {
process.env[key] = original;
}
},
};
}
测试代码就变成了:
test('解析失败要重试三次', async () => {
using logSpy = mockGlobal(console, 'log', jest.fn());
using clock = mockGlobal(Date, 'now', () => 1700000000000);
using env = mockEnv('NODE_ENV', 'test');
await expect(parseWithRetry(mockInput)).rejects.toThrow();
expect(logSpy).toBeDefined();
expect(console.log).toHaveBeenCalledTimes(3);
});
即便断言失败抛了异常,三个 mock 也会按倒序还原。写测试的时候不用再操心清理,注意力全在断言上。
这段代码还有个额外好处:mockGlobal 和 mockEnv 都是通用工具,放在 test/helpers 里全项目复用。以前每个测试文件里都有一份自己的 finally 块,写法各不相同,改动起来得一个个找。
八、案例三:带超时的请求
最后一个案例更贴近日常。调用外部接口时通常需要一个超时,同时要保证请求结束后把定时器清掉,不然会阻塞进程退出。
function disposableTimeout(fn, ms) {
const id = setTimeout(fn, ms);
return {
[Symbol.dispose]() {
clearTimeout(id);
},
};
}
async function fetchJson(url, { timeout = 5000 } = {}) {
using ctrl = new AbortController();
using timer = disposableTimeout(
() => ctrl.abort(new Error(`请求超时:${url}`)),
timeout
);
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
}
这段代码里有两个资源。一个是 AbortController——现在的运行时里它已经自带 Symbol.dispose(调 abort()),所以不需要额外包装。另一个是定时器,需要七行的小工具来包装。
有几个地方值得注意。
第一,成功返回的时候,定时器被清掉,但 ctrl.abort() 也会被调用。这看起来不太对——请求已经成功了,为什么要 abort?
答案是:对已经完成的请求调用 abort() 是空操作。AbortSignal 会变成 aborted 状态,但那个 fetch 早就不关心它了。这一点让代码变得很简洁——不需要写 if 判断”是不是真的需要中断”。
第二,res.json() 是在函数末尾被 await 的。这意味着在 JSON 解析的过程中,两个资源都还在有效状态。如果解析很慢,超时仍然会生效,先中断流再抛异常。这个行为符合直觉。
第三,如果 AbortController 在你用的运行时里还没有 Symbol.dispose,补一行就够:
if (!AbortController.prototype[Symbol.dispose]) {
Object.defineProperty(AbortController.prototype, Symbol.dispose, {
value() { this.abort(); },
writable: true,
configurable: true,
});
}
这类补丁在迁移期很常见,可以放在项目入口统一处理。等运行时都支持了再删掉即可。
九、八个实际踩过的坑
1. dispose 里抛异常会吞掉业务异常
try {
using x = {
[Symbol.dispose]() { throw new Error('清理失败'); },
};
throw new Error('业务失败');
} catch (e) {
console.log(e.constructor.name);
// SuppressedError
console.log(e.error.message); // 业务失败
console.log(e.suppressed.message); // 清理失败
}
两个都抛出异常的时候,得到的是一个 SuppressedError,里面同时带着两个错误。原始错误在 .error,清理时的错误在 .suppressed。
这个设计是对的——两个错误都不该丢。但很多错误上报工具不认识这种类型,会把 SuppressedError 上报成一个没有 message 的空壳。接的时候记得在全局的 error handler 里做一次展开。
2. 资源泄漏时不会报错
let leaked;
{
using conn = pool.acquire();
leaked = conn; // 把引用存到外面
}
// conn 已经释放了,但 leaked 还指着一个"已关闭"的对象
await leaked.query('SELECT 1'); // 报错:连接已关闭
using 不是垃圾回收机制,它是纯粹的确定性清理——出了作用域就释放,不管外面还有没有人引用它。这一点和 FinalizationRegistry 完全不同。
写的时候要意识到:不要把 using 声明的对象传出块外。如果确实需要传出去,就得用 DisposableStack.move(),或者干脆不用 using,改成手动的生命周期管理。
3. 顶层 using 的行为因环境而异
// 模块顶层
using db = connectDatabase();
在 ES Module 的顶层使用 using 是合法的,清理发生在模块求值结束时。但在 CommonJS 或者 <script> 里写顶层 using,解析器可能会报语法错误——因为那里的”顶层”不是一个块作用域。
想避开这个差异,最简单的方式是永远在函数或者显式的块里用:
{
using db = connectDatabase();
await seed(db);
}
一行大括号的事。
4. for 循环里每次迭代都会 dispose
for (const file of files) {
using handle = open(file);
await process(handle);
}
// 每次迭代结束都会关闭当前文件的 handle
这个行为是对的——每次迭代有自己的块作用域。但如果把声明写在循环外面,就变成了整个循环共用一份:
using handle = open(files[0]);
for (const file of files) {
// handle 一直是第一个文件的,直到循环整个结束才释放
}
这个错误在代码评审里不容易看出来,因为两段代码长得只差一行。for...of 循环头里也能声明:
for (using handle of openAll(files)) {
await process(handle);
}
这种写法是每次迭代拿一个资源、迭代结束就释放,语义最清楚。
5. 编译到老版本目标时会有惊喜
TypeScript 会把 using 降级成 try/finally。但降级有几个已知的边界:
如果模块里有 eval 或者 with,降级后的代码在某些场景下会失效。如果 using 声明出现在函数的最外层、并且函数体里没有别的块结构,编译器有时需要额外包一层大括号才能正确降级。极少数情况下(比如和装饰器混用),Babel 和 TS 的产物会有细微差异。
我的建议是:新项目把 target 直接设成 ES2022 或更高,让运行时原生处理。老项目的构建链要不要动,得先跑一遍完整的回归测试再决定。
6. 忘了 Symbol 是全局注册的
// 错误:用了 Symbol('dispose'),每次调用都是新的符号
const mySymbol = Symbol('dispose');
const resource = {
[mySymbol]() { /* ... */ },
};
using x = resource; // 找不到清理方法,报错
Symbol.dispose 是规范里预先定义好的 well-known symbol,不是自己 new 出来的。用 Symbol('dispose') 创建的是一个全新的、独一无二的符号,跟协议完全没关系。
这个错误在补全和跳转都比较差的环境里很容易犯,特别是符号名和描述字符串长得一模一样的时候。
7. 异步清理不会自动串行
async function f() {
await using a = asyncResourceA();
await using b = asyncResourceB();
// 结束时会先 await a 的清理,再 await b 的清理
}
清理确实是串行的,但它们不会和块内已经发起的异步任务协调。也就是说,如果块内启动了一个游离的异步操作(没有 await),dispose 的时候它可能还在跑:
{
using conn = pool.acquire();
doSomethingInBackground(conn); // 没有 await
}
// conn 已经释放了,但后台任务还在用它
解决办法是把所有异步操作都 await 掉,或者用 AbortController 通知后台任务停止。这其实是老问题的延续,using 并不能帮你发现它。
8. 不要用它管理”逻辑上不需要清理”的东西
// 过度使用
using user = await fetchUser(id);
using total = calculateTotal(user.orders);
这两个对象没有任何需要清理的资源。using 不会报错(因为没有 dispose 方法就会抛错,所以这段代码其实是错的),但即便给它们加上空方法,也只是白白增加了一层 try/finally。
判断标准很简单:这个东西是否持有了需要显式归还的外部资源——文件句柄、socket、数据库连接、锁、订阅、定时器。是就用 using,不是就用 const。
十、什么时候该用,什么时候别用
适合的场景:
任何需要成对出现的 acquire / release。文件读写、网络连接、数据库事务、分布式锁、事件订阅、定时器、测试夹具。这类场景下 using 带来的可读性提升是明显的,而且是结构性的——不管你往块里加多少个新的出口,清理都不会漏。
不适合的场景:
对象的生命周期完全由外部控制,比如从缓存里取出来的单例。多个对象需要按非倒序的顺序清理——using 固定是倒序,想改顺序得用 DisposableStack 手动注册,那样反而更麻烦。需要根据运行时条件决定是否清理的场景——using 没有”有条件跳过”的语法,得靠让对象实现一个什么都不做的 dispose 来绕过。
还有一种情况要额外注意:如果目标环境里 Symbol.dispose 还不存在,需要引入 polyfill。polyfill 本身很小(就是几个 Symbol.for 加上 SuppressedError 的定义),但引入之后要考虑它和已有代码的兼容性。有些库自己实现了一套 dispose() 方法,和标准的 Symbol 协议并不通用,迁移的时候要注意区分。
十一、写在最后
回头看那个导出脚本。最后重写完之后,本来的四个 finally 块全都消失了,代码从二十五行的嵌套缩到了十行左右。
但真正让我觉得值得的是另一件事。重写之后,团队里新来的同事第一次看这段代码就能读懂,不需要先在大脑里模拟一遍 try/finally 的执行顺序。以前那段代码大家都得先花半分钟”进入状态”,才能开始理解业务逻辑。
这类”消除样板”的特性,对团队的价值往往比对个人更大。个人写的时候小心一点也能不出错,但一个五人团队里,每个人都在自己的那段代码里小心一点,加起来的成本会很大。把它变成语言层面的默认行为,成本就被摊平了。
如果你的项目正好在 ES2022+ 的目标下,可以先从测试代码开始试。测试夹具的清理逻辑最适合迁移,因为它小而独立,改坏了也不会影响线上。用一段时间之后再推到业务代码里,节奏会比较舒服。

