using 声明实战:用 Symbol.dispose 管好每一份资源

写 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();
  }
}

功能全对,但读起来累。三层嵌套,每层都要考虑「走到这一步了吗」「这条路径下需不需要回滚」。三个月后回来看,很难一眼分清业务逻辑和清理逻辑。

今年落地的一个提案,就是专门解决这个模式的。它带来两个新关键字:usingawait using

核心机制:Symbol.disposeusing 声明

先看最简单的用法。任何对象,只要在原型上挂了一个名为 [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]()

触发时机和栈展开的顺序值得记清楚:

  • 正常跑完代码块:立即调用;
  • returnbreakcontinuethrow:离开当前作用域前调用;
  • 有多个 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 到底有没有生效不确定,回滚一次是安全的兜底。

案例二:让定时器跟着作用域走

前面那个超时定时器在 finallyclearTimeout,容易被遗忘。给它做个包装:

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.disposeSymbol.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 开始也支持 usingawait using 语法。如果目标运行时比较旧,最直接的替代方案就是老老实实写 try/finally——不优雅但可靠。

把 try/finally 换成 using 这种改动,不追求覆盖率,只把那些嵌套超过两层、或者容易漏掉 finally 的地方挑出来重写就够了。改完再回头读一遍,代码里到底在干什么,会清楚不少。

using 声明实战:用 Symbol.dispose 管好每一份资源
收藏 (0) 打赏

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

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

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

淘吗网 javascript using 声明实战:用 Symbol.dispose 管好每一份资源 https://www.taomawang.com/web/javascript/2794.html

常见问题

相关文章

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

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