【Nest.js】深入理解装饰器(一)~

2,706 阅读24分钟

image.png

近年来,由于 Node.js、JavaScript 已经成为 web 前端和后端应用程序的“通用开发语言”。这促成了诸如 AngularReact 和 Vue 等优秀项目的出现,他们提高了开发者的工作效率,并能够创建快速、可测试和可扩展的前端应用程序。然而,尽管 Node (和服务器端 JavaScript)拥有大量优秀的软件库、辅助程序和工具,但没有一个能够有效地解决我们所面对的主要问题,即 架构

Nest 提供了一个开箱即用的应用程序体系架构,允许开发者及其团队创建高度可测试、可扩展、松散耦合且易于维护的应用程序。这种架构深受 Angular 的启发。 —— Nest.js 官网

Nest.js 的核心原理其实就是通过装饰器给 class 或者对象添加元数据,然后初始化的时候取出这些元数据,进行依赖的分析,然后创建对应的实例对象就可以了。它的核心就是 IOC 容器,也就是自动扫描依赖,创建实例对象并且自动依赖注入。Nest 的 Controller、Module、Service 等等所有的装饰器都是通过 Reflect.meatdata 给类或对象添加元数据的,然后初始化的时候取出来做依赖的扫描,实例化后放到 IOC 容器里。

我们需要知道两个比较新的特性:

  • 装饰器
  • Reflect.meatdata

这第一篇先来搞懂装饰器。

1、什么是装饰器(Decorators)?

先用代码来看看装饰器的强大魔力:

  • 没有装饰器的时候:

image.png

  • 有装饰器之后:

image.png

直观上,代码都简洁了不少 ~

getHomeAddress 函数违反了单一责任原则,因为它执行了本应由不同函数处理的多项任务。具体来说,该函数负责验证参数、授权用户、执行业务逻辑和处理错误。虽然该函数的主要目的是调用 homeAddressApi 并返回结果,但它还执行了这些额外的任务,从而使函数变得更加复杂和难以维护。

更好的方法是将验证、授权和错误处理逻辑委托给单独的函数,让 getHomeAddress 只专注于检索家庭住址的业务逻辑。

通过使用 TypeScript 装饰器,我们可以将原始代码转换为更精简的版本,从而分离关注点并提高代码的可维护性。使用单独的装饰器来处理验证、授权和错误处理,getHomeAddress 函数能够只关注检索家庭住址的业务逻辑。这种分工使代码更简洁、更易于维护,也更容易理解和修改。getHomeAddress 函数不再杂乱无章地包含无关的代码,因此使用起来更加简单。

1.1 装饰器定义

装饰器是 TypeScript 的一项强大功能,允许开发人员修改或扩展类、方法、访问器和属性的行为。它们提供了一种优雅的方式来添加功能或修改现有构造的行为,而无需改变其原始实现。

装饰器在 TypeScript 和 JavaScript 生态系统中有着丰富的历史。装饰器的概念受到 Python 和其他编程语言的启发,这些语言使用类似的构造来修改或扩展类、方法和属性的行为。最初的 JavaScript 装饰器提案是在 2014 年提出的,此后又开发了多个版本的提案,目前的提案处于 ECMAScript 标准化进程的第 3 阶段。

TypeScript 5.0 版本正式支持第 3 阶段装饰器提案。该提案有四个阶段,这意味着它可以快速稳定,无需对 API 进行重大更改。

TypeScript 是这样描述装饰器的:

  • 装饰器提供了一种为类声明和成员添加注解元编程语法的方法。

  • 装饰器是一种特殊类型的声明,可以附加到类声明方法访问器属性参数。装饰器使用 形式@expression,其中expression必须求值为将在运行时调用的函数,其中包含有关装饰声明的信息。

装饰器本质上是一种特殊的函数被应用在于:

  1. 类属性
  2. 类方法
  3. 类访问器
  4. 类方法的参数(在 TypeScript 5.x 中不再包含)

一句话:装饰器就是一个接收特定参数的函数,使用@函数名可以对一些类,属性,方法等进行装饰来实现一些 运行时 的 hook 拦截机制。


先强调一下:当前的装饰器只适用于类和类的成员/方法,不适用于普通函数!主要原因是存在函数提升。


所以应用装饰器其实很像是组合一系列函数,类似于高阶函数和类。 通过装饰器我们可以轻松实现代理模式来使代码更简洁以及实现其它一些更有趣的能力。

装饰器的语法十分简单,只需要在想使用的装饰器前加上@符号,装饰器就会被应用到目标上:

