显式的资源管理的英文名字是:Explicit Resource Management。该特性在ECMAScript的提案中还处于Stage3阶段,虽然TypeScript 5.2版本已经实现了该特性,但是TypeScript只能在语法层面进行支持,对于新的变量、新的函数,还需要JavaScript运行环境支持,否则只能使用Polyfill。
[Symbol.dispose]()方法
我们在用Node.js做本地开发或者服务端开发的时候,时常需要打开本地文件,对文件的数据进行操作,然后还需要关闭文件。关闭文件的操作我们很容易忘记,不关闭文件会导致内存不能回收,如果重复不断地运行这段程序,那内存很快会耗尽。现在的计算机语言都在竭尽全力的帮助程序员尽量方便的对内存进行回收,JavaScript、TypeScript也不例外。
举例如下,我们之前要打开一个文件,往往需要这样写代码:
typescript
import * as fs from "fs";
export function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
//
// 操作文件数据,这里很容易提前return
//
//
// 关闭文件。很容易被遗漏
fs.closeSync(file);
// 删除文件
fs.unlinkSync(path);
}
函数起始位置先打开文件,末尾关闭文件。但是如果有其他程序员不小心在代码中间部位提前return
,那后面的关闭文件代码就得不到执行,所以很容易导致内存泄漏。
作为团队里的架构师来说,一般会希望把这段关闭的代码进行封装,从而不需要每个开发人员都去手动的调用关闭操作。 这次的这个新特性就满足了架构师的需求:
typescript
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);
}
}
可以看到文件的打开和关闭操作被封装到了一个类中,这个类实现了Disposable
接口,这个接口有一个方法需要实现,那就是[Symbol.dispose]()
。 Disposable
和[Symbol.dispose]()
就是这次的新特性之一。 上面的代码虽然对打开和关闭进行了封装,但是依然需要类的使用者去调用[Symbol.dispose]()
方法,还是不完美。
using关键字 因此继续引入一个新的特性`using`关键字: ```typescript export function doSomeWork() { // 使用using时,file的作用域结束后,[Symbol.dispose]方法会被自动调用 using file = new TempFile(".some_temp_file");
kotlin
// 操作文件数据,有可能会提前return
if (someCondition()) {
// do some more work...
return;
}
}
csharp
可以看到`TempFile`的对象实例变量`file`前面用了一个关键字`using`,而不是`const`,它的功能和`const`类似,都是声明一个常量,但是它还表示,在`file`对象的作用域结束时,`file`对象的`[Symbol.dispose]()`方法会被自动调用。这就解决了架构师想解决的问题,架构师封装了`TempFile`类之后,其资源释放方法会被自动调用,不过前提是需要使用`using`关键字。
`using`关键字有如下几个特性:
1. 会在目标变量作用域(containing scope)结束时或者在`return`前,自动调用变量的成员方法`[Symbol.dispose]()`。这里要注意的是闭包的情况,后面会讲。
2. 当同一个作用域中多次使用`using`时,会从最后一个使用`using`的变量开始往前,挨个儿调用变量下的`[Symbol.dispose]`为名称方法,和栈的先进后出规则一致。
`using`的特性1,关于闭包的问题,需要详细说明一下:
```typescript
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);
}
}
function doSomeWork() {
// 使用using,file的作用域结束后,[Symbol.dispose]()方法会被自动调用
using file = new TempFile(".some_temp_file");
return () => {return file};
}
const getFile = doSomeWork();
const outFile = getFile();
上面例子中doSomeWork
中嵌套了箭头函数定义,形成闭包,file
最终被返回到外层,只要getFile
变量不被销毁,file
变量就不会被销毁。但是这并不影响file
下面的[Symbol.dispose]()
方法被执行。doSomeWork
函数在返回的那一刻,file
的containing scope
作用域已经结束了。containing scope
包括函数块作用域和大括号块作用域。
using
的特性2,举例如下:
typescript
// 函数也照样可以返回Disposable,后面会讲解
function loggy(id: string): Disposable {
console.log(`Creating ${id}`);
return {
[Symbol.dispose]() {
console.log(`Disposing ${id}`);
}
}
}
function func() {
using a = loggy("a");
using b = loggy("b");
{
using c = loggy("c");
using d = loggy("d");
}
using e = loggy("e");
return;
// 这里不会被运行,因此也没有dispose
using f = loggy("f");
}
func();
// Creating a
// Creating b
// Creating c
// Creating d
// Disposing d
// Disposing c
// Creating e
// Disposing e
// Disposing b
// Disposing a
这个例子主要是讲[Symbol.dispose]()
的调用顺序问题。 可以看到Disposing d
最先被调用,因为在众多using
变量中c
和d
最先结束了作用域,大括号是块级作用域,c
和d
被dispose
的顺序则是从后往前,最后声明的变量最先被dispose
。 f
由于在return
之后,因此不会被运行。 因此d
和c
被dispose
后,从最后的e
开始dispose
,依次往上。
函数返回Disposable类型变量给using
上面的例子中大部分都在说在类中定义[Symbol.dispose]()
函数,类还需要继承于Disposable
接口。 其实对于using
来说,只要所作用的变量带有[Symbol.dispose]()
方法属性,就可以了,而TypeScript是根据变量的属性内容来判断其类型的,因此这样一个对象,就可以认为是Disposable
类型:
typescript
// Disposable类型的对象
{
[Symbol.dispose]() {
console.log(`Disposing ${id}`);
}
}
Disposable
接口的定义如下:
typescript
interface Disposable {
[Symbol.dispose](): void;
}
因此我们也完全可以这么写:
typescript
function loggy(id: string): Disposable {
console.log(`Creating ${id}`);
return {
[Symbol.dispose]() {
console.log(`Disposing ${id}`);
}
}
}
function func() {
using a = loggy("a");
}
func();
由于loggy()
返回的对象有[Symbol.dispose]()
方法属性,因此可以对该对象使用using
来声明。而且该对象的类型就是Disposable
。
异常处理
细心的朋友可能会好奇对于抛异常的情况,using
还有用吗?答案是依然有用。 举例如下:
typescript
function throwy(id: string) {
return {
[Symbol.dispose]() {
console.log('外界抛异常也不阻挡我被执行');
}
};
}
function func() {
using a = throwy("a");
throw new Error("oops!")
// 最终 [Symbol.dispose]()依然会被执行
}
func();
可以看到func()
在返回前抛异常了,这时依然不影响[Symbol.dispose]()
被执行。事实上该异常会先被catch
住,等[Symbol.dispose]()
执行完,再被抛出来。
不过还有一种更复杂的情况,就是 [Symbol.dispose]()
中也抛异常了:
typescript
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!")
// 最后依然会执行[Symbol.dispose]()方法
}
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!
}
这时可以看到有两处在抛异常,func()
函数抛异常后[Symbol.dispose]()
依然会执行,因此两个异常都会被抛出,但是只有一个catch
,那catch(e: any)
括号的e
会是哪个呢?答案是,e
是一个新的异常对象SuppressedError
,该对象对那两个被抛出的异常对象进行了封装:
e.error
:代表[Symbol.dispose]()
方法中的Error
对象。e.suppressed
:代表func()
函数中的Error
对象
suppressed
是抑制的意思,可以理解为,其实func()
函数中的异常先被抑制住了,然后最后和[Symbol.dispose]()
中的异常放到同一个Error
对象中被抛出。
说到这里,可能大家会好奇TypeScript是怎么做到在作用域结束前,不管是否有异常都能执行[Symbol.dispose]()
的呢?其实翻看其编译后的代码,可以看到,其实是用try {} finally{}
实现的:
typescript
try{
} finally {
}
finally
可以确保不管是否有异常抛出,[Symbol.dispose]()
都会得到执行。
如果关闭资源是异步的怎么办
所谓异步地关闭资源就是指dispose
方法是异步的方法,这时首先我们应该将[Symbol.dispose]()
换成async [Symbol.asyncDispose]()
。 举例如下:
typescript
async function doWork() {
// 模拟一个异步
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();
},
}
}
这时如果我们要等待其关闭结束在继续往后执行的话,那我们应该怎么做呢?其实我估计大家已经想到了,对,就是这样await using
,加一个await
就可以了,不过这时using
后面的变量类型就不是Disposable
了,而是AsyncDisposable
,其实很好理解,它的[Symbol.asyncDispose]()
方法的返回值是Promise
类型,自然就可以用await了。
AsyncDisposable
也是一个接口(interface
),其定义如下:
typescript
interface AsyncDisposable {
[Symbol.asyncDispose](): PromiseLike<void>;
}
下面看看await using
如何使用:
typescript
async function func() {
await using a = loggy("a");
await using b = loggy("b");
{
await using c = loggy("c");
await using d = loggy("d");
}
await using e = loggy("e");
return;
// 这里不会被运行
// 不会被创建,也不会被dispose
await using f = loggy("f");
}
func();
// Constructing a
// Constructing b
// Constructing c
// Constructing d
// Disposing (async) d
// Disposing (async) c
// Constructing e
// Disposing (async) e
// Disposing (async) b
// Disposing (async) a
更简洁的使用方式
上面也提过,上面的用法特别适合架构师去封装它的代码。但是有时候可能不需要这么重量级的代码,有时候就希望轻便一点的一次性的代码,且没有重复利用的必要。 这时DisposableStack
和AsyncDisposableStack
就派上了用场。 举例如下:
typescript
function doSomeWork() {
const path = ".some_temp_file";
const file = fs.openSync(path, "w+");
using cleanup = new DisposableStack();
cleanup.defer(() => {
fs.closeSync(file);
fs.unlinkSync(path);
});
// 操作文件数据。。。
if (someCondition()) {
// 业务逻辑。。。
return;
}
// 。。。
}
DisposableStack
生成的对象就是一个Disposable
的对象,因此可以用using
。 这里的defer()
方法接收的是一个callback
函数,当cleanup
被执行[Symbol.dispose]()
方法时,该callback
函数会被调用。 defer()
方法和Go、Swift、Zig、Odin语言中关键字defer
很类似。
另外DisposableStack
还有其他一些方法,例如use
adopt
。他们可以往DisposableStack
栈中压入需要被释放的资源。 举例如下:
typescript
using stack = new DisposableStack();
const reader1 = stack.adopt(createReader(), reader => reader.releaseLock());
const reader2 = stack.adopt(createReader(), reader => reader.releaseLock());
const reader3 = stack.adopt(createReader(), reader => reader.releaseLock());
有多个reader
资源被压入栈中,这些资源最后被释放时,还是按照栈的规则,先进后出,因此最先被释放的是reader3
,其次reader2
,再次reader1
。
AsyncDisposableStack
和DisposableStack
用法一致,只不过它的回调函数返回的是Promise
,因此需要使用对应的await using
。
Polyfill
最后,还是我们刚开始说的,这个特性本质上是Javascript的新特性,目前还处在Stage3阶段,各个浏览器、Node.js还没有实现这些特性,TypeScript只能是语法层面去实现这个特性,有些Native的变量TypeScript做不了,例如Symbol.dispose
,Symbol
类型是一个Native的特殊类型,JavaScript很难去模拟,TypeScript的核心任务是创造新的类型相关的语法,因此也不太适合去模拟。
目前需要对下面这些进行Polyfill: Symbol.dispose
Symbol.asyncDispose
DisposableStack
AsyncDisposableStack
SuppressedError
不过如果我们仅仅是简单的使用using
或者await using
,那我们仅仅需要在JavaScript中放入如下Polyfill:
javascript
Symbol.dispose ??= Symbol("Symbol.dispose");
Symbol.asyncDispose ??= Symbol("Symbol.asyncDispose");
需要注意的是最好是在JavaScript中放入该段代码,不是TypeScript,如果放到TypeScript中,它会提示你Symbol.dispose
是只读属性,语法检查不过,如果实在想放TypeScript中,那需要给这两行代码分别加// @ts-ignore
的注释,这样可以关闭单行的语法检查。另外最好加到所有代码的最前面。 如果运行环境版本较老,上面的??=
也可以改成if else
的形式。
另外,微软官方文档还说需要对tsconfig.json
进行如下配置,es2022
也可以被替换成更低版本:
javascript
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022", "esnext.disposable", "dom"]
}
}
就是这些了。