如何在TypeScript中使用装饰器

来自菜鸟教程
跳转至:导航、​搜索

作者选择了 COVID-19 Relief Fund 作为 Write for DOnations 计划的一部分来接受捐赠。

介绍

TypeScriptJavaScript 语言的扩展,它使用带有编译时类型检查器的 JavaScript 运行时。 这种组合允许开发人员使用完整的 JavaScript 生态系统和语言功能,同时在其之上添加可选的静态类型检查、枚举、类和接口。 这些额外功能之一是装饰器支持。

装饰器是一种装饰类成员或类本身的方法,具有额外的功能。 当您将装饰器应用于类或类成员时,您实际上是在调用一个函数,该函数将接收被装饰内容的详细信息,然后装饰器实现将能够动态转换代码,添加额外的功能,并且减少样板代码。 它们是在 TypeScript 中进行元编程的一种方式,TypeScript 是一种编程技术,使程序员能够创建使用来自应用程序本身的其他代码作为数据的代码。

目前,有一个 stage-2 提案将装饰器 添加到 ECMAScript 标准中。 由于它还不是 JavaScript 功能,TypeScript 在实验性标志下提供了自己的装饰器实现。


本教程将向您展示如何在 TypeScript 中为类和类成员创建自己的装饰器,以及如何使用它们。 它将引导您完成不同的代码示例,您可以在自己的 TypeScript 环境或 TypeScript Playground 中进行操作,这是一个允许您直接在浏览器中编写 TypeScript 的在线环境。

先决条件

要遵循本教程,您将需要:

  • 一个环境,您可以在其中执行 TypeScript 程序以跟随示例。 要在本地计算机上进行设置,您将需要以下内容。 为了运行处理 TypeScript 相关包的开发环境,同时安装了 Node 和 npm(或 yarn)。 本教程使用 Node.js 版本 14.3.0 和 npm 版本 6.14.5 进行了测试。 要在 macOS 或 Ubuntu 18.04 上安装,请按照如何在 macOS 上安装 Node.js 和创建本地开发环境或如何在 Ubuntu 18.04 上安装 Node.js 的使用 PPA 安装部分中的步骤进行操作。 如果您使用的是适用于 Linux 的 Windows 子系统 (WSL),这也适用。 此外,您需要在您的机器上安装 TypeScript 编译器 (tsc)。 为此,请参阅官方 TypeScript 网站。
  • 如果您不想在本地机器上创建 TypeScript 环境,您可以使用官方的 TypeScript Playground 进行操作。
  • 您将需要足够的 JavaScript 知识,尤其是 ES6+ 语法,例如 解构、剩余运算符导入/导出 。 如果您需要有关这些主题的更多信息,建议阅读我们的 如何在 JavaScript 中编码系列
  • 本教程将参考支持 TypeScript 并显示内联错误的文本编辑器的各个方面。 这不是使用 TypeScript 所必需的,但确实可以更多地利用 TypeScript 功能。 为了获得这些好处,您可以使用像 Visual Studio Code 这样的文本编辑器,它完全支持开箱即用的 TypeScript。 您还可以在 TypeScript Playground 中尝试这些好处。

本教程中显示的所有示例都是使用 TypeScript 4.2.2 版创建的。

在 TypeScript 中启用装饰器支持

目前,装饰器在 TypeScript 中仍然是一个实验性功能,因此必须先启用它。 在本节中,您将了解如何在 TypeScript 中启用装饰器,具体取决于您使用 TypeScript 的方式。

TypeScript 编译器 CLI

要在使用 TypeScript Compiler CLI (tsc) 时启用装饰器支持,唯一需要的额外步骤是传递一个附加标志 --experimentalDecorators

tsc --experimentalDecorators

tsconfig.json

在具有 tsconfig.json 文件的项目中工作时,要启用实验装饰器,您必须将 experimentalDecorators 属性添加到 compilerOptions 对象:

{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}

在 TypeScript Playground 中,装饰器默认启用。

使用装饰器语法

在本节中,您将在 TypeScript 类中应用装饰器。

在 TypeScript 中,您可以使用特殊语法 @expression 创建装饰器,其中 expression 是一个将在运行时自动调用的函数,其中包含有关装饰器目标的详细信息。

装饰器的目标取决于您添加它们的位置。 目前,装饰器可以添加到类的以下组件中:

  • 类声明本身
  • 特性
  • 访问器
  • 方法
  • 参数

例如,假设您有一个名为 sealed 的装饰器,它在一个类中调用 Object.seal。 要使用您的装饰器,您可以编写以下内容:

@sealed
class Person {}

