using 声明实战:用显式资源管理替代 try/finally 的自动清理

写 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] 的类,试着改一两处调用点。改完对比一下代码量和出错的可能性,就能判断值不值得在项目里推广了。

using 声明实战:用显式资源管理替代 try/finally 的自动清理
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript using 声明实战:用显式资源管理替代 try/finally 的自动清理 https://www.taomawang.com/web/javascript/2812.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务