中文

精通 JavaScript 全新的显式资源管理,掌握 `using` 和 `await using`。学习自动化清理、防止资源泄漏,编写更简洁、更健壮的代码。

JavaScript 的新超能力:深入解析显式资源管理

在瞬息万变的软件开发世界中,有效管理资源是构建健壮、可靠和高性能应用程序的基石。几十年来,JavaScript 开发者一直依赖像 try...catch...finally 这样的手动模式来确保关键资源——例如文件句柄、网络连接或数据库会话——得到妥善释放。虽然这种方法可行,但它通常冗长、容易出错,并且在复杂场景中很快会变得笨拙不堪,这种模式有时被称为“回调地狱”或“毁灭金字塔”。

现在,这门语言迎来了一次范式转变:显式资源管理 (Explicit Resource Management, ERM)。这项强大的功能在 ECMAScript 2024 (ES2024) 标准中最终确定,其灵感来源于 C#、Python 和 Java 等语言中的类似结构,引入了一种声明式和自动化的方式来处理资源清理。通过利用新的 usingawait using 关键字,JavaScript 为一个永恒的编程挑战提供了远为优雅和安全的解决方案。

本综合指南将带您深入了解 JavaScript 的显式资源管理。我们将探讨它解决的问题,剖析其核心概念,通过实际示例进行演练,并揭示高级模式,让您无论在世界何处开发,都能编写出更简洁、更具弹性的代码。

守旧派:手动资源清理的挑战

在我们领略新系统的优雅之前,必须先了解旧系统的痛点。JavaScript 中资源管理的经典模式是 try...finally 块。

其逻辑很简单:在 try 块中获取资源,在 finally 块中释放它。finally 块保证了无论 try 块中的代码是成功、失败还是提前返回,它都会被执行。

让我们来看一个常见的服务器端场景:打开一个文件,向其写入一些数据,然后确保文件被关闭。

示例:使用 try...finally 进行简单的文件操作


const fs = require('fs/promises');

async function processFile(filePath, data) {
  let fileHandle;
  try {
    console.log('正在打开文件...');
    fileHandle = await fs.open(filePath, 'w');
    console.log('正在写入文件...');
    await fileHandle.write(data);
    console.log('数据写入成功。');
  } catch (error) {
    console.error('文件处理期间发生错误:', error);
  } finally {
    if (fileHandle) {
      console.log('正在关闭文件...');
      await fileHandle.close();
    }
  }
}

这段代码可以工作,但暴露了几个弱点:

现在,想象一下管理多个资源,比如一个数据库连接和一个文件句柄。代码很快就会变成一个嵌套的烂摊子:


async function logQueryResultToFile(query, filePath) {
  let dbConnection;
  try {
    dbConnection = await getDbConnection();
    const result = await dbConnection.query(query);

    let fileHandle;
    try {
      fileHandle = await fs.open(filePath, 'w');
      await fileHandle.write(JSON.stringify(result));
    } finally {
      if (fileHandle) {
        await fileHandle.close();
      }
    }
  } finally {
    if (dbConnection) {
      await dbConnection.release();
    }
  }
}

这种嵌套难以维护和扩展。这明确地表明需要一个更好的抽象。而这正是显式资源管理旨在解决的问题。

范式转变:显式资源管理的原则

显式资源管理 (ERM) 在资源对象和 JavaScript 运行时之间引入了一个契约。其核心思想很简单:一个对象可以声明它应该如何被清理,而语言则提供了语法,在对象离开作用域时自动执行该清理操作。

这是通过两个主要组件实现的:

  1. 可处置协议 (The Disposable Protocol): 一种标准方式,让对象可以使用特殊符号定义自己的清理逻辑:Symbol.dispose 用于同步清理,Symbol.asyncDispose 用于异步清理。
  2. usingawait using 声明: 将资源绑定到块作用域的新关键字。当退出该块时,会自动调用资源的清理方法。

核心概念:Symbol.disposeSymbol.asyncDispose

ERM 的核心是两个新的知名符号 (well-known Symbols)。一个对象如果有一个以这些符号之一为键的方法,就被认为是一个“可处置资源”。

使用 Symbol.dispose 进行同步处置

Symbol.dispose 符号指定了一个同步清理方法。这适用于清理过程不需要任何异步操作的资源,比如同步关闭文件句柄或释放在内存中的锁。

让我们为一个临时文件创建一个包装器,使其能够自我清理。


const fs = require('fs');
const path = require('path');

class TempFile {
  constructor(content) {
    this.path = path.join(__dirname, `temp_${Date.now()}.txt`);
    fs.writeFileSync(this.path, content);
    console.log(`已创建临时文件: ${this.path}`);
  }