请注意,在突出显示的代码中,您在 sealed 装饰器的目标之前添加了装饰器,在本例中为 Person 类声明。

这同样适用于所有其他类型的装饰器:

@classDecorator
class Person {
  @propertyDecorator
  public name: string;

  @accessorDecorator
  get fullName() {
    // ...
  }

  @methodDecorator
  printName(@parameterDecorator prefix: string) {
    // ...
  }
}

要添加多个装饰器,请将它们一个接一个地添加在一起:

@decoratorA
@decoratorB
class Person {}

在 TypeScript 中创建类装饰器

在本节中,您将完成在 TypeScript 中创建类装饰器的步骤。

对于名为 @decoratorA 的装饰器,您告诉 TypeScript 它应该调用函数 decoratorAdecoratorA 函数将被调用,其中包含有关您如何在代码中使用装饰器的详细信息。 例如,如果您将装饰器应用于类声明,则该函数将接收有关该类的详细信息。 此功能必须在您的装饰器工作的范围内。

要创建自己的装饰器,您必须创建一个与装饰器同名的函数。 也就是说,要创建您在上一节中看到的 sealed 类装饰器,您必须创建一个接收特定参数集的 sealed 函数。 让我们这样做:

@sealed
class Person {}

function sealed(target: Function) {
  Object.seal(target);
  Object.seal(target.prototype);
}

传递给装饰器的参数将取决于装饰器的使用位置。 第一个参数通常称为target

sealed 装饰器将仅用于类声明,因此您的函数将接收一个参数,即 target,其类型为 Function。 这将是应用装饰器的类的构造函数。

然后在 sealed 函数中调用 Object.seal,它是类构造函数,也是它们的原型。 当您这样做时,不能将新属性添加到类构造函数或其属性中,并且现有属性将被标记为不可配置。

重要的是要记住,当前无法在使用装饰器时扩展 target 的 TypeScript 类型。 这意味着,例如,您无法使用装饰器将新字段添加到类并使其成为类型安全的。

如果您在 sealed 类装饰器中返回了一个值,则该值将成为该类的新构造函数。 如果您想完全覆盖类构造函数,这很有用。

你已经创建了你的第一个装饰器,并将它与一个类一起使用。 在下一节中,您将学习如何创建装饰器工厂。

创建装饰器工厂

有时您需要在应用装饰器时将其他选项传递给装饰器,为此,您必须使用装饰器工厂。 在本节中,您将学习如何创建和使用这些工厂。

装饰器工厂是返回另一个函数的函数。 他们收到这个名字是因为他们不是装饰器实现本身。 相反,它们返回另一个负责实现装饰器的函数并充当包装函数。 通过允许客户端代码在使用装饰器时将选项传递给装饰器,它们在使装饰器可定制方面很有用。

假设您有一个名为 decoratorA 的类装饰器,并且您想添加一个可以在调用装饰器时设置的选项,例如布尔标志。 您可以通过编写类似于以下的装饰器工厂来实现此目的:

const decoratorA = (someBooleanFlag: boolean) => {
  return (target: Function) => {
  }
}

在这里,您的 decoratorA 函数返回另一个带有装饰器实现的函数。 注意装饰器工厂如何接收一个布尔标志作为它的唯一参数:

const decoratorA = (someBooleanFlag: boolean) => {
  return (target: Function) => {
  }
}

您可以在使用装饰器时传递此参数的值。 请参阅以下示例中突出显示的代码:

const decoratorA = (someBooleanFlag: boolean) => {
  return (target: Function) => {
  }
}

@decoratorA(true)
class Person {}

在这里,当您使用 decoratorA 装饰器时,将调用装饰器工厂并将 someBooleanFlag 参数设置为 true。 然后装饰器实现本身将运行。 这允许您根据使用方式更改装饰器的行为,从而使您的装饰器易于自定义和通过应用程序重用。

请注意,您需要传递装饰器工厂预期的所有参数。 如果您只是应用装饰器而不传递任何参数,如下例所示:

const decoratorA = (someBooleanFlag: boolean) => {
  return (target: Function) => {
  }
}

@decoratorA
class Person {}

TypeScript 编译器会给你两个错误,这可能会因装饰器的类型而异。 对于类装饰器,错误是 12381240

OutputUnable to resolve signature of class decorator when called as an expression.
  Type '(target: Function) => void' is not assignable to type 'typeof Person'.
    Type '(target: Function) => void' provides no match for the signature 'new (): Person'. (1238)
Argument of type 'typeof Person' is not assignable to parameter of type 'boolean'. (2345)

您刚刚创建了一个能够接收参数并根据这些参数更改其行为的装饰器工厂。 在下一步中,您将学习如何创建属性装饰器。

