Typescript 装饰器

工欲善其事必先利其器

装饰者模式(Decorator Pattern)也称为装饰器模式,即在不改变对象自身的基础上,动态增加额外的职责,属于结构型模式的一种。

使用装饰者模式的优点是把对象核心职责和要装饰的功能分开 ,装饰者模式属于非侵入式的行为修改。

装饰器是一项实验性特性,在未来的版本中可能会发生改变。(来自官网)

虽然目前仅仅是实验性特性,但是装饰器肯定会有,最多变的也是用法,毕竟装饰器在 Java、Python 这种语言中的场景已经足够成熟了。

开启配置

在 Typescript 配置中需要开启装饰器支持

1
2
3
4
5
6
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
}

装饰器的种类

  • 类装饰器
  • 方法装饰器
  • 属性装饰器
  • 参数装饰器
  • 访问器装饰器

装饰器的格式

装饰器通常通过一个 闭包函数 来声明,返回的匿名函数将在编译时调用。

届时将传递一些参数,如构造器、调用上下文、被装饰者的键名、装饰器属性对象(仅方法装饰器存在)等。

1
2
3
4
5
6
7
8
9
// 定义装饰器
function expression() {
// 装饰器执行函数
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {}
}

// 使用,注意这里用的是 expression 的返回值
@expression()
test () {}

使用闭包函数的好处是可以保留一些内存空间用于存储一些装饰器内部计算的逻辑,或者可以在调用装饰器的时候传递一些 Options 参数进去控制逻辑等。

同样也带来一些隐患,如函数上下文、原型链的复杂变化。

当然如果不需要的话,直接定义 function ,使用 @expression 调用即可。

类装饰器

类装饰器在调用的时候,接收一个 contructor 参数, 为被装饰的 class 的构造器。

如何定义

我们可以通过重载构造函数, 来为 class 添加一些自定义的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 定义装饰器
function MixinClass<T extends {new(...args:any[]): Record<any, any>}>(constructor: T) {
// 重载构造函数
return class extends constructor {
aaa = '这是添加的属性'

mixinFunc () {
console.log('这是添加的方法')
}
}
}

@MixinClass
class Test {
f () {
console.log(this.aaa);
this.mixinFunc()
}
}


const t = new Test()
t.f()

// 执行结果
这是添加的属性
这是添加的方法

当然嚯~

如果装饰器返回的 class 不继承 构造函数, 那等于是直接替换掉了被装饰的类(不过我想不到这种用法的业务场景 ~)

注意

在定义 Class 装饰器的时候,注意接收的参数,如:

1
<T extends {new(...args:any[]): Record<any, any>}>(constructor: T)

如果参数不对, ts 会报签名类型错误:

1
TS1238: Unable to resolve signature of class decorator when called as an expression.

业务场景

  1. 在声明 Vue 组件的时候,通过装饰器来定义一些属性,如声明 Props、Components,以达到简化写法的目的。
1
2
3
4
5
6
7
@Options({
props: {
type: String,
},
})
export default class NextTaoButton extends Vue {
}

方法装饰器

方法装饰器目前研究下来,觉得是最有用的了~

如何定义

方法装饰器的定义和类装饰器基本相同,不同的是多接受一个参数,可以访问到方法的属性对象,如此来, 我们便可以通过拦截这个对象来进行一些附加、修改数据的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export function ButtonProps(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
let callCount = 0;
// target.$set('clickNumber', target.clickNumber++)
// 拿到真正的执行函数
const fn = descriptor.value;

// 在函数调用的时候, 执行附加逻辑
function additionalFunc() {
console.log(callCount++);
}

// 修改函数
descriptor.value = function(...args: any) {
additionalFunc();

// 调用原始业务逻辑
fn.apply(target, args);
};

}

@ButtonProps
onClick(params: string) {
console.log('业务逻辑')
}

如上述逻辑

定义 @ButtonProps, 并通过闭包存储一个 callCount。

并重新定义函数的执行块,将附加逻辑追加进去,注意调用 fn 的时候要保证this 指向正常。

执行结果如下:

1
2
3
4
5
6
0
业务逻辑
1
业务逻辑
2
业务逻辑

业务场景一

现有需求,我们需要对一些函数进行调用日志记录,就可以通过装饰器来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 定义装饰器
export function Logger(logId?: string, handler?: (input: string, output: string) => string) {
const loggerInfo = Object.seal({ logId: logId, input: "", output: "", custom: "" });

// 模拟一个 日志服务
class LogService {
static publish(loggerInfo: {logId?: string; input: string; output: string; custom: string}) {
console.log("日志上报", new Date().getTime(), loggerInfo);
}
}

return function(
target: any,
key: string,
descriptor: TypedPropertyDescriptor<any>
) {
const oldValue = descriptor.value;

// 重写执行方法
descriptor.value = function(...args: any) {
loggerInfo.input = `${key}(${args.join(",")})`;

// 执行原方法
// 记录输出值
handler && (loggerInfo.custom = handler(loggerInfo.input, loggerInfo.output) || "");

loggerInfo.output = oldValue.apply(this, args);

// 被调用时,会自动发出一个事件
LogService.publish(loggerInfo);
};
return descriptor;
};
}

// 调用方
@Logger('onClick')
onClick(params: string) {
console.log('业务逻辑')
}

很简单嚯~ ,逻辑其实和上面定义点击次数是一样的,调用的时候只需要添加 @Logger ,就可以上报日志服务器。

业务场景二

最最最常见的需求之一:为函数添加防抖或节流。

如果没有装饰器,我们可能会这么做:

1
2
3
4
5
import { debounce } from 'lodash'

onClick = debounce(() => {
console.log('点击啦')
}, 500)

或者通过create 创建

1
2
3
4
5
6
7
8
9
10
11
import { debounce } from 'lodash'

onClick?: () => void

_onClick () {
console.log('点击啦')
}

created () {
this.onClick = debounce(this._onClick, 500)
}

emmm~ 无论怎么写都不是很优雅。

ok 我们来用装饰器来实现:

定义

1
2
3
4
5
6
export function Debounce(wait = 200) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// 修改函数
descriptor.value = debounce(descriptor.value, wait)
}
}

使用

1
2
3
4
@Debounce()
onClick() {
console.log("点击啦");
}

简单且优雅~

装饰器叠加

如果既需要防抖, 又需要Logger 呢?

装饰器是可以叠加生效的

1
2
3
4
5
@Logger('onclick')
@Debounce()
onClick() {
console.log("点击啦");
}

这里涉及到一个装饰器的执行先后顺序问题

  1. 编译阶段代码从上往下执行,所以如果是定义注释器的时候使用了函数包裹,则会先自上而下执行闭包构造函数,如 Debounce 的母函数。

  2. 点击阶段是从下往上执行,计算模型为 Logger(Debounce(onClick)),上层函数会重新计算下层函数的返回函数。

属性装饰器

注意: 属性描述符不会做为参数传入属性装饰器,这与TypeScript是如何初始化属性装饰器的有关。 因为目前没有办法在定义一个原型对象的成员时描述一个实例属性,并且没办法监视或修改一个属性的初始化方法。返回值也会被忽略。因此,属性描述符只能用来监视类中是否声明了某个名字的属性。

– 来自官网

因为属性装饰器只能用在 class 中,且会在代码编译阶段调用,此时 class 尚未被实例化,所以是拿不到属性值的。

由于鄙人不信邪,挣扎着尝试了一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 定义装饰器
function MathPower (target: any, propertyKey: string) {
target[propertyKey] = 'zhukejin' // 无效

target[propertyKey].writable = false // 无效

let value = 'zhukejin'

const getter = () => {
return value
}

const setter = (val: string) => {
console.log(333)
value = val
}

if (delete target[propertyKey]) {
// 无效
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
configurable: true,
enumerable: true
})
}
}

class Test {

@MathPower
name = ''

f () {
this.name = 'libai'
console.log(this.name);
}
}


const t = new Test()

t.name = 'ttt'

console.log(t.name)

属性的装饰器中,只能进行初始化参数的访问,并不能进行赋值、添加。

目前想不到什么业务场景可用…

不过官网提供了一个基于 reflect-metadata 的例子,获取可以用于真实业务场景, 比如通过元数据为一个参数添加备注、子信息等等

https://www.tslang.cn/docs/handbook/decorators.html#method-decorators

参数装饰器

参数装饰器接受三个参数,前两个同上,第三个额外接收一个此参数在参数列表中的 index。

但是嚯,参数装饰器和属性装饰器类似:不可以修改

所以可操作性就降低了很多,不过,我们可以通过监视参数传递配合方法装饰器,来执行一些额外的方法,比如必填校验?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 定义装饰器
function Required() {
// 收集 Required index
const requiredIndex: { index: number; propertyKey: string }[] = [];

return function(action?: "collect" | "validate") {
if (action === "validate") {
// 返回新地校验函数
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// 修改函数
const fn = descriptor.value;

descriptor.value = function(...args: any) {
// 调用时候校验
for (const index in requiredIndex) {
if (!args[index].index) {
// 抛错
throw Error(`请传入函数 ${requiredIndex[index].propertyKey} 的 第 ${requiredIndex[index].index} 个必填参数`)
}
}

fn.apply(target, args);
};
};
} else {
// 正常返回必填项收集装饰器
return function(target: any, propertyKey: string, index: number) {
// 收集
requiredIndex.push({
index,
propertyKey
});
}
}
};
}

调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const R = Required();

class Test {
@R('validate')
f(@R() name: string) {
// console.log(name);
}
}

const t = new Test();

t.f(11);

// 执行结果,抛错:
// Uncaught Error: 请传入函数 f 的 第 0 个必填参数

这里的定义逻辑是通过闭包,存储一个必填收集器,然后返回一个方法,返回的方法再根据参数分化出两个装饰器。(其实也算工厂模式嚯)

这样的嵌套的目的是保证两个装饰器既能区分用途,又能保持同一个执行 scope(对没错,就真的只是为了在闭包中存储必填收集器…)

当然这只是个Demo,讲道理 emmm, 实际业务场景是有,但~不多!关键还是得靠方法装饰器。

但既然已经用方法装饰器了,压根就不需要整这么复杂~

上面这个例子, 如果借助 Reflect matedata 来做的话,会简单很多, 因为不需要额外的存储空间。直接将信息放到元数据中即可。

访问器装饰器

访问器装饰器和方法装饰器有点类似,可以通过第三个参数来改变get、set。

但是需要注意

TypeScript不允许同时装饰一个成员的getset访问器。取而代之的是,一个成员的所有装饰的必须应用在文档顺序的第一个访问器上。这是因为,在装饰器应用于一个属性描述符时,它联合了getset访问器,而不是分开声明的。

– 来自官网

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function WhenGet(target: any, key: string, descriptor: PropertyDescriptor) {
descriptor.get = function () {
return 'ccc'
}
}

class Test {
@WhenGet
get name() {
return 'zhukejin'
}
}

const t = new Test();

console.log(t.name) // ccc