likes
comments
collection
share

NestJs:深入浅出装饰器

作者站长头像
站长
· 阅读数 4

引言

最近工作上刚好有接触部分 nest 相关的内容,之前对于 nest 了解的并不是特别深入。

刚好趁着工作中用接触到的使用机会,写一些文章来记录、分享自己对于学习、使用、掌握 nest 的心得来和大家共同交流、分享。

装饰器

随着 ES6 中 class 的普及,在一些特定场景下我们需要通过一些额外的特性支持标注或者修饰类或者类的成员,这样的场景下装饰器随之而来。

装饰器(Decorators)的提出为我们在类的声明及成员上通过元编程语法添加标注提供了一种可行性,目前装饰器语法在 js 中处于 Stage3 的提案。

文章中我们更多是通过 TypeScript 中的装饰器来和大家讲解这一特性,需要注意的是因为装饰器提案目前仍然为 Candidate 阶段所以未来如果有变更的话可能 typescriptjavascript 中的装饰器可能会有微小不同,不过目前来看 api 以及用法是完全相同。

在 TypeScript 项目中要启用装饰器语法的话,需要在 tsconfig.json 中额外开启 experimentalDecorators 属性。

接下来我们就来一起看看不同的装饰器是如何使用的;

类装饰器

类装饰器仅接受一个参数,该参数表示类本身。

同时,如果类装饰器返回一个值,它会使用提供的构造函数来替换类的声明。

比如:

// 类装饰器,接受一个参数即为类本身
// 将装饰后的类以及类的原型全部冻结变为不可扩展以及不可修改
function freeze(constructor: Function) {
  Object.freeze(constructor); // 冻结装饰的类
  Object.freeze(constructor.prototype); // 冻结类的原型
}


// 调用 freeze 装饰装饰 BugReport
@freeze
class BugReport {
  static type = 'report'
}


BugReport.type = 'hello'
console.log(BugReport.type) // TypeError: Cannot assign to read only property 'type' of function 'class BugReport

同时类装饰器如果存在一个有效返回值,该返回值会替代被修饰类的构造函数返回的实例对象。比如:

function override(target: new () => any) {
  return class Child {

  }
}

@override // override 装饰器修改了 Parent class 返回的实例对象
class Parent {

}

const instance = new Parent()

console.log(instance) // Child {}

方法装饰器

方法装饰器是在方法声明之前声明的。方式装饰器可用于观察、修改或替换方法定义。

方法装饰器接受三个参数:

  • 如果该装饰器修饰的是类的静态方法,那么第一个参数表示当前类的构造函数(即当前类)。如果修饰为类的原型方式,那么第一个参数表示该类的原型对象(prototype)。
  • 第二个参数表示该方法参数器修改的类的名称。
  • 第三个参数表示当前方法的属性描述符。

同时,如果方法装饰器返回一个值,它会被用作方法的属性描述符

比如下面的例子,我们使用方法装饰器修改类的实例方法,将 greet 方法变为不可枚举:

function enumerable(value: boolean) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {

    console.log(target) // Greeter.prototype
    console.log(propertyKey) // greet

    // 将该方法(Greeter.prototype.greet) 变为不可枚举
    descriptor.enumerable = value;
  };
}


class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
 
  // @enumerable(false) 修饰实例方法,既修饰器第一个参数为 Greeter.prototype
  @enumerable(false)
  greet() {
    return "Hello, " + this.greeting;
  }
}

console.log(Object.keys(Greeter.prototype)) // []

同时我们也可以使用方法修饰器来修饰类的静态方法:

function rewrite(full: boolean) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {

    console.log(target) // Person
    console.log(propertyKey) // eat

    // 将该方法 (Person.eat) 重写
    if (full) {
      descriptor.value = () => console.log('full!!!!');
    }

  };
}


class Person {
  // @rewrite(false) 修饰实例方法,既修饰器第一个参数为 Person
  @rewrite(true)
  static eat() {
    return 'Eat Hot Pot'
  }
}

console.log((Person.eat())) // full!!!!

属性访问器装饰器

属性访问器装饰器同样在属性访问器声明前使用,常用于观察、修改或替换属性访问器的定义。

