首页 值得一看 🔍️

最近发布的 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 声明。它们都类似于 JavaScriptusing 关键字,提供类似的显式方法来在作用域末尾执行对象的 “清理” 。

using 声明在其作用域范围的最后或在 “提前返回”(如主动 returnthrow 错误)之前执行清理动作。它们也像堆栈一样以先进后出的顺序进行处理。

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

如果你希望其他人一致地执行清理逻辑,那么根据 DisposableAsyncDisposable 定义类型可以使你的代码更容易使用。实际上,许多现有的类型都存在于具有 dispose()close() 方法的环境中。例如,Visual Studio Code api 甚至定义了自己的 Disposable 接口。浏览器和运行时(如 Node.jsDenoBun )中的 api 也可能选择使用 Symbol.disposeSymbol.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 ?我们应该为需要执行的每个可能的操作公开一个方法,还是应该将属性直接公开?

下面有请 DisposableStackAsyncDisposableStack。这些对象对于进行一次性清理和任意数量的清理非常有用。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 方法,如 useadopt)应在创建资源后立即调用。顾名思义,DisposableStack 它像堆栈一样按照先进后出的顺序处理它跟踪的所有内容,因此 defer 在创建值后会立即执行,这有助于避免一些奇怪的依赖问题。AsyncDisposable 工作原理类似,但可以跟踪 async 函数和 AsyncDisposables,并且本身就是一个 AsyncDisposable

这个方法在很多方面与 GoSwiftZigOdin 等语言中的关键字 defer 相似,其中约定也应该是相似的。



文章评论

未显示?请点击刷新