深入浅出TypeScript装饰器

1,089 阅读9分钟

前言

最近在学习Nest.js的内容,发现装饰器本质和Java的面向切面编程。装饰器用于给类,方法,属性以及方法参数等增加一些附属功能而不影响其原有特性。其在Typescript应用中的主要作用类似于Java中的注解,在AOP(面向切面编程)使用场景下非常有用。

面向切面编程(AOP)  是一种编程范式,它允许我们分离横切关注点,藉此达到增加模块化程度的目标。它可以在不修改代码自身的前提下,给已有代码增加额外的行为(通知)

装饰器一般用于处理一些与类以及类属性本身无关的逻辑,例如: 一个类方法的执行耗时统计或者记录日志,可以单独拿出来写成装饰器。

看一下官方的解释更加清晰明了

装饰器是一种特殊类型的声明,它能够被附加到类声明,方法, 访问符,属性或参数上。 装饰器使用 @expression这种形式,expression求值后必须为一个函数,它会在运行时被调用,被装饰的声明信息做为参数传入。

如果有使用过spring boot或者php的symfony框架的话,就基本知道装饰器的作用分别类似于以上两者注解和annotation,而node中装饰器用的比较好的框架是nest.js。不过不了解也没关系,接下来我就按我的理解讲解一下装饰器的使用。

不过目前装饰器还不属于标准,还在建议征集的第二阶段,但这并不妨碍我们在ts中的使用。只要在 tsconfig.json中开启 experimentalDecorators就可以使用了。

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

类装饰器

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

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

比如:

// 类装饰器,接受一个参数即为类本身
// 将装饰后的类以及类的原型全部冻结变为不可扩展以及不可修改
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 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)

参数装饰器

同样,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
}

至此所有常见的类装饰器都介绍完了,其实本质的装饰器函数入参都是一致的,第一个参数是装饰器所在的类名、第二个参数是装饰参数,接下来我们看一下装饰器的实现原理。

实现原理

我们将一个包含很多装饰器的类将ts代码编译成es5的打包结果如下:

// ....
// 属性装饰器
__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;

会发现所有装饰器都在调用__decorate方法,并且不同的装饰器,对于__decorate方法的入参也是通用型很强。

  • 第一个参数表示当前修饰器个数的集合,这是一个数组。

  • 第二个参数表示当前修饰器修饰的目标(类的构造函数或者类的原型),这一步在 TS 编译后就已经确定。

  • 第三个参数如果存在的话,表示当前修饰器修饰对象的 key (这是一个字符串,可能为方法名、属性名等)。

  • 第四个参数如果存在的话,为 null 或者为 undefined

然后我们再看一下具体的__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;
};

函数最后都会返回r对象,一开始会给予实参个数以及特定参数进行判断处理,然后基于decoratorstarget获得所有装饰方法,然后拿到装饰类的原型。

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

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

至此,深入浅出装饰器全过程结束。

如果喜欢我的文章或者想上岸大厂,可以关注公众号「量子前端」,将不定期关注推送前端好文、分享就业资料秘籍,也希望有机会一对一帮助你实现梦想。