  // 这是同步的可处置方法
  [Symbol.dispose]() {
    console.log(`正在处置临时文件: ${this.path}`);
    try {
      fs.unlinkSync(this.path);
      console.log('文件删除成功。');
    } catch (error) {
      console.error(`删除文件失败: ${this.path}`, error);
      // 在 dispose 方法内部处理错误也很重要!
    }
  }
}

TempFile 的任何实例现在都是一个可处置资源。它有一个以 Symbol.dispose 为键的方法,其中包含了从磁盘删除文件的逻辑。

使用 Symbol.asyncDispose 进行异步处置

许多现代的清理操作都是异步的。关闭数据库连接可能涉及通过网络发送一个 QUIT 命令,或者消息队列客户端可能需要刷新其传出缓冲区。对于这些场景,我们使用 Symbol.asyncDispose

Symbol.asyncDispose 关联的方法必须返回一个 Promise(或者是 async 函数)。

让我们来模拟一个需要异步释放回池中的数据库连接。


// 一个模拟的数据库池
const mockDbPool = {
  getConnection: () => {
    console.log('已获取数据库连接。');
    return new MockDbConnection();
  }
};

class MockDbConnection {
  query(sql) {
    console.log(`正在执行查询: ${sql}`);
    return Promise.resolve({ success: true, rows: [] });
  }

  // 这是异步的可处置方法
  async [Symbol.asyncDispose]() {
    console.log('正在将数据库连接释放回池中...');
    // 模拟释放连接时的网络延迟
    await new Promise(resolve => setTimeout(resolve, 50));
    console.log('数据库连接已释放。');
  }
}

现在,任何 MockDbConnection 实例都是一个异步可处置资源。当不再需要它时,它知道如何异步地释放自己。

新语法:usingawait using 的实际应用

定义了我们的可处置类之后,现在我们可以使用新的关键字来自动管理它们。这些关键字创建了块作用域的声明,就像 letconst 一样。

使用 using 进行同步清理

using 关键字用于实现了 Symbol.dispose 的资源。当代码执行离开 using 声明所在的块时,[Symbol.dispose]() 方法会被自动调用。

让我们来使用我们的 TempFile 类:


function processDataWithTempFile() {
  console.log('进入块...');
  using tempFile = new TempFile('This is some important data.');

  // 你可以在这里使用 tempFile
  const content = fs.readFileSync(tempFile.path, 'utf8');
  console.log(`从临时文件读取: "${content}"`);

  // 这里不需要清理代码!
  console.log('...正在做更多工作...');
} // <-- tempFile.[Symbol.dispose]() 会在这里被自动调用!

processDataWithTempFile();
console.log('已退出块。');

输出将会是:

进入块...
已创建临时文件: /path/to/temp_1678886400000.txt
从临时文件读取: "This is some important data."
...正在做更多工作...
正在处置临时文件: /path/to/temp_1678886400000.txt
文件删除成功。
已退出块。

看,这多么整洁!资源的整个生命周期都包含在块内。我们声明它,使用它,然后就可以忘记它。语言会处理清理工作。这在可读性和安全性上是一个巨大的进步。

管理多个资源

你可以在同一个块中有多个 using 声明。它们将以其创建顺序的相反顺序被处置(LIFO 或“栈式”行为)。


{
  using resourceA = new MyDisposable('A'); // 第一个创建
  using resourceB = new MyDisposable('B'); // 第二个创建
  console.log('在块内部,使用资源中...');
} // resourceB 先被处置,然后是 resourceA

使用 await using 进行异步清理

await using 关键字是 using 的异步对应物。它用于实现了 Symbol.asyncDispose 的资源。由于清理是异步的,该关键字只能在 async 函数内部或模块的顶层使用(如果支持顶层 await)。

让我们来使用我们的 MockDbConnection 类:


async function performDatabaseOperation() {
  console.log('进入异步函数...');
  await using db = mockDbPool.getConnection();

  await db.query('SELECT * FROM users');

  console.log('数据库操作完成。');
} // <-- await db.[Symbol.asyncDispose]() 会在这里被自动调用!

(async () => {
  await performDatabaseOperation();
  console.log('异步函数已完成。');
})();

输出演示了异步清理过程:

进入异步函数...
已获取数据库连接。
正在执行查询: SELECT * FROM users
数据库操作完成。
正在将数据库连接释放回池中...
(等待 50ms)
数据库连接已释放。
异步函数已完成。

就像 using 一样,await using 语法处理了整个生命周期,但它会正确地 await 异步清理过程。它甚至可以处理仅支持同步处置的资源——它只是不会等待它们。

高级模式:DisposableStackAsyncDisposableStack

有时,using 的简单块作用域不够灵活。如果你需要管理的资源组的生命周期不与单个词法块绑定怎么办?或者如果你正在与一个不产生带有 Symbol.dispose 的对象的旧库集成怎么办?