@simpleDecorator1  
class MyClass {  
    @simpleDecorator2  
    method() {  
        // ...  
    }  
}  
  
// 你也可以放在一行
@simpleDecorator class MyClass {  
    @simpleDecorator1 @simpleDecorator2 method() {  
        // ...  
    }  
}

那装饰器能干嘛呢?常用的比如:

  • 日志记录:装饰器可用于记录方法调用。
  • 验证:装饰器可用于在设置方法参数或对象属性之前对其进行验证。
  • 缓存:装饰器可用于缓存函数调用的结果,以提高性能。
  • 授权:装饰器可用于根据用户角色或权限限制对方法的访问。

1.2 装饰器的历史

装饰器模式是一种经典的设计模式,它可以在不修改被装饰者(如某个函数、某个类等)源码的前提下,为被装饰者增加 / 移除某些功能(收集用户定义的类/函数的信息,例如用于生成路由表,实现依赖注入等等、也可以对用户定义的类/函数进行增强,增加额外功能)。一些现代编程语言在语法层面都提供了对装饰器模式的支持,并且各语言中的现代框架都大量应用了装饰器。

在之前,我们想要在 TypeScript 中使用装饰器,需要在 tsconfig 中添加 --experimentalDecorators 标志,这其实就是 TypeScript 对最原始的处于 stage1 阶段的装饰器提案的支持,在 TypeScript 5.0 中,将对全新的处于 stage3 阶段的装饰器提案提供支持,开箱即用。

装饰器提案从提出到进入 stage3 阶段,中间经历了大约 9 年的时间,在这期间又经历了多项重大改动。为啥这样一个提案要经历这么长的时间?中间都经历了些什么?下面我们先来回顾一下它的历史。

以下来自2ality.com/2022/10/jav…

以下是历史描述:

  • 各小组如何开展自己的项目,又如何就 TC39 建议开展合作。

  • TC39 提案是如何通过 the TC39 process 的各个阶段(从 0 开始到 4 结束,即提案准备添加到 ECMAScript 时)取得进展的。在这一过程中,提案发生了许多变化。 按时间顺序记述的相关事件:

  • 2014-04-10: 装饰器由 Yehuda Katz 向 TC39 提出。该提案已进入第 0 阶段。

    • 卡茨(Katz’s)的提案是与罗恩-巴克顿合作提出的。关于该提案的讨论最早可追溯到 2013 年 7 月
  • 2014-10-22(ngEurope 会议,巴黎):Angular 团队宣布,Angular 2.0 将使用 AtScript 编写,并编译为 JavaScript(通过 Traceur)和 Dart。计划包括将 AtScript 基于 TypeScript,同时添加以下内容:

    • 三种注解:
      • 类型注解
      • 字段注解明确声明字段。
      • 元数据注解的语法与装饰器相同,但只添加元数据,不改变注解构造的工作方式。
    • 运行时类型检查
    • 类型自省
  • 2015-01-28: Yehuda Katz 和 Jonathan Turner 报告说,Katz 和 TypeScript 团队正在交换意见。

  • 2015-03-05(ng-conf,盐湖城):Angular 团队和 TypeScript 团队宣布 Angular 将从 AtScript 切换到 TypeScript,TypeScript 将采用 AtScript 的部分功能(尤其是装饰器)。

  • 2015-03-24: 装饰器提案已进入第一阶段。当时,他们在 GitHub 上有一个仓库(由 Yehuda Katz 创建),后来被移到了现在的位置

  • 2015-07-20: TypeScript 1.5 发布后,在 --experimentalDecorators 标志后面支持第 1 阶段装饰器。

    一些 JavaScript 项目(如 Angular 和 MobX)使用了 TypeScript 的这一功能,这让 JavaScript 看起来已经有了装饰器。

    到目前为止,TypeScript 还不支持较新版本的装饰器 API。Ron Buckton 提出的拉取请求提供了对第 3 阶段装饰器的支持,可能会在 v4.9 之后的版本中发布。

  • 2016-07-28: 在 Yehuda Katz 和 Brian Terlson 的介绍之后,提案进入了第二阶段。

  • 2017-07-27: 丹尼尔-艾伦伯格(Daniel Ehrenberg)在几个月前加入该提案后,举办了他的第一次装饰公司推介会。几年来,他一直推动着公司的发展。

  • 后来,克里斯-加勒特(Chris Garrett)加入了该提案,并帮助它进入了第 3 阶段,即 2022-03-28 日。装饰器元数据被转移到另一个提案中,该提案从第 2 阶段开始。

  • 2023-01-26:TypeScript 5.0 beta 版本,支持 stage3 阶段的装饰器写法。

