TypeScript 中的装饰器

什么是 Decorator

装饰器(Decorator)用来增强 JavaScript 类(class)的功能,许多面向对象的语言都有这种语法,ES 的这一语法提案目前仍处于 stage3 阶段。

简介

Decorator(装饰器)本质上是一个传入固定参数的函数,主要修饰类及类的成员变量。 装饰器使用 @ 语法:

function Deco() {}
@Deco  
class A {}  

一般情况下我们并不会直接使用装饰器的方式,而是使用一种叫做 Decorator Factory 的形式:

function Deco(){  
    return () => {}
}  
@Deco()  
class A {}  

跟第一种写法相比,不同的地方在于 Deco 函数内部又返回了一个函数,使用方式也从 @Deco 变成了函数调用 @Deco(),后者的优点在于程序执行时,优先执行 Deco(),然后通过 Deco 内部返回的函数作为装饰器的实际逻辑,这样,我们就可以通过入参来灵活使用装饰器。

装饰器的使用方式

装饰器的作用范围

根据 ECMAScript 规范,装饰器只能作用于:

  • 类的属性
  • 类的方法
  • 属性存储器/访问符(accessor):getter、setter
    但是在 TS 中又支持了参数装饰器,所以装饰器一共可以作用于5个部分。 先通过一段伪代码简单了解一下写法:
@Deco() // 1.类装饰器  
class A {  
  @modifyName() // 2.参数装饰器  
  name!: string
  
  @log() // 3.方法装饰器  
  method(){}        
  
  @addOne() // 4.属性存储器(访问符)装饰器  
  getter() {} 
  // setter() {}
  handler(@CheckParam() input: string) { // 5.参数装饰器  
    console.log(input);
  }}

下面来看下这几种的用法:

INFO

TS 中想要使用装饰器需要开启 experimentalDecorators 选项。

类装饰器

我们上面说到,装饰器的本质是一个传入固定参数的函数,那么类装饰器的入参就是类本身,而不是类的原型对象。因此我们可以通过类装饰器复写类的属性和方法,甚至可以通过装饰器返回一个新类。
给类 A 添加新的属性和方法:

const addMethod = (): ClassDecorator => {
  return (target:any) => {    // 添加到原型上  
    target.prototype.methodInProrotype = () => {      
      console.log('method in prototype')   
    }    // 添加到类本身上  
    target.methodInClass = () => {      
      console.log('method in class')    
    }
  }} 

const addProperty = (value: string): ClassDecorator => {  
  return (target: any) => {    
    target.prototype.propertyInPrototype = value    
    target.propertyInClass = `static-${value}`  
  }}  
  
@addMethod()  
@addProperty('shinji')  
class A {}
const a:any = new A()  

console.log(a.methodInProrotype()) // method in prototype  
console.log((A as any).methodInClass()) // method in class  
console.log(a.propertyInPrototype) // shinji  
console.log((A as any).propertyInClass) // static-shinji  

方法装饰器

方法装饰器的固定参数分别是:类的原型方法名以及方法的属性描述符(PropertyDescriptor),我们可以通过方法的属性描述符来控制方法的内部实现(value)、enumaerable、writable 等。
通过方法装饰秀,来统计实例方法的执行时间。
提供了两种写法,使用 Decorator Factory 的形式及不使用 Factory 的形式:

const calcRuntime = (): MethodDecorator => {  
return (target, methodName, descriptor: TypedPropertyDescriptor<any>) => {    
  const originalMethod = descriptor.value    
  descriptor.value = function (...args: unknown[]){      
    const start = Date.now()      
    const res = originalMethod.apply(this, ...args)      
    const end = Date.now()      
    console.log(`${String(methodName)} Time: `,end- start, 'ms');      
    return res    
  }
}}  

class A {  
@calcRuntime()  
billionLoop() {    
  for (let i=0;i<1000000;i++) {}  
}
}

console.log(new A().billionLoop()) // "billionLoop Time: ",  2ms  
const calcRuntime = (target:any, methodName: any, descriptor: any) => {  
  const originalMethod = descriptor.value    
  descriptor.value = function (...args: unknown[]){      
    const start = Date.now()      
    const res = originalMethod.apply(this, ...args)      
    const end = Date.now()      
    console.log(`${String(methodName)} Time: `,end- start,'ms');      
    return res    
  }}  

class A {  
@calcRuntime  
billionLoop() {    
  for (let i=0;i<1000000;i++) {}  
}}

new A().billionLoop()  

需要特别注意:方法装饰器的 target 是类的原型而非类本身

属性装饰器

属性装饰器的固定入参只有:类的原型属性名称。属性装饰器作用有限,但是我们依旧可以通过直接在类的原型上赋值的方式来修改属性。

const modifyNum = (): PropertyDecorator => {  
  return (target: any, propertyIdentifier) => {        
    target[propertyIdentifier] = 100
}}  

