深入浅出TypeScript装饰器

前言

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

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

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

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

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

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

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

typescript 复制代码
{  
    "compilerOptions": {  
        "target": "ES5",  
        "experimentalDecorators": true  
    }  
}

类装饰器

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

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

比如:

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

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

ts 复制代码
function override(target: new () => any) {
  return class Child {

  }
}

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

}

const instance = new Parent()

console.log(instance) // Child {}

方法装饰器

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

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

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

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

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

ts 复制代码
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)) // []

属性访问器装饰器

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

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

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

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

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

ts 复制代码
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
  • 第三个参数,表示当前参数所在方法的位置索引。

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

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

ts 复制代码
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的打包结果如下:

typescript 复制代码
// ....
// 属性装饰器
__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方法:

typescript 复制代码
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 重新赋值给当前构造函数。

typescript 复制代码
Parent = __decorate([logger, __param(0, paramDecorator)], Parent);

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

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

相关推荐
腾讯TNTWeb前端团队32 分钟前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰4 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪4 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪4 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy5 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom5 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom6 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom6 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom6 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom6 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试