可以发现,到达 stage3 阶段花了很长的时间,主要是因为各种利益权衡的问题,很难让各方达成一致,包括其他功能(例如类成员和私有状态)的交互以及性能等方面。

1.3 装饰器分类

1.3.1 TypeScript 5.x 之前

一共5种装饰器可被我们使用:

  1. 类装饰器
  2. 属性装饰器
  3. 方法装饰器
  4. 访问器装饰器
  5. 参数装饰器

通常是这样的:

// 类装饰器
@classDecorator
class Bird {

  // 属性装饰器
  @propertyDecorator
  name: string;
  
  // 方法装饰器
  @methodDecorator
  fly(
    // 参数装饰器
    @parameterDecorator
      meters: number
  ) {}
  
  // 访问器装饰器
  @accessorDecorator
  get egg() {}
}

1.3.2 TypeScript 5.x 之后

装饰器的种类为:

  1. 类装饰器
  2. 属性装饰器
  3. 自动访问器装饰器(从 v4.9 引入)
  4. getter/setter 访问器装饰器
  5. 类方法装饰器

与stage 2 的区别是,stage 3 当前就不支持参数装饰器了。参数 Decorator 已经从 Decorator 主体的 Proposal 中分离出来,还在讨论中:GitHub 问题 - 参数装饰器

通常是这样的:

@classDecorator
class Foo {
  @fieldDecorator
  name: string = "foo";

  @accessorDecorator
  accessor hoge: number = 0;

  @getterDecorator
  get bar(): string {
    return "bar";
  }

  @setterDecorator
  set bar(v: string) {
      // ...
  }

  @methodDecorator
  greet() {
    console.log("hello!!")
  }
}

1.4 元编程

在一些装饰器的教程中,通常会遇到一个名词:元编程。什么是元编程?比较晦涩难懂的说法是:

  • 我们不编写处理用户数据的代码(编程)。
  • 我们编写的代码是处理用户数据的代码(元编程)。

通俗来讲:元编程 (meta-programming) 是通过操作 程序实体 (program entity),在 编译时 (compile time) 计算出 运行时 (runtime) 需要的常数、类型、代码的方法。它的诞生是源于:需要非常灵活的代码来适应快速变化的需求,同时保证性能。

与一般代码的区别是:

  1. 一般代码的操作对象是数据
  2. 元编程的操作对象是代码code as data
  3. 如果编程的本质是抽象,那么元编程就是更高层次的抽象。

1.5 注意事项

需要注意的是,在 TypeScript 5.x 之前,装饰器还只是作为一个实验性功能,使用时需要在tsconfig.json 中开启以下配置:

{
  "compilerOptions": {
    "target": "ES5",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

从 v5.0 开始,不需要此设置,默认情况下可以使用 stage 3 的装饰器。

  • 第一个,experimentalDecorators打开装饰器支持。
  • 第二个,emitDecoratorMetadata,发出包所需的数据reflect-metadata。这个包使我们能够通过记录有关类、属性、方法和参数的元数据,在装饰器中做一些更强大的事情。

1.6 TS 4.xTS 5.x的装饰器有什么区别?

需要注意的是:

  • TypeScript 5.0 为装饰器添加了一些新的实现,使其与 ECMAScript 提议的下一阶段保持一致。

  • 装饰器可帮助您自定义类及其成员(以及方法、属性和 set/get 访问器)的功能。装饰器还可以扩展这些功能。装饰器在 TypeScripts 中处于试验阶段已有数年时间,但 TypeScript 5.0 Final 对装饰器的使用方式进行了一些修改,因此如果你在之前的实验性阶段使用过装饰器,建议你查看一下修改内容。

  • 根据微软发布的博客说明,新的装饰器与 --emitDecoratorMetadata 不兼容,并且不再允许装饰参数

如果你已经使用 TypeScript 有一段时间了,那么你可能已经知道它对 "实验性 "装饰器的支持已经有很多年了。虽然这些实验性装饰器非常有用,但它们是装饰器提案的旧版本模型,并且始终需要一个名为 --experimentalDecorators 的编译器选择标志。在 TypeScript 中尝试使用装饰器时,如果没有这个标记,就会提示错误信息。

在可预见的未来,--experimentalDecorators 将继续存在;不过,如果没有该标志,装饰器现在将成为所有新代码的有效语法。在 --experimentalDecorators 之外,它们将以不同的方式进行类型检查和输出。由于类型检查规则和发射方式完全不同,虽然可以编写装饰器来同时支持新旧装饰器的行为,但任何现有的装饰器函数都不可能这样做。

这项新的装饰器提议与--emitDecoratorMetadata不兼容,而且不允许装饰参数。未来的 ECMAScript 提议可能会有助于弥合这一差距。

最后一点:目前,装饰器提案要求类装饰器必须在导出关键字之后(如果有的话)。

export @register class Foo { 
    // ... 
} 

export 
@Component({ 
    // ... 
}) 
class Bar { 
    // ... 
}

TypeScript 将在 JavaScript 文件中执行此限制,但不会在 TypeScript 文件中执行。这样做的部分原因是出于现有用户的考虑 —— 官方希望在最初的 "实验性" 装饰器和标准化装饰器之间提供一条稍微容易一些的迁移路径。

2、类装饰器

2.1 类装饰器函数

装饰器函数只有一个参数:类的构造函数,类型签名:

// TypeScript 5.x 之前
type ClassDecorator = <TFunction extends Function>
  (target: TFunction) => TFunction | void;
  
// TypeScript 5.x 之后
// 类装饰器接受两个参数:`value`(当前类本身)和`context`(上下文对象)。
// 其中,`context`对象的`kind`属性固定为字符串`class`。
type ClassDecorator = (
  value: Function,
  context: {
    kind: 'class';
    name: string | undefined;
    addInitializer(initializer: () => void): void;
  }
) => Function | void;

image.png

我们可以额外从 context 中取到一些信息:

  • kind:被修饰的结构类型,包括'class'、'method'、'getter'、'setter'、'accessor'、'field'
  • name:被修饰的实体名称
  • addInitializer:一个初始化完成后的回调函数,运行的时机会取决于装饰器的种类:
    • 类装饰器:在类被完全定义并且所有静态字段都被初始化之后运行。
    • 非静态类元素装饰器:在实例化期间运行(实例字段被初始化之前)。
    • 静态类元素装饰器:在类定义期间运行(在定义静态字段之前但在定义其他所有其他类元素之后)。

来看下面这个例子:

function logConstructor(constructor: Function) {
    const ret = {
        constructor,
        extensible: Object.isExtensible(constructor),
        frozen: Object.isFrozen(constructor),
        sealed: Object.isSealed(constructor),
        values: Object.values(constructor),
        properties: Object.getOwnPropertyDescriptors(constructor),
        members: {}
    } as any;
    for (const key of Object.getOwnPropertyNames(constructor.prototype)) {
        ret.members[key] = constructor.prototype[key];
    }

    console.log(`ClassDecoratorExample `, ret);
}

@logConstructor
class ClassDecoratorExample {
    constructor(x: number, y: number) {
        console.log(`ClassDecoratorExample(${x}, ${y})`);
    }
    method() {
        console.log(`method called`);
    }
}

new ClassDecoratorExample(3, 4).method()

@logConstructor 就是装饰器函数,该装饰器不使用任何参数,因此不需要括号。它打印出有关构造函数的一些信息。在大多数情况下,我们使用对象类中的方法来查询被装饰类的数据。在某些情况下,查询的对象是 constructor.prototype,因为该对象包含了附加到类中的方法的实现细节。

运行上面的代码,会得出以下输出:

ClassDecoratorExample  {
  constructor: [class ClassDecoratorExample],
  extensible: false,
  frozen: false,
  sealed: false,
  values: [],
  properties: {
    length: {
      value: 2,
      writable: false,
      enumerable: false,
      configurable: false
    },
    name: {
      value: 'ClassDecoratorExample',
      writable: false,
      enumerable: false,
      configurable: false
    },
    prototype: {
      value: {},
      writable: false,
      enumerable: false,
      configurable: false
    }
  },
  members: {
    constructor: [class ClassDecoratorExample],
    method: [Function: method]
  }
}

ClassDecoratorExample(3, 4)
method called

如果不使用类装饰器函数中的必填参数,会发生什么情况?

function Decorator() {
    console.log('In Decorator');
}

@Decorator
class FooClass {
    foo: string;
}

在这种情况下,会出现编译时错误:

error TS1329: 'Decorator' accepts too few arguments to be used as a decorator here. Did you mean to call it first and write '@Decorator()'?

2.2 带参数的类装饰器

装饰器也可以接受参数,这需要遵循一种不同的模式,即装饰器工厂。下面是一个简单的类装饰器示例,它不仅展示了如何传递参数,还帮助我们理解了使用多个装饰器时的执行顺序。

function withParam(path: string) {
    console.log(`outer withParam ${path}`);
    return (target: Function) => {
        console.log(`inner withParam ${path}`);
    };
}

@withParam('first')
@withParam('middle')
@withParam('last')
class ExampleClass {
    // ...
}

外层函数 withParam 接收与装饰器一起使用的参数列表。内层函数是装饰器函数,是实现所需签名的地方,内部函数是装饰器的实际实现。withParam(parameter) 是一个表达式,它返回的函数具有正确的类装饰器签名。这使得内部函数成为装饰器函数,而外部函数则是生成该函数的工厂函数。

在这个示例中,我们加了三次 withParam,打印出的信息是:

outer withParam first
outer withParam middle
outer withParam last
inner withParam last
inner withParam middle
inner withParam first

记住,工厂函数是自上而下执行的,而装饰器函数则是自下而上执行的。

2.3 类装饰器应用

让我们来看看类装饰器的一种可能的实际用途。也就是说,一个框架可能会保存某些类型的类的列表。我们来模仿一个网络应用框架,其中某些类包含 URL 路由功能。每个路由器类都处理特定 URL 前缀的路由,以及该路由的特定配置。

const registeredClasses = [];

function Router(path: string, options ?: object) {
    return (constructor: Function) => {
        registeredClasses.push({
            constructor, path, options
        });
    };
}

@Router('/')
class HomePageRouter {
    // routing functions
}

@Router('/blog', {
    rss: '/blog/rss.xml'
})
class BlogRouter {
    // routing functions
}

console.log(registeredClasses);

Router 是一个生成类装饰器的工厂函数,可将类添加到 registeredClasses 数组中。该函数包含两个选项,其中 pathURL 路径前缀,options 是一个可选的配置对象。

运行后的输出结果如下:

[
  {
    constructor: [class HomePageRouter],
    path: '/',
    options: undefined
  },
  {
    constructor: [class BlogRouter],
    path: '/blog',
    options: { rss: '/blog/rss.xml' }
  }
]

从构造函数对象开始,可以获得大量我们想要得到的额外数据。由于类装饰器最后运行,因此可以选择让类装饰器对类中包含的任何方法或属性进行操作。此外,更常见的数据存储方法不是像这样使用数组,而是使用 Reflection Metadata API,后面也会介绍这个API。

类装饰器还可以这么用,比如一个收集类的实例:

type Constructor<T = {}> = new (...args: any[]) => T;

class InstanceCollector {
  instances = new Set();

  install = <Class extends Constructor>(
    Value: Class,
    context: ClassDecoratorContext<Class>
  ) => {
    const _this = this;
    return class extends Value {
      constructor(...args: any[]) {
        super(...args);
        _this.instances.add(this);
      }
    };
  };
}

const collector = new InstanceCollector();

@collector.install
class Calculator {
  add(a: number, b: number): number {
    return a + b;
  }
}

const calculator1 = new Calculator();
const calculator2 = new Calculator();

console.log('instances: ', collector.instances);

2.4 使用类装饰器修改类

类装饰器还可以应用于类构造函数,对类定义进行修改等。

function reportableClassDecorator<T extends { new (...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    reportingURL = "http://www...";
  };
}

@reportableClassDecorator
class BugReport {
  type = "report";
  title: string;

  constructor(t: string) {
    this.title = t;
  }
}

const bug = new BugReport("Needs dark mode") as any;
console.log(bug.title); //  "Needs dark mode"
console.log(bug.type); //  "report"

console.log(bug.reportingURL); // http://www...

请注意,装饰器不会改变 TypeScript 类型,因此类型系统并不知道新属性 reportingURL,所以我们需要做类型断言,否则会报错。

2.5 小结

  • 装饰器接收类对象,我们可以从中访问大量数据。

  • 装饰器函数在类对象创建时执行,而不是在类实例构建时执行。这意味着,要直接影响生成的实例,我们必须创建一个匿名子类。

  • 使用匿名子类可能比较麻烦。访问任何添加的方法或属性都需要跳过重重障碍,而在匿名子类中,重载的方法或属性都是透明执行的。

3、类属性装饰器

TypeScript 5 以后的新的类属性装饰器具有下面的类型签名:

type ClassFieldDecorator = (
  value: undefined,
  context: {
    kind: 'field';
    name: string | symbol;
    static: boolean;
    private: boolean;
    access: { get: () => unknown, set: (value: unknown) => void };
    addInitializer(initializer: () => void): void;
  }
) => (initialValue: unknown) => unknown | void;

相比类方法装饰器,主要有下面两个地方不同:

  • access 中可以同时拿到 setter 和 getter 方法,但是类方法装饰器只能拿到 getter 方法。
  • 返回值类型不同,在类属性装饰器中,可以通过返回一个方法来改变属性的初始值 initialValue

装饰器可以加到 TypeScript 类中的属性或字段上,像这样:

class ContainingClass {

    @Decorator(?? optional parameters)
    name: type;
}

3.1 类装饰器函数

属性装饰器附加于类定义中的属性。在 JavaScript 中,属性是与对象相关联的值。最简单的属性只是对象中声明的一个字段。

属性装饰器函数接收两个参数:

  • 对于静态成员,是类的构造函数;对于实例成员,是类的原型。
  • 给出属性名称的字符串

来看下面的例子:

function logProperty(target: Object, member: string): any {
    console.log(`PropertyExample logProperty ${target} ${member}`);
}

class PropertyExample {

    @logProperty
    name!: string;
}

const pe = new PropertyExample();
if (!pe.hasOwnProperty('name')) {
    console.log(`No property 'name' on pe`);
}
pe.name = "Stanley Steamer";
if (!pe.hasOwnProperty('name')) {
    console.log(`No property 'name' on pe`);
}

console.log(pe);

输出结果:

PropertyExample logProperty [object Object] name
No property 'name' on pe
PropertyExample { name: 'Stanley Steamer' }

尽管 name 属性已在该类中明确定义,但对 hasOwnProperty 的首次调用却返回 false,表明该属性不存在。

让我们逐行分析代码的执行过程和输出结果:

  1. 首先,代码定义了一个名为logProperty的装饰器函数,它会在属性被赋值时打印日志。
  2. 然后,代码定义了一个名为PropertyExample的类,该类具有一个装饰器@logProperty应用在name属性上。
  3. 接下来,代码创建了一个PropertyExample的实例pe
  4. 在检查pe是否具有名为name的属性时,由于name属性是在类中通过装饰器定义的,而不是在实例上直接定义的,因此pe.hasOwnProperty("name")返回false,并打印了"No property 'name' on pe"。
  5. 然后,代码给pename属性赋值为"Stanley Steamer"。
  6. 再次检查pe是否具有名为name的属性时,由于name属性已经被赋值,因此pe.hasOwnProperty("name")返回true,不会打印任何内容。
  7. 最后,代码打印了pe对象,显示{ name: 'Stanley Steamer' }

为了进一步探讨这个问题,开打印一下 PropertyDescriptor 对象:

function GetDescriptor() {
  return (target: Object, member: string) => {
    const prop = Object.getOwnPropertyDescriptor(target, member);
    console.log(`Property ${member} ${prop}`);
  };
}

class Student {
  @GetDescriptor()
  year!: number;
}

const stud1 = new Student();
console.log(Object.getOwnPropertyDescriptor(stud1, "year"));
stud1.year = 2022;
console.log(Object.getOwnPropertyDescriptor(stud1, "year"));

对象类有两个与属性的 PropertyDescriptor 对象相关的函数,即 getOwnPropertyDescriptordefineProperty。本脚本在执行装饰器时调用 getOwnPropertyDescriptor,在创建对象实例后调用 getOwnPropertyDescriptor,然后在为属性赋值后调用 getOwnPropertyDescriptor

让我们运行这个脚本:

Property year undefined
undefined
{ value: 2022, writable: true, enumerable: true, configurable: true }

在为属性赋值之前,我们无法获取描述符。

让我们逐行分析代码的执行过程和输出结果:

  1. 首先,代码定义了一个名为GetDescriptor的函数,它返回一个装饰器函数。装饰器函数在属性被访问时获取属性的描述符,并打印日志。
  2. 接下来,代码定义了一个名为Student的类,它具有一个装饰器@GetDescriptor应用在year属性上。
  3. 然后,代码创建了一个Student的实例stud1
  4. 在装饰器函数中,通过调用Object.getOwnPropertyDescriptor(target, member)来获取stud1对象上year属性的描述符。由于year属性尚未被赋值,因此描述符中的value属性为undefined
  5. 然后,代码打印了获取到的属性描述符,输出{ value: undefined, writable: true, enumerable: true, configurable: true }
  6. 接下来,代码给stud1year属性赋值为2023
  7. 再次通过Object.getOwnPropertyDescriptor(stud1, "year")获取stud1对象上year属性的描述符。这次描述符中的value属性为2023,表示属性已经被成功赋值。
  8. 最后,代码打印了获取到的属性描述符,输出{ value: 2023, writable: true, enumerable: true, configurable: true }

在第一次打印属性描述符时,由于属性尚未被赋值,value属性为undefined;而在第二次打印属性描述符时,value属性被成功赋值为2023

TypeScript 文档是这么说的:

注意 由于 TypeScript 中属性装饰器的初始化方式,属性描述符(Property Descriptor)不能作为属性装饰器的参数。这是因为目前还没有在定义原型成员时描述实例属性的机制,也没有观察或修改属性初始化器的方法。

换句话说,属性描述符函数是在 PropertyDescriptor 对象存在之前执行的。

3.2 类属性装饰器应用

在装饰器函数中,我们会得到一个目标对象、属性名称以及传递给装饰器函数的任何参数。我们无法覆盖或修改属性的行为,我们能做的就是从装饰器中记录数据,就像我们在上面类装饰器应用示例中那样。

举一个数据验证框架的例子。我们可以将装饰器附加到描述可接受值的属性上,然后验证框架将使用这些设置来确定某个值是否可接受:

const registered = [];

function IntegerRange(min: number, max: number) {
    return (target: Object, member: string) => {
        registered.push({
            target, member,
            operation: {
                op: 'intrange',
                min, max
            }
        });
    }
}

function Matches(matcher: RegExp) {
    return (target: Object, member: string) => {
        registered.push({
            target, member,
            operation: {
                op: 'match',
                matcher
            }
        });
    }
}

上面是两个属性装饰器工厂函数。第一个函数记录了一个验证操作,确保值是一个整数,在给定的值范围内。另一个操作是根据正则表达式进行字符串匹配。这两个操作的数据都记录在注册数组中。

class StudentRecord {

    @IntegerRange(1900, 2050)
    year: number;

    @Matches(/^[a-zA-Z ]+$/)
    name: string;
}

const sr1 = new StudentRecord();

console.log(registered);

输出结果:

[
  {
    target: {},
    member: 'year',
    operation: { op: 'intrange', min: 1900, max: 2050 }
  },
  {
    target: {},
    member: 'name',
    operation: { op: 'match', matcher: /^[a-zA-Z ]+$/ }
  }
]

可见,通过属性装饰器在另一个位置记录有关属性的任何数据都非常容易。在看一些框架设计中,其他函数可以查阅这些数据并做一些有用的事情。

3.3 使用 Object.defineProperty 的盲区

有些博客上一些关于属性装饰器的教程文章建议使用 Object.defineProperty 来实现运行时数据验证。该建议的缺陷就像我们刚才演示的那样 —— PropertyDescriptor 对象对属性装饰器函数不可用。下面讨论一下使用 defineProperty 的错误建议。从装饰器函数开始:

function ValidRange(min: number, max: number) {
    return (target: Object, member: string) => {
        console.log(`Installing ValidRange on ${member}`);
        let value: number;
        Object.defineProperty(target, member, {
            enumerable: true,
            get: function() {
                console.log("Inside ValidRange get");
                return value;
            },
            set: function(v: number) {
                console.log(`Inside ValidRange set ${v}`);
                if (v < min || v > max) {
                    throw new Error(`Not allowed value ${v}`);
                }
                value = v;
            }
        });
    }
}

此装饰器用于数值属性,并强制最小值和最大值之间的有效范围。它使用 get/set 函数调用 defineProperty,其中 set 函数强制执行范围。在数据存储方面,函数会将值存储在局部变量中。这看起来简单明了,不是吗?

下面进行测试:

class Student {
  @ValidRange(1900, 2050)
  year!: number;
}

const stud_1 = new Student();
const stud_2 = new Student();

stud_1.year = 1901;
stud_2.year = 1911;
console.log(`stud1 ${stud_1.year} stud_2 ${stud_2.year}`);
stud_1.year = 2030;
console.log(`stud1 ${stud_1.year} stud_2 ${stud_2.year}`);
// stud_1.year = 1899;
// console.log(stud_1.year);

console.log(`stud1 ${stud_1.year} stud_2 ${stud_2.year}`);
stud_2.year = 2022;
console.log(`stud1 ${stud_1.year} stud_2 ${stud_2.year}`);
stud_2.year = 2023;
console.log(`stud1 ${stud_1.year} stud_2 ${stud_2.year}`);

定义了一个类,并生成两个实例。我们为其中一个实例赋值,然后打印输出这些值。如果你想看看数据验证的操作,注释掉赋值 1899 的那一行,你会看到它抛出一个异常。

打印结果:

Installing ValidRange on year
Inside ValidRange set 1901
Inside ValidRange set 1911
Inside ValidRange get
Inside ValidRange get
stud1 1911 stud_2 1911
Inside ValidRange set 2030
Inside ValidRange get
Inside ValidRange get
stud1 2030 stud_2 2030
Inside ValidRange get
Inside ValidRange get
stud1 2030 stud_2 2030
Inside ValidRange set 2022
Inside ValidRange get
Inside ValidRange get
stud1 2022 stud_2 2022
Inside ValidRange set 2023
Inside ValidRange get
Inside ValidRange get
stud1 2023 stud_2 2023

每次设置或检索值时,我们都会打印出来。我们看到,stud_1stud_2 分别被赋值为 1901 和 1911,但打印出来的两个值都是 1911。无论我们给哪个变量分配新值,另一个变量都显示相同的值。

这是怎么回事?问题出在装饰器函数内部的数据存储上。在构建类定义时,该函数只对给定类中的每个属性执行一次。该函数不会在每次创建类的实例时执行,只会在创建定义时执行。存储数据的局部变量 value 只创建一次。该实例位于装饰器函数的堆栈框架内,每个类的每个属性只执行一次。

这就意味着,使用 @ValidRange 在所有属性实例之间共享存储在 value 中的数据。这是因为在使用 @ValidRange 时,属性的数据存储由装饰器管理,而不是由 JavaScript 管理。

在本例中,我们有一个名为 Student 的类,它有一个名为 year 的属性,该属性使用 @ValidRange 进行了装饰。正如我们所演示的,两个 year 实例共享同一个值。

要验证这种行为,请在 Student 类中添加以下字段:

@ValidRange(0, 150)
age: number;

增加另一个由 @ValidRange 管理的属性。我们会看到同样的数据共享问题吗?年龄的值是否与年份的值相同?

测试一下:

stud_1.year = 1901;
stud_2.year = 1911;
stud_1.age = 20;
console.log(`stud_1 ${stud_1.year} ${stud_1.age} stud_2 ${stud_2.year} ${stud_2.age}`);

这将为其中一个 Student 实例的年龄赋值,然后打印两个实例的年龄:

Installing ValidRange on year
Installing ValidRange on age
Inside ValidRange set 1901
Inside ValidRange set 1911
Inside ValidRange set 20
Inside ValidRange get
Inside ValidRange get
Inside ValidRange get
Inside ValidRange get
stud_1 1911 20 stud_2 1911 20

年份和年龄属性的值在它们之间是不同的,但在Student实例之间是共享的。我们只分配了一次值,但注意到两个实例都打印了相同的值。

为了进一步演示,请生成一个新的 Student 实例:

const stud_3 = new Student();

然后,不要为该实例赋值,而是添加一条 console.log 语句:

console.log(`stud_3 ${stud_3.year} ${stud_3.age}`);

输出结果:

stud_3 1911 20

尽管 stud_3 没有分配任何值,但它显示了相同的值。

Student.year 属性的每个实例都共享相同的值,Student.age 属性的每个实例也是如此。这是因为 @ValidRange 管理的是数据存储,而不是 JavaScript。造成这种情况的原因是使用 Object.defineProperty 的方式不正确。JavaScript 确实为我们提供了很多工具来解决这些问题。

在执行属性装饰器函数时,JavaScript 尚未创建 PropertyDescriptor 对象。覆盖该属性描述符的 get/set 函数会非常强大。如果创建了自己的属性描述符,并认为已经实现了运行时数据验证的目标,那将会产生误导。

后下面会讲到访问者装饰器,通过覆盖正确属性描述符中的 get/set 函数来实现运行时数据验证。

3.4 小结

我们可以将装饰器附加到属性上。这意味着我们可以记录附加到每个属性的装饰器的信息,然后对这些数据进行处理。但是,我们却不知道如何访问 PropertyDescriptor 以及如何使用 get/set 函数。原因在于类装饰器函数的执行时机。

如果我们能覆盖 PropertyDescriptor 中的 get/set 方法,就会有很多可能性。但是,对于属性而言,在为属性赋值之前,该对象是不存在的。

我们可以在数据结构中记录装饰器信息。例如,class-validator 包中有 @IsInt@Min@Max 这样的装饰器来验证属性值。我们知道,它必须将这些信息记录到数据结构中,当应用程序调用 validate 函数时,它必须检查这些数据,以便知道如何验证类实例。

参考资料