class A {  
    @modifyNum()
    num!: number
}  
const a = new A()  
console.log(a.num, 'num') // 100, num  

参数装饰器

参数装饰器其中包括两部分:构造函数参数装饰器方法参数装饰器,且他们的入参包括类的原型参数所在的方法名参数在函数中的索引(即第几个参数)

const checkParam = ():ParameterDecorator => {  
  return (target: any, identifier, index) => {        
    console.log(target, identifier, index)    
  }
}

class A {  
  handle(@checkParam() num: number) {}
}  
  
const a = new A()  
a.handle(1) // A: {},  "handle",  0   

访问符装饰器

访问符就是常见的get value(){}set value(val){} 这样的方法。所以访问符装饰器本质上也是方法的装饰器,但是访问符装饰器是一种比较特殊的存在,即访问符装饰器只能作用于一对访问符getter/setter其中之一上,即作用于 getter 就不能作用于 setter,反之亦然。这是因为,不论你是装饰的是哪一个,装饰器入参中的属性描述符都会包括 getter 与setter 方法:

const setterDeco = (value: string): MethodDecorator => {  
    return (target: any, identifier, descriptor: any) => {        
      const originalSetter = descriptor.set        
      descriptor.set =  (newValue: string) => {          
        const composed = `Raw: ${newValue}, Actual: ${value}-${newValue}` 
        originalSetter.call(A.prototype, composed);          
        console.log(`setterDeco: ${composed}`)        
      }    
    }}

class A {  
  _value!: string;  
  get value() {    
    return this._value; 
  }  
  @setterDeco('shinji')  
  set value(input: string) {    
    this._value = input;  
  }}  

const a = new A();  
a.value = 'ayanami'  
// "setterDeco: Raw: ayanami, Actual: shinji-ayanami"  

上面这段代码,我们在 a.value 的时候设置的值是 ayanami,但是通过访问符装饰器对 setter 的方法进行了拦截,将值改为了 shinji-ayanami
但依旧可以通过 a.value 进行取值,取到的值就是拦截后修改的值,即 Raw: ayanami, Actual: shinji-ayanami

console.log(a.value) // Raw: ayanami, Actual: shinji-ayanami  

装饰器的入参和返回值类型

根据上文简单总结一下:

  • 类:(class) => ClassDecorator
  • 方法:(class.prototype, identifier, descriptor) => MethodDecorator
    • identifier: 装饰器修饰的方法名
  • 访问符:(class.prototype, identifier, descriptor) => MethodDecorator
    • identifier: 装饰器修饰的方法名
  • 参数:(class.prototype, identifier, index) => ParameterDecorator
    • identifier:装饰器修饰的参数所在的方法名
  • 属性:(class.prototype, propertyIdentifier) => PropertyDecorator
    • propertyIdentifier: 装饰器修饰的属性名

可以看出来,唯一相同的返回值类型就是:方法装饰器访问符装饰器,本质上两者都是装饰方法的。

装饰器的执行机制

装饰器的执行机制包括三个部分:执行时机执行原理执行顺序

执行时机

因为装饰器的本质就是一个函数,所以当一个类绑定了一个装饰器的时候,即使不主动实例化这个类,或者不去调用类的一些静态方法,装饰器也是会正常执行的,比如:

function Cls(): ClassDecorator {
	return (target) => {
		console.log("target:", target)
	}
}

@Cls()
class A {}
// 无需 new A() 装饰器也是正常执行的,最终会打印:
// "target:", class A { }

执行原理

装饰器原理我们可以通过编译一段代码看一下代码最终的效果:

// 编译前
function Cls(): ClassDecorator {
	return (target) => console.log(target, "cls")
}
function Method(): MethodDecorator {
	return (target) => console.log(target, 'method')
}
function Prop(): PropertyDecorator {
	return (target) => console.log(target, 'prop')
}
function Param(): ParameterDecorator {
	return (target) => console.log(target, 'param')
}

@Cls()
class A {
	constructor(@Param() init?: string) { }
	
	@Prop()
	prop: string = 'shinji'
	
	@Prop()
	static staticProp: string = 'static shinji'
	
	@Method()
	handler(@Param() input: string) {}
	
	@Method()
	static staticHandler(@Param() input: string) {}
}

const foo = new A()
// 编译后,省略一些代码
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    // 省略具体实现
};
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
    return function (target, key) { decorator(target, key, paramIndex); }
};
function Cls() {
    return (target) => console.log(target, "cls");
}
function Method() {
    return (target) => console.log(target, 'method');
}
// ...
let A = class A {
    constructor(init) {
        this.prop = 'shinji';
    }
    handler(input) { }
    static staticHandler(input) { }
};
A.staticProp = 'static shinji';
// 看这里!!!
__decorate([
    Prop(),
    __metadata("design:type", String)
], A.prototype, "prop", void 0);
__decorate([
    Method(),
    __param(0, Param()),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [String]),
    __metadata("design:returntype", void 0)
], A.prototype, "handler", null);
__decorate([
    Prop(),
    __metadata("design:type", String)
], A, "staticProp", void 0);
__decorate([
    Method(),
    __param(0, Param()),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [String]),
    __metadata("design:returntype", void 0)
], A, "staticHandler", null);
A = __decorate([
    Cls(),
    __param(0, Param()),
    __metadata("design:paramtypes", [String])
], A);
const foo = new A();

