最近发布的 TypeScript 5.2
版本中带来了一个全新的关键字 using
,同时它也是一项进入 Stage 3
阶段的 TC39
提案。官方对它的定义为 Explicit Resource Management
,即显式资源管理,它具体解决了什么问题,又怎么使用呢,我们一起来看一下。
在日常开发中,当我们创建某个对象后,通常需要进行某种 “清理” 的动作,比如关闭长链接、删除临时文件、释放内存等等。
比如我们现在有这样一个函数,它创建了一个临时文件,然后对这个文件进行了某些读写操作,最后关闭并将其删除。
import * as fs from "fs";
export function doSomeWork() {
const path = ".conardli_temp_file";
const file = fs.openSync(path, "w+");
// 文件操作
// 关闭并删除
fs.closeSync(file);
fs.unlinkSync(path);
}
看起来没什么毛病,但如果我们需要提前 return
呢?
export function doSomeWork() {
const path = ".conardli_temp_file";
const file = fs.openSync(path, "w+");
// 文件操作...
if (someCondition()) {
// 更多操作...
// 关闭并删除.
fs.closeSync(file);
fs.unlinkSync(path);
return;
}
// 关闭并删除.
fs.closeSync(file);
fs.unlinkSync(path);
}
我们需要继续在新增的判断逻辑中增加清理操作。如果抛出异常呢?我们也不能保障文件可以被及时清理掉,这时候我们可能会选择将它们写在 try/finally
中。
export function doSomeWork() {
const path = ".conardli_temp_file";
const file = fs.openSync(path, "w+");
try {
// 文件操作...
if (someCondition()) {
// 更多操作...
return;
}
}
finally {
// 关闭并删除.
fs.closeSync(file);
fs.unlinkSync(path);
}
}
虽然这挺健壮的,但它给我们的代码增加了相当多的 “噪音”。如果我们开始在 finally
块中添加更多清理逻辑,我们可能还会遇到其他的问题。这就是 “显式资源管理” 提案希望解决的问题。
我们可以用 Symbol.dispose
声明一个方法,并且将需要执行的清理逻辑写在里面,然后我们将类实现一个 TypeScript
提供的新的全局类型 Disposable
:
class TempFile implements Disposable {
#path: string;
#handle: number;
constructor(path: string) {
this.#path = path;
this.#handle = fs.openSync(path, "w+");
}
// 其他方法
[Symbol.dispose]() {
// 关闭并删除.
fs.closeSync(this.#handle);
fs.unlinkSync(this.#path);
}
}
然后我们可以调用这些方法。
export function doSomeWork() {
const file = new TempFile(".conardli_temp_file");
try {
// ...
}
finally {
file[Symbol.dispose]();
}
}
将清理逻辑移到 TempFile
内部本身并没有给我们带来多少好处;我们只是将所有清理工作从 finally
块移到了令一个方法中。但是这个方法有一个众所周知的 “名字” ,这就意味着 JavaScript
可以在它的基础上构建其他的特性。
现在,我们的新关键字 “using” 闪亮登场了,我们可以用它来声明一种新的变量,有点像 const
。关键的区别在于它会在变量作用域结束时调用 Symbol.dispose
方法!
所以我们可以简单地这样写代码:
export function doSomeWork() {
using file = new TempFile(".conardli_temp_file");
// 文件操作...
if (someCondition()) {
// 关闭并删除...
return;
}
}
代码里不再有 try/finally
了,这正是 using
关键字为我们做的事情,我们不必自己来处理这个问题了。
您可能熟悉 C#
中的 using
声明、Python
中的 with
语句或 Java
中的 try-with-resource
声明。它们都类似于 JavaScript
的 using
关键字,提供类似的显式方法来在作用域末尾执行对象的 “清理” 。
using
声明在其作用域范围的最后或在 “提前返回”(如主动 return
或 throw
错误)之前执行清理动作。它们也像堆栈一样以先进后出的顺序进行处理。
function loggy(id: string): Disposable {
console.log(`Creating ${id}`);
return {
[Symbol.dispose]() {
console.log(`Disposing ${id}`);
}
}
}
function func() {
using a = loggy("17");
using b = loggy("b");
{
using c = loggy("c");
using d = loggy("d");
}
using e = loggy("e");
return;
// 无效声明 ❌
using f = loggy("f");
}
f();
// Creating 17
// Creating b
// Creating c
// Creating d
// Disposing d
// Disposing c
// Creating e
// Disposing e
// Disposing b
// Disposing 17
using
声明应该能够处理异常;如果抛出移除,则它会在执行清理后重新抛出。另一方面,函数体可能会按预期执行,但 Symbol.dispose
可能会抛出异常。在这种情况下,该异常也会被重新抛出。
但是,如果处理之前和处理期间的逻辑都抛出异常,会发生什么呢?对于种些情况,Error
引入了一种新的子类型 SuppressedError
。它具有一个保存 suppressed
最后抛出的错误的属性和一个保存 error
最近抛出的错误的属性。
class ErrorA extends Error {
name = "ErrorA";
}
class ErrorB extends Error {
name = "ErrorB";
}
function throwy(id: string) {
return {
[Symbol.dispose]() {
throw new ErrorA(`Error from ${id}`);
}
};
}
function func() {
using a = throwy("a");
throw new ErrorB("oops!")
}
try {
func();
}
catch (e: any) {
console.log(e.name); // SuppressedError
console.log(e.message); // An error was suppressed during disposal.
console.log(e.error.name); // ErrorA
console.log(e.error.message); // Error from a
console.log(e.suppressed.name); // ErrorB
console.log(e.suppressed.message); // oops!
}
大家可能已经注意到,我们在前面这些示例中使用的都是同步方法。但是,许多资源处理都会涉及异步操作,我们需要等待这些操作完成才能继续运行任何其他代码。
这就是为什么还有一个新的 Symbol.asyncDispose
,对应的,还有一个新的 await using
声明。它与 using
声明类似,但关键是它们会等待异步处理。为了方便起见,TypeScript
还引入了一个名为 AsyncDisposable
的全局类型,它可以描述具有 asyncDispose
方法的任何对象。
async function doWork() {
// Do fake work for half a second.
await new Promise(resolve => setTimeout(resolve, 500));
}
function loggy(id: string): AsyncDisposable {
console.log(`Constructing ${id}`);
return {
async [Symbol.asyncDispose]() {
console.log(`Disposing (async) ${id}`);
await doWork();
},
}
}
async function func() {
await using a = loggy("17");
await using b = loggy("b");
{
await using c = loggy("c");
await using d = loggy("d");
}
await using e = loggy("e");
return;
// Unreachable.
// Never created, never disposed.
await using f = loggy("f");
}
f();
// Constructing 17
// Constructing b
// Constructing c
// Constructing d
// Disposing (async) d
// Disposing (async) c
// Constructing e
// Disposing (async) e
// Disposing (async) b
// Disposing (async) 17
如果你希望其他人一致地执行清理逻辑,那么根据 Disposable
和 AsyncDisposable
定义类型可以使你的代码更容易使用。实际上,许多现有的类型都存在于具有 dispose()
或 close()
方法的环境中。例如,Visual Studio Code api
甚至定义了自己的 Disposable
接口。浏览器和运行时(如 Node.js
、Deno
和 Bun
)中的 api
也可能选择使用 Symbol.dispose
和 Symbol.asyncDispose
来处理已经具有清理方法的对象,例如 File Handler
、长连接等。
这对于一个库来说可能听起来很棒,但是对于我们的业务场景来说可能有点沉重。如果你正在有大量的临时清理工作,那么创建新类型可能会引入大量的过度抽象和有关最佳实践的问题。比如我们再来看 TempFile
的例子。
class TempFile implements Disposable {
#path: string;
#handle: number;
constructor(path: string) {
this.#path = path;
this.#handle = fs.openSync(path, "w+");
}
// other methods
[Symbol.dispose]() {
// Close the file and delete it.
fs.closeSync(this.#handle);
fs.unlinkSync(this.#path);
}
}
export function doSomeWork() {
using file = new TempFile(".conardli_temp_file");
// use file...
if (someCondition()) {
// do some more work...
return;
}
}
我们想要的只是记住调用两个函数 - 但这是最好的编写方式吗?我们应该在构造函数中调用 openSync
,创建一个 open()
方法,还是自己传入 handler
?我们应该为需要执行的每个可能的操作公开一个方法,还是应该将属性直接公开?
下面有请 DisposableStack
和 AsyncDisposableStack
。这些对象对于进行一次性清理和任意数量的清理非常有用。DisposableStack
是一个对象,它具有多种跟踪 Disposable
对象的方法,并且可以被赋予用于执行任意清理工作的函数。我们还可以将它们分配给 using
变量,这下明白了吧 — 它们也是 disposable
!
function doSomeWork() {
const path = ".conardli_temp_file";
const file = fs.openSync(path, "w+");
using cleanup = new DisposableStack();
cleanup.defer(() => {
fs.closeSync(file);
fs.unlinkSync(path);
});
// use file...
if (someCondition()) {
// do some more work...
return;
}
// ...
}
defer()
方法只会接受一个回调,并且该回调将在 cleanup
被处理后运行。通常,defer
(以及其他 DisposableStack
方法,如 use
和 adopt
)应在创建资源后立即调用。顾名思义,DisposableStack
它像堆栈一样按照先进后出的顺序处理它跟踪的所有内容,因此 defer
在创建值后会立即执行,这有助于避免一些奇怪的依赖问题。AsyncDisposable
工作原理类似,但可以跟踪 async
函数和 AsyncDisposables
,并且本身就是一个 AsyncDisposable
。
这个方法在很多方面与 Go
、Swift
、Zig
、Odin
等语言中的关键字 defer
相似,其中约定也应该是相似的。