对于这些场景,JavaScript 提供了两个辅助类:DisposableStackAsyncDisposableStack

DisposableStack:灵活的清理管理器

DisposableStack 是一个管理一组清理操作的对象。它本身也是一个可处置资源,因此你可以用一个 using 块来管理它的整个生命周期。

它有几个有用的方法:

示例:条件性资源管理

想象一个函数,它只在满足特定条件时才打开日志文件,但你希望所有的清理工作都在最后统一进行。


function processWithConditionalLogging(shouldLog) {
  using stack = new DisposableStack();

  const db = stack.use(getDbConnection()); // 总是使用数据库

  if (shouldLog) {
    const logFileStream = fs.createWriteStream('app.log');
    // 推迟流的清理工作
    stack.defer(() => {
      console.log('正在关闭日志文件流...');
      logFileStream.end();
    });
    db.logTo(logFileStream);
  }

  db.doWork();

} // <-- 栈被处置,以 LIFO 顺序调用所有注册的清理函数。

AsyncDisposableStack:为异步世界而生

你可能已经猜到,AsyncDisposableStack 是异步版本。它可以管理同步和异步的可处置资源。它的主要清理方法是 .disposeAsync(),该方法返回一个在所有异步清理操作完成时解析的 Promise

示例:管理混合资源

让我们创建一个 Web 服务器的请求处理程序,它需要一个数据库连接(异步清理)和一个临时文件(同步清理)。


async function handleRequest() {
  await using stack = new AsyncDisposableStack();

  // 管理一个异步可处置资源
  const dbConnection = await stack.use(getAsyncDbConnection());

  // 管理一个同步可处置资源
  const tempFile = stack.use(new TempFile('request data'));

  // 从旧 API 中接管一个资源
  const legacyResource = getLegacyResource();
  stack.adopt(legacyResource, () => legacyResource.shutdown());

  console.log('正在处理请求...');
  await doWork(dbConnection, tempFile.path);

} // <-- stack.disposeAsync() 被调用。它会正确地等待异步清理完成。

AsyncDisposableStack 是一个强大的工具,用于以一种干净、可预测的方式来编排复杂的设置和拆卸逻辑。

使用 SuppressedError 实现健壮的错误处理

ERM 最微妙但最重要的改进之一是它处理错误的方式。如果在 using 块内部抛出了一个错误,并且在随后的自动处置过程中又抛出了另一个错误,会发生什么?

在旧的 try...finally 世界里,来自 finally 块的错误通常会覆盖或“抑制”来自 try 块的原始、更重要的错误。这常常使得调试变得极其困难。

ERM 通过一种新的全局错误类型解决了这个问题:SuppressedError。如果在另一个错误已经传播时,处置过程中又发生了一个错误,那么这个处置错误就会被“抑制”。原始错误会被抛出,但它现在会有一个 `suppressed` 属性,其中包含了被抑制的处置错误。


class FaultyResource {
  [Symbol.dispose]() {
    throw new Error('处置期间出错!');
  }
}

try {
  using resource = new FaultyResource();
  throw new Error('操作期间出错!');
} catch (e) {
  console.log(`捕获到错误: ${e.message}`); // 操作期间出错!
  if (e.suppressed) {
    console.log(`被抑制的错误: ${e.suppressed.message}`); // 处置期间出错!
    console.log(e instanceof SuppressedError); // false
    console.log(e.suppressed instanceof Error); // true
  }
}

这种行为确保了你永远不会丢失原始失败的上下文,从而带来更健壮、更易于调试的系统。

JavaScript 生态系统中的实际用例

显式资源管理的应用非常广泛,并且与全球各地的开发者息息相关,无论他们是在后端、前端还是测试领域工作。

浏览器和运行时支持

作为一个现代特性,了解你可以在哪里使用显式资源管理非常重要。截至 2023 年末 / 2024 年初,它在主流 JavaScript 环境的最新版本中得到了广泛支持:

对于旧环境,你需要依赖像 Babel 这样的转译器,并配合适当的插件来转换 using 语法,并 polyfill 必要的符号和栈类。

结论:安全与清晰的新时代

JavaScript 的显式资源管理不仅仅是语法糖;它是对语言的一项根本性改进,旨在提升安全性、清晰度和可维护性。通过自动化繁琐且易错的资源清理过程,它让开发者能够专注于他们的核心业务逻辑。

关键要点如下:

当你开始新项目或重构现有代码时,请考虑采用这种强大的新模式。它将使你的 JavaScript 更简洁,你的应用程序更可靠,你作为开发者的生活也更轻松一点。这是编写现代、专业 JavaScript 的一项真正的全球标准。