编译后的代码首先定义了个一 __decorate 方法,这个方法的作用是通过传入的参数来判断到底是哪种 decorator(装饰器),然后执行对应的装饰器逻辑。我们再观察一下上面编译后的代码,每种 decorator 函数调用传入的参数是不同的:

装饰器类型入参类型
方法类的原型
属性类的原型
类本身
静态属性类本身
静态方法类本身

需要注意的是属性装饰器再应用时未被初始化(属性实例化后才会有值),所以无法获取到值,所以入参是 void 0

执行顺序

上面编译后的代码同样可以看到装饰器的执行顺序是:

  1. 实例属性
  2. 实例方法
  3. 实例方法的参数
  4. 静态属性
  5. 静态方法
  6. 静态方法的参数
  7. 类及类构造函数

说完执行顺序,再说一下应用顺序,之前有提到说到装饰器被绑定到类上的时候也是正常执行的,这其实是装饰器的一个执行过程,也就是上面说的执行顺序,当实例化这个类的时候,装饰器工厂求值后得到的表达式才开始真正的生效:

关于执行顺序与应用顺序,执行是装饰器求值得到最终装饰器表达式的过程,而应用则是最终装饰器逻辑代码执行的过程

const decoratorFactory = () => {
	// 执行
	return (target) => { /* 应用 */ }
}

装饰器的应用顺序与执行顺序稍有差别,其中方法参数装饰器会先于方法装饰器应用__param(0, Param())) 但实际上,实例属性、方法,静态属性、方法,这些装饰器的执行顺序取决于在类中定义的位置,调整一下定义顺序后,对应编译后的顺序也会改变,即先定义先执行。具体的顺序可以参考 typescript 官方给出的 [Decorator Evaluation](TypeScript: Documentation - Decorators (typescriptlang.org)open in new window) 我们可以通过一段代码,看下具体的执行顺序与应用顺序:

function excuteOrder(identifiery: any): any {
	console.log(`${identifiery}: 执行`)
	return (target:any) => console.log(`${identifiery}: 应用`)
}

@excuteOrder('class')
class A {
	constructor(@excuteOrder('constructor') init?: string) { }
	
	@excuteOrder('prop')
	prop: string = 'shinji'
	
	@excuteOrder('static prop')
	static staticProp: string = 'static shinji'
	
	@excuteOrder('instance method')
	handler(@excuteOrder('instance method param') input: string) {}
	
	@excuteOrder('static method')
	static staticHandler(@excuteOrder('static method param') input: string) {}
}
// [LOG]: "prop: 执行"
// [LOG]: "prop: 应用"
// [LOG]: "instance method: 执行"
// [LOG]: "instance method param: 执行"
// [LOG]: "instance method param: 应用"
// [LOG]: "instance method: 应用"
// [LOG]: "static prop: 执行"
// [LOG]: "static prop: 应用"
// [LOG]: "static method: 执行"
// [LOG]: "static method param: 执行"
// [LOG]: "static method param: 应用"
// [LOG]: "static method: 应用"
// [LOG]: "class: 执行"
// [LOG]: "constructor: 执行"
// [LOG]: "constructor: 应用"
// [LOG]: "class: 应用"

上述 log 打印的顺序中类装饰是在最后应用的,也就是说在类装饰器应用的阶段,我们可以使用到方法或者其他装饰器标记的信息,比如给某个方法标记了 @deprecated 后,我们可以通过类装饰器把标记了 @deprecated 的方法进行报错处理等。

多个同类装饰器的执行顺序

直接看代码:

function First(): any {
	console.log('First 执行')
	return (target:any) => console.log('First 应用')
}

function Second(): any {
	console.log('Second 执行')
	return (target:any) => console.log('Second 应用')
}

function Third(): any {
	console.log('Third 执行')
	return (target:any) => console.log('Third 应用')
}

@First()
@Second()
@Third()
class A {}
// log:
// [LOG]: "First 执行"
// [LOG]: "Second 执行"
// [LOG]: "Third 执行"
// [LOG]: "Third 应用"
// [LOG]: "Second 应用"
// [LOG]: "First 应用"

像,实在是太像了,和洋葱模型简直一模一样。同样,方法中的参数装饰器也遵循这一机制。
以上就是 TypeScript 中装饰器相关的内容。