JavaScript using 声明实战:用显式资源管理替代嵌套 try/finally

我们有个夜间跑的报表导出任务,逻辑不复杂:拿一把分布式锁,从连接池取一个连接,开事务,写临时 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+ 的目标下,可以先从测试代码开始试。测试夹具的清理逻辑最适合迁移,因为它小而独立,改坏了也不会影响线上。用一段时间之后再推到业务代码里,节奏会比较舒服。

JavaScript using 声明实战:用显式资源管理替代嵌套 try/finally
收藏 (0) 打赏

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

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

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

淘吗网 javascript JavaScript using 声明实战:用显式资源管理替代嵌套 try/finally https://www.taomawang.com/web/javascript/2827.html

常见问题

相关文章

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

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