创建属性装饰器

类属性是另一个可以使用装饰器的地方。 在本节中,您将了解如何创建它们。

任何属性装饰器都接收以下参数:

  • 对于静态属性,类的构造函数。 对于所有其他属性,类的原型。
  • 成员的姓名。

目前,没有办法获取属性描述符作为参数。 这是由于 TypeScript 中属性装饰器的初始化方式。

这是一个装饰器函数,它将成员的名称打印到控制台:

const printMemberName = (target: any, memberName: string) => {
  console.log(memberName);
};

class Person {
  @printMemberName
  name: string = "Jon";
}

当你运行上面的 TypeScript 代码时,你会在控制台中看到如下打印:

Outputname

您可以使用属性装饰器来覆盖被装饰的属性。 这可以通过使用 Object.defineProperty 以及属性的新 setter 和 getter 来完成。 让我们看看如何创建一个名为 allowlist 的装饰器,它只允许将属性设置为静态许可列表中存在的值:

const allowlist = ["Jon", "Jane"];

const allowlistOnly = (target: any, memberName: string) => {
  let currentValue: any = target[memberName];

  Object.defineProperty(target, memberName, {
    set: (newValue: any) => {
      if (!allowlist.includes(newValue)) {
        return;
      }
      currentValue = newValue;
    },
    get: () => currentValue
  });
};

首先,您要在代码顶部创建一个静态许可名单:

const allowlist = ["Jon", "Jane"];

然后,您正在创建属性装饰器的实现:

const allowlistOnly = (target: any, memberName: string) => {
  let currentValue: any = target[memberName];

  Object.defineProperty(target, memberName, {
    set: (newValue: any) => {
      if (!allowlist.includes(newValue)) {
        return;
      }
      currentValue = newValue;
    },
    get: () => currentValue
  });
};

请注意您如何使用 any 作为 target 的类型:

const allowlistOnly = (target: any, memberName: string) => {

对于属性装饰器,目标参数的类型可以是类的构造函数,也可以是类的原型,在这种情况下使用any比较容易。

在装饰器实现的第一行中,您将被装饰的属性的当前值存储到 currentValue 变量中:

  let currentValue: any = target[memberName];

对于静态属性,这将设置为其默认值(如果有)。 对于非静态属性,这将始终是 undefined。 这是因为在运行时,在编译的 JavaScript 代码中,装饰器在实例属性设置为其默认值之前运行。

然后使用 Object.defineProperty 覆盖该属性:

  Object.defineProperty(target, memberName, {
    set: (newValue: any) => {
      if (!allowlist.includes(newValue)) {
        return;
      }
      currentValue = newValue;
    },
    get: () => currentValue
  });

Object.defineProperty 调用具有 gettersettergetter 返回存储在 currentValue 变量中的值。 如果 setter 会将 currentVariable 的值设置为 newValue

让我们使用您刚刚编写的装饰器。 创建以下 Person 类:

class Person {
  @allowlistOnly
  name: string = "Jon";
}

您现在将创建一个类的新实例,并测试设置并获取 name 实例属性:

const allowlist = ["Jon", "Jane"];

const allowlistOnly = (target: any, memberName: string) => {
  let currentValue: any = target[memberName];

  Object.defineProperty(target, memberName, {
    set: (newValue: any) => {
      if (!allowlist.includes(newValue)) {
        return;
      }
      currentValue = newValue;
    },
    get: () => currentValue
  });
};

class Person {
  @allowlistOnly
  name: string = "Jon";
}

const person = new Person();
console.log(person.name);

person.name = "Peter";
console.log(person.name);

person.name = "Jane";
console.log(person.name);

运行代码您应该看到以下输出:

OutputJon
Jon
Jane

该值永远不会设置为 Peter,因为 Peter 不在允许列表中。

如果您想让您的代码更具可重用性,允许在应用装饰器时设置允许列表,该怎么办? 这是装饰器工厂的一个很好的用例。 让我们通过将 allowlistOnly 装饰器变成装饰器工厂来做到这一点:

const allowlistOnly = (allowlist: string[]) => {
  return (target: any, memberName: string) => {
    let currentValue: any = target[memberName];

    Object.defineProperty(target, memberName, {
      set: (newValue: any) => {
        if (!allowlist.includes(newValue)) {
          return;
        }
        currentValue = newValue;
      },
      get: () => currentValue
    });
  };
}

在这里,您将之前的实现包装到另一个函数中,即装饰器工厂。 装饰器工厂接收一个名为 allowlist 的参数,它是一个字符串数组。

现在要使用您的装饰器,您必须通过许可名单,如以下突出显示的代码所示:

class Person {
  @allowlistOnly(["Claire", "Oliver"])
  name: string = "Claire";
}

尝试运行与您之前编写的代码类似的代码,但有新的更改:

const allowlistOnly = (allowlist: string[]) => {
  return (target: any, memberName: string) => {
    let currentValue: any = target[memberName];

    Object.defineProperty(target, memberName, {
      set: (newValue: any) => {
        if (!allowlist.includes(newValue)) {
          return;
        }
        currentValue = newValue;
      },
      get: () => currentValue
    });
  };
}

class Person {
  @allowlistOnly(["Claire", "Oliver"])
  name: string = "Claire";
}

const person = new Person();
console.log(person.name);
person.name = "Peter";
console.log(person.name);
person.name = "Oliver";
console.log(person.name);

该代码应为您提供以下输出:

OutputClaire
Claire
Oliver

显示它按预期工作,person.name 永远不会设置为 Peter,因为 Peter 不在给定的许可列表中。

现在您已经使用普通装饰器函数和装饰器工厂创建了您的第一个属性装饰器,是时候看看如何为类访问器创建装饰器了。

创建访问器装饰器

在本节中,您将了解如何装饰类访问器。

就像属性装饰器一样,访问器中使用的装饰器接收以下参数:

  1. 对于静态属性,类的构造函数,对于所有其他属性,类的原型。
  2. 成员的姓名。

但与属性装饰器不同的是,它还接收第三个参数,即访问器成员的属性描述符。

鉴于 Property Descriptors 包含特定成员的 setter 和 getter,访问器装饰器只能应用于单个成员的 setter 或 getter,而不能同时应用于两者。

如果您从访问器装饰器返回一个值,该值将成为 getter 和 setter 成员的访问器的新属性描述符。

下面是一个装饰器示例,可用于更改 getter/setter 访问器的 enumerable 标志:

const enumerable = (value: boolean) => {
  return (target: any, memberName: string, propertyDescriptor: PropertyDescriptor) => {
    propertyDescriptor.enumerable = value;
  }
}

请注意示例中您是如何使用装饰器工厂的。 这允许您在调用装饰器时指定可枚举标志。 以下是如何使用装饰器:

class Person {
  firstName: string = "Jon"
  lastName: string = "Doe"

  @enumerable(true)
  get fullName () {
    return `${this.firstName} ${this.lastName}`;
  }
}

访问器装饰器类似于属性装饰器。 唯一的区别是它们接收带有属性描述符的第三个参数。 现在您已经创建了第一个访问器装饰器,下一节将向您展示如何创建方法装饰器。

创建方法装饰器

在本节中,您将了解如何使用方法装饰器。

方法装饰器的实现与您创建访问器装饰器的方式非常相似。 传递给装饰器实现的参数与传递给访问器装饰器的参数相同。

让我们重用之前创建的 enumerable 装饰器,但这次是在以下 Person 类的 getFullName 方法中:

const enumerable = (value: boolean) => {
  return (target: any, memberName: string, propertyDescriptor: PropertyDescriptor) => {
    propertyDescriptor.enumerable = value;
  }
}

class Person {
  firstName: string = "Jon"
  lastName: string = "Doe"

  @enumerable(true)
  getFullName () {
    return `${this.firstName} ${this.lastName}`;
  }
}

如果您从方法装饰器返回一个值,该值将成为该方法的新属性描述符。

让我们创建一个 deprecated 装饰器,它在使用该方法时将传递的消息打印到控制台,记录一条消息说该方法已被弃用:

const deprecated = (deprecationReason: string) => {
  return (target: any, memberName: string, propertyDescriptor: PropertyDescriptor) => {
    return {
      get() {
        const wrapperFn = (...args: any[]) => {
          console.warn(`Method ${memberName} is deprecated with reason: ${deprecationReason}`);
          propertyDescriptor.value.apply(this, args)
        }

        Object.defineProperty(this, memberName, {
            value: wrapperFn,
            configurable: true,
            writable: true
        });
        return wrapperFn;
      }
    }
  }
}

在这里,您正在使用装饰器工厂创建装饰器。 这个装饰器工厂接收一个类型为 string 的参数,这是弃用的原因,如下面突出显示的部分所示:

const deprecated = (deprecationReason: string) => {
  return (target: any, memberName: string, propertyDescriptor: PropertyDescriptor) => {
    // ...
  }
}

deprecationReason 将在稍后将弃用消息记录到控制台时使用。 在您的 deprecated 装饰器的实现中,您正在返回一个值。 当您从方法装饰器返回值时,该值将覆盖该成员的属性描述符。

您正在利用这一点将 getter 添加到您的装饰类方法中。 这样您就可以更改方法本身的实现。

但是为什么不直接使用 Object.defineProperty 而不是为方法返回一个新的属性装饰器呢? 这是必要的,因为您需要访问 this 的值,对于非静态类方法,该值绑定到类实例。 如果您直接使用 Object.defineProperty 将无法检索 this 的值,并且如果方法以任何方式使用 this,装饰器会破坏您的代码当您从装饰器实现中运行包装的方法时。

在您的情况下, getter 本身的 this 值绑定到非静态方法的类实例并绑定到静态方法的类构造函数。

然后在 getter 内部创建一个包装函数,称为 wrapperFn,此函数使用 console.warn 将消息记录到控制台,传递收到的 deprecationReason然后从装饰器工厂调用原始方法,使用 propertyDescriptor.value.apply(this, args),这样调用原始方法时,它们的 this 值正确绑定到类实例,以防它是非静态方法。

然后,您将使用 defineProperty 覆盖类中方法的值。 这类似于 memoization 机制,因为对同一方法的多次调用将不再调用您的 getter,而是直接调用 wrapperFn。 您现在正在使用 Object.defineProperty 将类中的成员设置为将 wrapperFn 作为其值。

让我们使用您的 deprecated 装饰器:

const deprecated = (deprecationReason: string) => {
  return (target: any, memberName: string, propertyDescriptor: PropertyDescriptor) => {
    return {
      get() {
        const wrapperFn = (...args: any[]) => {
          console.warn(`Method ${memberName} is deprecated with reason: ${deprecationReason}`);
          propertyDescriptor.value.apply(this, args)
        }

        Object.defineProperty(this, memberName, {
            value: wrapperFn,
            configurable: true,
            writable: true
        });
        return wrapperFn;
      }
    }
  }
}

class TestClass {
  static staticMember = true;

  instanceMember: string = "hello"

  @deprecated("Use another static method")
  static deprecatedMethodStatic() {
    console.log('inside deprecated static method - staticMember =', this.staticMember);
  }

  @deprecated("Use another instance method")
  deprecatedMethod () {
    console.log('inside deprecated instance method - instanceMember =', this.instanceMember);
  }
}

TestClass.deprecatedMethodStatic();

const instance = new TestClass();
instance.deprecatedMethod();

在这里,您创建了一个具有两个属性的 TestClass:一个是静态的,一个是非静态的。 您还创建了两种方法:一种是静态的,一种是非静态的。

然后,您将 deprecated 装饰器应用于这两种方法。 运行代码时,控制台中会出现以下内容:

Output(warning) Method deprecatedMethodStatic is deprecated with reason: Use another static method
inside deprecated static method - staticMember = true
(warning)) Method deprecatedMethod is deprecated with reason: Use another instance method
inside deprecated instance method - instanceMember = hello