当属性装饰器被调用时,和方法装饰器同样会接受三个参数,分别为:

  • 如果当前属性访问器为类的静态属性访问器,那么属性访问器修饰器接受的第一个参数则为当前类的构造函数。否则,如果修饰的为实例上的属性访问器,则第一个参数为类的原型。
  • 第二个参数为当前被修饰的成员名称。
  • 第三个参数为当前被修饰的属性描述符。

同样,如果访问器装饰器返回一个值,它也会被用作方法的属性描述符

比如,当我们使用装饰器来修饰当前类上的属性访问器时:



function baseLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  // 触发属性访问器时
  console.log(`Trigger getter(${target.name}/${propertyKey})`)
}

class Person {

  @baseLog
  static get username() {
    return '19Qingfeng'
  }
}

// Trigger getter(Person/username)
// 19Qingfeng
console.log(Person.username)

同时,我们也可以使用属性访问装饰器来装饰类的实例属性访问器:

function baseLog(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  // 触发属性访问器时
  console.log(target) // Person.prototype
  console.log(`Trigger getter(${target.name}/${propertyKey})`)
}

class Person {

  get name() {
    return 'wang.haoyu'
  }

  @baseLog
  get username() {
    return '19Qingfeng'
  }
}

const user = new Person()

console.log(user.username) // Trigger getter(wang.haoyu/username)

属性装饰器

同时,类的属性也存在装饰器。类的属性装饰器同样用在属性声明之前,并且属性装饰器接受两个参数分别为:

  • 如果修饰的为类的实例属性,那么第一个参数代表类的原型。反之,如果修饰的为类的静态属性,那么第一个参数则为类的构造函数(即为类本身)。
  • 第二个参数即为被修饰的属性名称。

比如,当我们使用类的属性装饰器时:

class Greeter {
  @attributeLog
  greeting: string;

  @attributeLog
  static username = 'wang.haoyu'

  constructor(message: string) {
    this.greeting = message;
  }

  replace() {
    return this.greeting.replace("%s", this.greeting);
  }
}

function attributeLog(target: any, propertyKey: string) {
  console.log(target) // Greeter.prototype { replace: [Function (anonymous)] }
  console.log(propertyKey) // greeting
}


// log:

// { replace: [Function (anonymous)] } { replace: [Function (anonymous)] }
// greeting 

// [Function: Greeter] { username: 'wang.haoyu' }
// username

参数装饰器

同样,class 上每个方法的参数还存在参数修饰器。参数修饰器会为参数声明之前,同样具有三个参数:

  • 当参数修饰器修饰的所在方法为类的构造函数/静态方法时,第一个参数表示类的构造函数(类本身)。反之,当参数修饰器修饰的参数所在的方法为实例方法时,此时第一个参数代表类的原型。
  • 如果修饰的为类的静态/实例方法时,第二个参数为当前参数修饰器所在方法的方法名。如果参数修饰器所在的方法为类的构造函数参数修饰时,此时第二个参数为 undefined
  • 第三个参数,表示当前参数所在方法的位置索引。

我们依次来看看参数装饰器分别装饰类的构造函数、类的静态方法上的参数以及类的实例方法上的参数不同表现:

参数修饰器所在方法为修饰类的构造函数:

class Person {

  constructor(@logger name: string) {

  }
}


function logger(target: any, methodName: string | undefined, index: number) {
  console.log(target) // [Function: Person]
  console.log(methodName) // undefined
  console.log(index) // 0
}

参数修饰器所在方法为修饰类的静态方法:

class Person {

  static read(@logger name: string) {

  }
}


function logger(target: any, methodName: string | undefined, index: number) {
  console.log(target) // [Function: Person] { read: [Function (anonymous)] }
  console.log(methodName) // undefined
  console.log(index) // 0
}

参数修饰器所在方法为类实例方法:

class Person {

  read(@logger name: string) {

  }
}


function logger(target: any, methodName: string | undefined, index: number) {
  console.log(target) // { read: [Function (anonymous)] }
  console.log(methodName) // undefined
  console.log(index) // 0
}

装饰器实现原理

上文中我们简单聊了聊 typescript 中各种装饰器的概念以及使用方式,接下来我们稍微聊聊 typescript 中是如何在低版本浏览器中实现装饰器这一特性的。

我们结合一个简单的例子来对比,下面的例子分别存在类、方法、属性、参数四种装饰器:

// 类装饰器
function logger(target: any) {
  console.log(`called ${target.name}`)
}

// 方法装饰器
function methodDecorator(target: any, methodName: string, descriptor: PropertyDescriptor) {
  console.log(`called ${methodName}`)
}

// 属性访问符装饰器
function accessorDecorator(target: any, accessorKey: string) {
  console.log(`called accessor decorator`)
}

// 参数装饰器
function paramDecorator(target: any, methodName: string | undefined, index: number) {
  console.log(`called paramDecorator`)
}

// 属性装饰器
function propertyDecorators(target: any, key: string) {
  console.log(`called propertyDecorators`)
}

@logger
class Parent {
  @propertyDecorators
  private company: string = 'hello'

  constructor(@paramDecorator name: string) {

  }

  @accessorDecorator
  get gender() {
    return 'man'
  }

  @accessorDecorator
  static staticGender() {
    return 'man'
  }

  @methodDecorator
  getName(@paramDecorator firstName: string) {
    return firstName + '19Qingfeng'
  }

  @methodDecorator
  static getStaticName() {
    return '19Qingfeng'
  }
}

接下来我们设置 tsconfig.jsontarget: es5 后,我们一起来看看上边的 ts 代码经过编译后的 js 代码会变成什么样子:

var __decorate = function (decorators, target, key, desc) {
  var c = arguments.length,
    r =
      c < 3
        ? target
        : desc === null
        ? (desc = Object.getOwnPropertyDescriptor(target, key))
        : desc,
    d;
  for (var i = decorators.length - 1; i >= 0; i--) {
    if ((d = decorators[i])) {
      r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    }
  }

  return c > 3 && r && Object.defineProperty(target, key, r), r;
};

// 参数装饰器前置包裹方法
var __param = function (paramIndex, decorator) {
  return function (target, key) {
    decorator(target, key, paramIndex);
  };
};

// 类装饰器
function logger(target) {
  console.log('called '.concat(target.name));
}
// 方法装饰器
function methodDecorator(target, methodName, descriptor) {
  console.log('called '.concat(methodName));
}
// 属性访问符装饰器
function accessorDecorator(target, accessorKey) {
  console.log('called accessor decorator');
}
// 参数装饰器
function paramDecorator(target, methodName, index) {
  console.log('called paramDecorator');
}
// 属性装饰器
function propertyDecorators(target, key) {
  console.log('called propertyDecorators');
}
var Parent = /** @class */ (function () {
  // 构造函数
  function Parent(name) {
    this.company = 'hello';
  }
  // 定义类的原型 & 静态
  Object.defineProperty(Parent.prototype, 'gender', {
    get: function () {
      return 'man';
    },
    enumerable: false,
    configurable: true,
  });
  Parent.staticGender = function () {
    return 'man';
  };
  Parent.prototype.getName = function (firstName) {
    return firstName + '19Qingfeng';
  };
  Parent.getStaticName = function () {
    return '19Qingfeng';
  };

  // 属性装饰器
  __decorate([propertyDecorators], Parent.prototype, 'company', undefined);
  // 访问器属性装饰器(原型)
  __decorate([accessorDecorator], Parent.prototype, 'gender', null);
  // 方法装饰器 & 参数(实例方法)装饰器
  __decorate(
    [methodDecorator, __param(0, paramDecorator)],
    Parent.prototype,
    'getName',
    null
  );
  // 访问器属性装饰器(实例)
  __decorate([accessorDecorator], Parent, 'staticGender', null);
  // 方法装饰器(实例)
  __decorate([methodDecorator], Parent, 'getStaticName', null);
  // 类装饰器 & 参数装饰器(类的构造函数)
  Parent = __decorate([logger, __param(0, paramDecorator)], Parent);
  return Parent;
})();

上述代码为我们的 ts 装饰器代码编译后的 js 代码,这里我稍微做了一些简化,删除了编译后的一些不必要代码。

乍一看大家或许稍微有些懵,不过没关系,接下来我们来一步一步拆解它。

首先我们可以看到在这段代码的结尾,我们可以看到不同的装饰器其实核心都是在调用一个 __decorate 的方法。

