前言
最近在学习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对象,一开始会给予实参个数以及特定参数进行判断处理,然后基于decorators、target获得所有装饰方法,然后拿到装饰类的原型。
最终,会返回处理后的装饰器方法 r,在类装饰器上我们会使用到返回后的 r 重新赋值给当前构造函数。
Parent = __decorate([logger, __param(0, paramDecorator)], Parent);
至此,深入浅出装饰器全过程结束。