Ykcory web developer

TypeScript 5.0 更新了什么?


TypeScript 5.0 更新了什么?

最近,TypeScript 5.0 终于发布 Release 版本了,增加了许多有用的新特性。旨在使TypeScript更小、更简单、更快速。

Here’s a quick list of what’s new in TypeScript 5.0!

装饰器 / Decorators

装饰器是即将推出的 ECMAScript 功能,它允许我们以可重用的方式自定义类及其成员。

让我们考虑以下代码:

class Person {
    name: string;
    constructor(name: string) {
        this.name = name;
    }

    greet() {
        console.log(`Hello, my name is ${this.name}.`);
    }
}

const p = new Person("Ron");
p.greet();

这里的 greate方法很简单,但是如果这个方法做一些异步的、递归的、或者有副作用的等等。我们尝试使用console.log来帮助我们debug

class Person {
    name: string;
    constructor(name: string) {
        this.name = name;
    }

    greet() {
        console.log("LOG: Entering method.");

        console.log(`Hello, my name is ${this.name}.`);

        console.log("LOG: Exiting method.")
    }
}

如果我们有一种办法可以很方便的在每一个方法都加上类似的代码,那就会很方便的帮助我们开发。这就是装饰器的用武之地。

我们可以编写一个loggedMethod函数:

function loggedMethod(originalMethod: any, _context: any) {

    function replacementMethod(this: any, ...args: any[]) {
        console.log("LOG: Entering method.")
        const result = originalMethod.call(this, ...args);
        console.log("LOG: Exiting method.")
        return result;
    }

    return replacementMethod;
}

这里接收一个originalMethod的参数,并且返回了一个函数replacementMethod,执行过程如下:

  1. 打印:LOG: Entering method.
  2. this参数和它的所有参数传递给原始方法
  3. 打印:LOG: Exiting method.
  4. 返回原始结果的执行方法

现在我们可以使用loggedMethod装饰 greet方法:

class Person {
    name: string;
    constructor(name: string) {
        this.name = name;
    }

    @loggedMethod
    greet() {
        console.log(`Hello, my name is ${this.name}.`);
    }
}

const p = new Person("Ron");
p.greet();

// Output:
//
//   LOG: Entering method.
//   Hello, my name is Ron.
//   LOG: Exiting method.

我们只需要在greet上面使用 loggedMethod——注意这里的写法是@loggedMethod。这样,它会被原始方法和 context 对象调用。因为 loggedMethod 返回了一个新函数,该函数替换了 greet 的原始定义。

loggedMethod 的第二个参数被称为“ context 对象”,它包含一些关于如何声明装饰方法的有用信息——比如它是 #private 成员还是静态成员,或者方法的名称是什么。 下面来重写 loggedMethod 以利用它并打印出被修饰的方法的名称。

function loggedMethod(originalMethod: any, context: ClassMethodDecoratorContext) {
    const methodName = String(context.name);

    function replacementMethod(this: any, ...args: any[]) {
        console.log(`LOG: Entering method '${methodName}'.`)
        const result = originalMethod.call(this, ...args);
        console.log(`LOG: Exiting method '${methodName}'.`)
        return result;
    }

    return replacementMethod;
}

TypeScript 提供了一个名为 ClassMethodDecoratorContext 的类型,它对方法装饰器采用的 context 对象进行建模。除了元数据之外,方法的 context 对象还有一个有用的函数:addInitializer。 这是一种挂接到构造函数开头的方法(如果使用静态方法,则挂接到类本身的初始化)。

举个例子,在JavaScript中,经常会写如下的模式:

class Person {
    name: string;
    constructor(name: string) {
        this.name = name;

        this.greet = this.greet.bind(this);
    }

    greet() {
        console.log(`Hello, my name is ${this.name}.`);
    }
}

或者,greet可以声明为初始化为箭头函数的属性。

class Person {
    name: string;
    constructor(name: string) {
        this.name = name;
    }

    greet = () => {
        console.log(`Hello, my name is ${this.name}.`);
    };
}

编写这段代码是为了确保在greet作为独立函数调用或作为回调函数传递时不会重新绑定。

const greet = new Person("Ray").greet;

greet();

可以编写一个装饰器,使用addInitializer在构造函数中为我们调用 bind

function bound(originalMethod: any, context: ClassMethodDecoratorContext) {
    const methodName = context.name;
    if (context.private) {
        throw new Error(`'bound' cannot decorate private properties like ${methodName as string}.`);
    }
    context.addInitializer(function () {
        this[methodName] = this[methodName].bind(this);
    });
}

bound不会返回任何内容,所以当它装饰一个方法时,它会保留原来的方法。相反,它会在其他字段初始化之前添加逻辑。

class Person {
    name: string;
    constructor(name: string) {
        this.name = name;
    }