// ...
  // 属性装饰器
  __decorate([propertyDecorators], Parent.prototype, 'company', undefined);
  // 访问器属性装饰器(原型)
  __decorate([accessorDecorator], Parent.prototype, 'gender', null);
  // 方法装饰器 & 参数(实例方法)装饰器
  __decorate(
    [methodDecorator, __param(0, paramDecorator)],
    Parent.prototype,
    'getName',
    null
  );
  // 访问器属性装饰器(实例)
  __decorate([accessorDecorator], Parent, 'staticGender', null);
  // 方法装饰器(实例)
  __decorate([methodDecorator], Parent, 'getStaticName', null);
  // 类装饰器 & 参数装饰器(类的构造函数)
  Parent = __decorate([logger, __param(0, paramDecorator)], Parent);
// ...

我们可以看到不同类型的装饰器关于调用 __decorate 方法唯一不同的即是传入方法的参数个数以及类型的不同

装饰器类型实参个数备注
属性装饰器3
访问器属性装饰器4
类装饰器2
方法装饰器4
参数装饰器2修饰类的构造函数
参数装饰器4修饰类的实例方法/静态方法

关于参数类型,__decorate 方法针对不同的修饰器本质上参数类型是一致的:

  • 第一个参数表示当前修饰器个数的集合,这是一个数组。
  • 第二个参数表示当前修饰器修饰的目标(类的构造函数或者类的原型),这一步在 TS 编译后就已经确定。
  • 第三个参数如果存在的话,表示当前修饰器修饰对象的 key (这是一个字符串,可能为方法名、属性名等)。
  • 第四个参数如果存在的话,为 null 或者为 undefined

需要额外留意的是,实参中传递 undefined 和调用时不传递参数,对于 arguments.length 实际产生的效果是不一致的。

比如:

function logArgumentsLength() {
  console.log(arguments.length);
}

logArgumentsLength(); // 0

logArgumentsLength(undefined, undefined); // 2

当然,我们在现代 EcmaScript (ES6以及以上)中获取函数的个数更多是使用 rest 剩余操作符,这样的方式在新版的 js 已经不被推荐了。

所以,对于 __decorate 方法不同的装饰器调用的均为 __decorate 方法,不过我们可以按照实际传入的参数个数进行分类:

  • 属性装饰器,传入 4 个实参。
  • 访问器装饰器: 传入 4 个实参。
  • 方法装饰器: 传入 4 个实参。
  • 类装饰器: 传入 2 个实参。
  • 参数装饰器: 如果修饰的为类构造函数的参数,则传入 2 个实参。反之,如果参数装饰器所在的方法为实例方法或者静态方法的话,此时会传入 4 个实参。

之后,我们再来详细看看所谓的装饰器方法 __decorate

var __decorate = function (decorators, target, key, desc) {
  // 首先获得实参的个数
  var c = arguments.length,

  // 1. 如果实参个数小于 3 ,则表示该装饰器为 类装饰或者在构造函数上的参数装饰器
  // 2. 如果实参个数大于等于3, 则表示为非 1 情况的装饰器。
  // 2.1 此时根据传入的第四个参数,来判断是否存在属性描述
  // 如果 desc 传入 null,则获取当前 target key 的属性描述符给 r 赋值。比如访问器属性装饰器、方法装饰器
  // 相反如果传入非 null (通常为 undefined), 则直接返回 desc 。比如属性装饰器


  // 此时 r 根据不同情况,
  // 要么是传入的 target    (实参个数小于3)
  // 要么是 Object.getOwnPropertyDescriptor(target, key) (实参个数小于3,且 desc 为 null)
  // 要么是 undefined (实参个数小于3, desc 为 undefined)
    r =
      c < 3
        ? target
        : desc === null
        ? (desc = Object.getOwnPropertyDescriptor(target, key))
        : desc,
    d;
  for (var i = decorators.length - 1; i >= 0; i--) {
    // 从数组的末尾到首部依次遍历获得每一个装饰方法
    if ((d = decorators[i])) {
      // 同样判断参数个数
      // 1. 如果实参个数小于 3, 类装饰器/构造函数上的参数装饰
      // 此时 d 为当前装饰器方法, r 为传入的 target (Parent)
      // 此时直接使用当前装饰器进行调用,传入 d(r) 也就是 d(Parent)
      // 2. 如果实参个数大于 3 ,则调用当前装饰 d(target, key, r)
      // 3. 如果实参个数等于 3 , 则调用 d(target, key)
      // 同时为 r 重新赋值,交给下一次 for 循环遍历处理下一个装饰器函数
      r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    }
  }
  // 最终装饰器函数会进行返回
  // 如果个数大于 3,并且 r 存在 则会返回 Object.defineProperty(target, key, r) ,将返回的 r 当作属性描述符定义在 target key 上
  // 最终返回 r 
  return c > 3 && r && Object.defineProperty(target, key, r), r;
};