这表明这两种方法都使用您的包装函数正确包装,该函数将一条消息记录到控制台并说明弃用原因。

你现在已经使用 TypeScript 创建了你的第一个方法装饰器。 下一节将向您展示如何创建 TypeScript 支持的最后一个装饰器类型,即参数装饰器。

创建参数装饰器

参数装饰器可以用在类方法的参数中。 在本节中,您将学习如何创建一个。

与参数一起使用的装饰器函数接收以下参数:

  1. 对于静态属性,类的构造函数。 对于所有其他属性,类的原型。
  2. 成员的姓名。
  3. 方法参数列表中参数的索引。

无法更改与参数本身相关的任何内容,因此此类装饰器仅对观察参数使用本身有用(除非您使用更高级的东西,例如 reflect-metadata)。

这是一个装饰器的示例,它打印被装饰的参数的索引以及方法名称:

function print(target: Object, propertyKey: string, parameterIndex: number) {
  console.log(`Decorating param ${parameterIndex} from ${propertyKey}`);
}

然后你可以像这样使用你的参数装饰器:

class TestClass {
  testMethod(param0: any, @print param1: any) {}
}

运行上述代码应在控制台中显示以下内容:

OutputDecorating param 1 from testMethod

您现在已经创建并执行了一个参数装饰器,并打印出返回装饰参数索引的结果。

结论

在本教程中,您已经实现了 TypeScript 支持的所有装饰器,将它们与类一起使用,并了解了它们之间的区别。 您现在可以开始编写自己的装饰器来减少代码库中的样板代码,或者更加自信地使用带有库的装饰器,例如 Mobx

有关 TypeScript 的更多教程,请查看我们的 How To Code in TypeScript 系列页面