    @bound
    @loggedMethod
    greet() {
        console.log(`Hello, my name is ${this.name}.`);
    }
}

const p = new Person("Ray");
const greet = p.greet;

greet();

注意,我们使用了两个装饰器:@bound@loggedMethod。这些装饰是以“相反的顺序”运行的。也就是说,@loggedMethod修饰了原始方法greet@bound修饰了@loggedMethod的结果。在这个例子中,这没有关系——但如果装饰器有副作用或期望某种顺序,则可能有关系。

可以将这些装饰器放在同一行:

@bound @loggedMethod greet() {
		console.log(`Hello, my name is ${this.name}.`);
}

我们甚至可以创建返回装饰器函数的函数。这使得我们可以对最终的装饰器进行一些自定义。如果我们愿意,我们可以让loggedMethod返回一个装饰器,并自定义它记录消息的方式。

function loggedMethod(headMessage = "LOG:") {
    return function actualDecorator(originalMethod: any, context: ClassMethodDecoratorContext) {
        const methodName = String(context.name);

        function replacementMethod(this: any, ...args: any[]) {
            console.log(`${headMessage} Entering method '${methodName}'.`)
            const result = originalMethod.call(this, ...args);
            console.log(`${headMessage} Exiting method '${methodName}'.`)
            return result;
        }

        return replacementMethod;
    }
}

如果这样做,必须在使用loggedMethod作为装饰器之前调用它。然后,可以传入任何字符串作为记录到控制台的消息的前缀。

class Person {
    name: string;
    constructor(name: string) {
        this.name = name;
    }

    @loggedMethod("")
    greet() {
        console.log(`Hello, my name is ${this.name}.`);
    }
}

const p = new Person("Ron");
p.greet();

// Output:
//
//    Entering method 'greet'.
//    Hello, my name is Ron.
//    Exiting method 'greet'.

装饰器可不仅仅用于方法,还可以用于属性/字段、gettersetter和自动访问器。甚至类本身也可以装饰成子类化和注册。

上面的loggedMethodbound装饰器示例写的很简单,并省略了大量关于类型的细节。实际上,编写装饰器可能相当复杂。例如,上面的loggedMethod类型良好的版本可能看起来像这样:

function loggedMethod<This, Args extends any[], Return>(
    target: (this: This, ...args: Args) => Return,
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
    const methodName = String(context.name);

    function replacementMethod(this: This, ...args: Args): Return {
        console.log(`LOG: Entering method '${methodName}'.`)
        const result = target.call(this, ...args);
        console.log(`LOG: Exiting method '${methodName}'.`)
        return result;
    }

    return replacementMethod;
}

我们必须使用thisArgsreturn类型参数分别建模this、参数和原始方法的返回类型。

具体定义装饰器函数的复杂程度取决于想要保证什么。需要记住,装饰器的使用次数将超过它们的编写次数,所以类型良好的版本通常是更好的——但显然与可读性有一个权衡,所以请尽量保持简单。

const 类型参数 / const Type Parameters

当推断一个对象的类型时,TypeScript通常会选择一个通用类型。例如,在本例中,names 的推断类型是string[]

type HasNames = { readonly names: string[] };
function getNamesExactly<T extends HasNames>(arg: T): T["names"] {
    return arg.names;
}

// Inferred type: string[]
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"]});

通常这样做的目的是实现突变。然而,根据getnames确切的作用以及它的使用方式,通常情况下需要更具体的类型。到目前为止,通常不得不在某些地方添加const,以实现所需的推断:

// The type we wanted:
//    readonly ["Alice", "Bob", "Eve"]
// The type we got:
//    string[]
const names1 = getNamesExactly({ names: ["Alice", "Bob", "Eve"]});

// Correctly gets what we wanted:
//    readonly ["Alice", "Bob", "Eve"]
const names2 = getNamesExactly({ names: ["Alice", "Bob", "Eve"]} as const);

这写起来会很麻烦,也很容易忘记。在 TypeScript 5.0 中,可以在类型参数声明中添加const修饰符,从而使类const推断成为默认值:

type HasNames = { names: readonly string[] };
function getNamesExactly<const T extends HasNames>(arg: T): T["names"] {
//                       ^^^^^
    return arg.names;
}

// Inferred type: readonly ["Alice", "Bob", "Eve"]
// Note: Didn't need to write 'as const' here
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });

注意,const修饰符并不排斥可变值,也不需要不可变约束。使用可变类型约束可能会得到意外的结果。例如:

declare function fnBad<const T extends string[]>(args: T): void;

// 'T' is still 'string[]' since 'readonly ["a", "b", "c"]' is not assignable to 'string[]'
fnBad(["a", "b" ,"c"]);

这里,T的推断候选值是readonly ["a", "b", "c"],而readonly数组不能用于需要可变数组的地方。在这种情况下,推理回退到约束,数组被视为string[],调用仍然成功进行。