上边的 __decorate 方法中每一步都已经进行了详细的注释,本质上仍然是根据我们上边描述的根据参数个数以及参数的类型来判断当前调用 __decorate 方法的装饰器是哪种类型的装饰器,从而依次调用装饰器函数传入对应参数。

同时,我们也可以看到最终 __decorate 函数会返回最终处理后的 r ,这里如果为访问器装饰器或者方法装饰器的话,我们会将最终返回的有效值作为属性描述符定义在装饰的方法、访问器上。

最终,会返回处理后的装饰器方法 r,在类装饰器上我们会使用到返回后的 r 重新赋值给当前构造函数:

Parent = __decorate([logger, __param(0, paramDecorator)], Parent);

当然,需要额外留意的是对于修饰非构造函数参数的参数装饰器来说,本质上我们也可以将它理解为一种方法装饰器,比如代码中的

  __decorate(
    [methodDecorator, __param(0, paramDecorator)],
    Parent.prototype,
    'getName',
    null
  );

ts 在编译后将 getName 的方法修饰器和参数修饰器放在了同一个数组中当作方法修饰器来处理。

唯一不同的是,参数修饰器优先经过了 __param(当前参数所在索引,原始装饰器方法) 的处理,返回了一个新的装饰器函数:

var __param = function (paramIndex, decorator) {
  // 参数装饰器返回的真实装饰器方法
  // 我们将这里返回的方法称为 realParamsDecorator
  return function (target, key) {
    decorator(target, key, paramIndex);
  }; 
};

实际上返回的 realParamsDecorator 会接受 3 个真实参数,分别为 taregetkey 以及 desc (当前参数所在方法的属性描述),但是由于 realParamsDecorator 仅接受两个参数,自然参数装饰器会丢掉 desc

截止目前 Typescript 中我们将装饰器编译称为 Es5 代码后的实现已经完全和大家讲解完毕,有兴趣的同学可以私下自己尝试去阅读一下编译后的 Es5 代码,其实实现的机制无非是通过不同参数个数来确定而已并没有多少复杂。

Relefect MetaData 元数据

之所以这里和大家提到 Relefect MetaData 是因为在 nestjs 中有两个非常核心的概念:IOC(Inversion of Control) 以及 DI(Dependency Injection) 他们内部的实现机制和 Relefect MetaData 息息相关。

由于这篇文章其实篇幅已经过长,关于 MetaData 这部分我们本次稍带一些它的相关概念即可,下一篇文章中我会详细和大家聊聊 nest 中是如何配合 Relefect MetaData 来实现 IOC 以及 DI 的。

相信对于 ES6 中的 Relefect 大家都有了解过,但是对于 Relefect MetaData 大多数小伙伴可能并未听说过。

Relefect MetaData 目前处于实验性阶段,简单来说 decorator 装饰器是用于为类、属性以及方法添加一些附加的功能。

但是我们无法判断当前类/方法上定义了哪些装饰器, Reflect Api 的出现则解决了这一问题。Relefect Metadata 更多用于访问和修改元数据,通常我们可以配合 Reflect.metadatadecorator 来为装饰器进行一些元数据的定义和存取。

其实不严谨的说,暂时大家可以将 Reflect.metadata 理解成为 Object.defineProperty 类似的作用即可。

结尾

趁着最近工作中接触 nest 相关知识,刚好对于一些很久未使用的东西(装饰器)以及自己探索 nest 中的一些内部实现细节我会一一和大家在专栏中进行分享,帮助大家的同时也刚好为自己巩固这部分知识。

如果有同学对于装饰器相关的知识,或者 nest 有疑惑的部分也可以在评论区留下你的想法我们一起交流。