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

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

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

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

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

  • 装饰器
  • Reflect.meatdata

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

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

TypeScript 是这样描述装饰器的:

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

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

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

  1. 类属性
  2. 类方法
  3. 类访问器
  4. 类方法的参数

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


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


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

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

js 复制代码
function simpleDecorator() {
  console.log('---hi I am a decorator---')
}

@simpleDecorator
class A {}

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

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

让我们来快速认识一下这五种装饰器:

js 复制代码
// 类装饰器
@classDecorator
class Bird {

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

有个问题:什么是元编程?比较晦涩难懂的说法是:

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

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

与一般代码的区别是:

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

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

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

1、类装饰器

1.1 类装饰器函数

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

ts 复制代码
type ClassDecorator = <TFunction extends Function>
  (target: TFunction) => TFunction | void;

来看下面这个例子:

ts 复制代码
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,因为该对象包含了附加到类中的方法的实现细节。

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

ts 复制代码
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

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

ts 复制代码
function Decorator() {
    console.log('In Decorator');
}

@Decorator
class FooClass {
    foo: string;
}

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

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

1.2 带参数的类装饰器

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

ts 复制代码
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,打印出的信息是:

yaml 复制代码
outer withParam first
outer withParam middle
outer withParam last
inner withParam last
inner withParam middle
inner withParam first

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

1.3 类装饰器应用

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

ts 复制代码
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 是一个可选的配置对象。

运行后的输出结果如下:

ts 复制代码
[
  {
    constructor: [class HomePageRouter],
    path: '/',
    options: undefined
  },
  {
    constructor: [class BlogRouter],
    path: '/blog',
    options: { rss: '/blog/rss.xml' }
  }
]

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

1.4 使用类装饰器修改类

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

ts 复制代码
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,所以我们需要做类型断言,否则会报错。

1.5 小结

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

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

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

2、类属性装饰器

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

ts 复制代码
class ContainingClass {

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

2.1 类装饰器函数

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

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

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

来看下面的例子:

ts 复制代码
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);

输出结果:

ts 复制代码
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 对象:

ts 复制代码
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

让我们运行这个脚本:

ts 复制代码
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 对象存在之前执行的。

2.2 类属性装饰器应用

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

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

ts 复制代码
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
            }
        });
    }
}

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

ts 复制代码
class StudentRecord {

    @IntegerRange(1900, 2050)
    year: number;

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

const sr1 = new StudentRecord();

console.log(registered);

输出结果:

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

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

2.3 使用 Object.defineProperty 的盲区

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

ts 复制代码
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 函数强制执行范围。在数据存储方面,函数会将值存储在局部变量中。这看起来简单明了,不是吗?

下面进行测试:

ts 复制代码
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 的那一行,你会看到它抛出一个异常。

打印结果:

ts 复制代码
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 类中添加以下字段:

ts 复制代码
@ValidRange(0, 150)
age: number;

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

测试一下:

ts 复制代码
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 实例的年龄赋值,然后打印两个实例的年龄:

ts 复制代码
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 实例:

ts 复制代码
const stud_3 = new Student();

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

ts 复制代码
console.log(`stud_3 ${stud_3.year} ${stud_3.age}`);

输出结果:

ts 复制代码
stud_3 1911 20

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

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

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

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

2.4 小结

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

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

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

参考资料

相关推荐
outstanding木槿几秒前
JS中for循环里的ajax请求不数据
前端·javascript·react.js·ajax
酥饼~7 分钟前
html固定头和第一列简单例子
前端·javascript·html
一只不会编程的猫11 分钟前
高德地图自定义折线矢量图形
前端·vue.js·vue
m0_7482509313 分钟前
html 通用错误页面
前端·html
来吧~22 分钟前
vue3使用video-player实现视频播放(可拖动视频窗口、调整大小)
前端·vue.js·音视频
鎈卟誃筅甡35 分钟前
Vuex 的使用和原理详解
前端·javascript
呆呆小雅40 分钟前
二、创建第一个VUE项目
前端·javascript·vue.js
m0_748239331 小时前
前端(Ajax)
前端·javascript·ajax
Fighting_p1 小时前
【记录】列表自动滚动轮播功能实现
前端·javascript·vue.js
前端Hardy1 小时前
HTML&CSS:超炫丝滑的卡片水波纹效果
前端·javascript·css·3d·html