更好的定义应该使用readonly string[]:

declare function fnGood<const T extends readonly string[]>(args: T): void;

// T is readonly ["a", "b", "c"]
fnGood(["a", "b" ,"c"]);

同样,要记住,const修饰符只影响在调用中编写的对象、数组和基本类型表达式的推断,所以不会(或不能)用const修饰的参数将看不到任何行为的变化:

declare function fnGood<const T extends readonly string[]>(args: T): void;
const arr = ["a", "b" ,"c"];

// 'T' is still 'string[]'-- the 'const' modifier has no effect here
fnGood(arr);

extends 支持多配置文件 / Supporting Multiple Configuration Files in extends

当管理多个项目时,通常每个项目的 tsconfig.json 文件都会继承于基础配置。这就是为什么TypeScript支持extends字段,用于从compilerOptions中复制字段。

// packages/front-end/src/tsconfig.json
{
    "extends": "../../../tsconfig.base.json",
    "compilerOptions": {
        "outDir": "../lib",
        // ...
    }
}

但是,在某些情况下,可能希望从多个配置文件进行扩展。例如,想象一下使用一个TypeScript 基本配置文件到 npm。如果想让所有的项目也使用npm中@tsconfig/strictest包中的选项,那么有一个简单的解决方案:将tsconfig.base.json扩展到@tsconfig/strictest

// tsconfig.base.json
{
    "extends": "@tsconfig/strictest/tsconfig.json",
    "compilerOptions": {
        // ...
    }
}

这在一定程度上是有效的。 如果有任何项目不想使用 @tsconfig/strictest,就必须手动禁用这些选项,或者创建一个不从 @tsconfig/strictest 扩展的单独版本的 tsconfig.base.json

为了提供更多的灵活性,Typescript 5.0 允许extends字段接收多个项。例如,在这个配置文件中:

{
    "extends": ["a", "b", "c"],
    "compilerOptions": {
        // ...
    }
}

这样写有点像直接扩展 c,其中 c 扩展 b,b 扩展 a。 如果任何字段“冲突”,则后一个项生效。

所以在下面的例子中,strictNullChecksnoImplicitAny 都会在最终的 tsconfig.json 中启用。

// tsconfig1.json
{
    "compilerOptions": {
        "strictNullChecks": true
    }
}

// tsconfig2.json
{
    "compilerOptions": {
        "noImplicitAny": true
    }
}

// tsconfig.json
{
    "extends": ["./tsconfig1.json", "./tsconfig2.json"],
    "files": ["./index.ts"]
}

可以用下面的方式重写最上面的例子:

// packages/front-end/src/tsconfig.json
{
    "extends": ["@tsconfig/strictest/tsconfig.json", "../../../tsconfig.base.json"],
    "compilerOptions": {
        "outDir": "../lib",
        // ...
    }
}

所有枚举都是联合枚举 / All enums Are Union enums

当 TypeScript 最初引入枚举时,它只不过是一组具有相同类型的数值常量:

enum E {
    Foo = 10,
    Bar = 20,
}

E.Foo 和 E.Bar 唯一的特别之处在于它们可以分配给任何期望类型 E 的东西。除此之外,它们只是数字。

function takeValue(e: E) {}

takeValue(E.Foo); // works
takeValue(123); // error!

直到 TypeScript 2.0 引入了枚举字面量类型,它赋予每个枚举成员自己的类型,并将枚举本身转换为每个成员类型的联合。它还允许我们只引用枚举类型的一个子集,并缩小这些类型。

// Color is like a union of Red | Orange | Yellow | Green | Blue | Violet
enum Color {
    Red, Orange, Yellow, Green, Blue, /* Indigo */, Violet
}

// Each enum member has its own type that we can refer to!
type PrimaryColor = Color.Red | Color.Green | Color.Blue;

function isPrimaryColor(c: Color): c is PrimaryColor {
    // Narrowing literal types can catch bugs.
    // TypeScript will error here because
    // we'll end up comparing 'Color.Red' to 'Color.Green'.
    // We meant to use ||, but accidentally wrote &&.
    return c === Color.Red && c === Color.Green && c === Color.Blue;
}

给每个枚举成员指定自己的类型有一个问题,即这些类型在某种程度上与成员的实际值相关联。在某些情况下,这个值是不可能计算出来的——例如,枚举成员可以通过函数调用进行初始化。

enum E {
    Blah = Math.random()
}

每当TypeScript遇到这些问题时,它都会悄无声息地退出并使用旧的枚举策略。这意味着要放弃并集和字面量类型的所有优点。

TypeScript 5.0 通过为每个计算成员创建唯一的类型,设法将所有枚举转换为联合枚举。这意味着现在可以缩小所有枚举的范围,并将其成员作为类型引用。

https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/