All in ts -- 装饰器

js设计模式,提出了装饰器提案,结合babel或者使用typescript,就能让我们也能在项目中愉快地使用上装饰器了。下面我们简要了解一下装饰器模式,装饰器是什么,js/ts有什么类型的装饰器,使用装饰器的好处是什么,最后再以几个我们项目中的装饰器实践作为结尾,给大家一点参考。

什么是装饰器模式?

装饰器模式,能够在不改变对象自身的基础上,在程序运行期间,动态加上职责,进包装现有的模块,使之“更加华丽”,并不影响现有的功能。装饰器模式,比继承相比,是一种更加灵活轻便的做法。

装饰器模式的作用是什么?

装饰器模式,主要面向切面编程,增加一种解耦的方式,解决只用继承增加额外职责导致子类膨胀的问题。在不修改原有的对象(接口),添加新的功能,使之表现更加友好。

装饰器模式,常用的场景有哪些?

  • 注入参数(提供默认参数,生成参数)
  • 预处理、后处理(例如配置上下文)
  • 记录函数行为(日志,缓存,计时,性能测试,事物处理等)

装饰器模式例子1

最常见的例子,在方法体的前后进行日志记录,即有一段逻辑代码,在代码开始需要写log,代码完成之后,需要写log,这样就会在一堆log代码中,淹没了我们的逻辑代码:

1
2
3
4
5
6
7
8
9
10
11
function doSomething1() {
console.log('start doSomething1');
// doSomething1
console.log('end doSomething1');
}

function doSomething2() {
console.log('start doSomething2');
// doSomething2
console.log('end doSomething2');
}

可以看出,代码重复率高,这个时候,我们应该考虑一个方法,把日志记录的功能逻辑抽离出来,保留代码的干净整洁。
显然,我们用装饰器模式,来解决这个问题,抽离日志逻辑:

1
2
3
4
5
6
7
function autoLog(func) {
return function () {
console.log(`start ${func.name}`);
func();
console.log(`end ${func.name}`);
}
}

功能代码为:

1
2
3
4
5
6
function doSomething1() {
// doSomething1
}
function doSomething2() {
// doSomething2
}

这时候,只需要给功能函数装饰上autoLog,就可以达到记录日志的目的:

1
2
autoLog(doSomething1);
autoLog(doSomething2);

对比之前的版本,抽离了耦合代码,原方法更加专注实际逻辑,反过来理解,装饰器模式在原逻辑上添加记录日志的功能,即符合之前说的仅包装现有的模块,使之“更加华丽”

装饰器模式例子2

再看一个场景,后台接口的登陆控制,权限控制,在进入接口主要逻辑的时候,需要先进行登陆判断,通常我们添加拦截器(中间件模式,后需要会简单讨论)进行权限验证。还有一种办法,就是增加一个登陆判断,于是添加判断:

1
2
3
if (!logined) {
return ctx.error(401, ''need login');
}

但是接口不可能是每个登陆用户都能访问的,这需要判断用户的权限字典,这样我们再增加一个判断:

1
2
3
4
5
6
if (!logined) {
return ctx.error(401, 'need login');
}
if (checkPermission(xxx)) {
return ctx.error(403, 'no promission');
}

这样会增加一个繁琐的问题,在编写接口的时候,每次都需要判断登陆与权限控制,上述伪代码省略了鉴权过程,实际代码结构会更复杂。这样开发人员除了要了解功能实现,还要了解权限控制的其他方面,增加开发繁琐度。鉴权,只是核心逻辑的一个切面,因此有必要抽离非功能实现的切面部分,使开发人员更加专注实际逻辑,而且减少出差错的概率。这时候可以用装饰器模式来解决。前面说了,也可以用中间件模式,后续再简单讨论。

1
2
3
4
5
6
7
8
9
10
11
function controllerFunc() {
// doControllerFunc
}
const finalFunc = checkLogin(checkPermisssion(xxx)(controllerFunc));
/*
@checkLogin
@checkPermission(xxx)
controllerFunc () {
// doControllerFunc
}
*/

可以看到,使用高级函数,可以达到装饰器的效果,但是缺点是需要手动赋值,看起来不优雅,这时候可以拿出我们的大杀器:
装饰器语法@Decorator

装饰器语法@Decorator

装饰器语法是es7的提案,长时间处于Stage2阶段,说明装饰器语法,基本完成了,但是仍允许有变化,根据提案所说,`装饰器是:

一个求值结果为函数的表达式,接受目标对象、名称和装饰器描述作为参数,可选地返回一个装饰器描述来安装到目标对象上。`

当前js/ts的装饰器有以下四种类型:

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

前面写了这么多,终于拿出ts来,从ts的角度,对以上四种类型进行初步了解:

1
2
3
4
5
6
7
// 类装饰器
declear type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
// 属性装饰器
declear type PropertyDecorator = (target: Object, propertyKey: string|symbol) => void;
// 方法装饰器
declear type MethodDecorator = <T>(target:Object, propertyKey: string|symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescrptor<T> | void;
declear type ParammetorDecorator = (target: Object,propertyKey: stirng|symbal, paramerterIndex: number) => void;

装饰器,可以装饰属性方法参数
我们考虑一些实际的场景来了解各类装饰器的用法。

类装饰器

1
2
// 类装饰器
declear type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;

根据类型定义,可以知道类装饰器接收类本身,并且可选地返回一个新的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
Class SomeBody {
speed: number = 100; //移动速度
name: string

constructor(name: string) {
this.name = name;
}
hit(rival: SomeBody) {
const hitDamage: number = 10; //拳头攻击身体要害
console.log(`{this.name}对${rival.name}造成一次伤害: ${hitDamage}`);
}
}

某些玩家会很无耻给他的游戏角色加上作弊器@cheating:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function cheating(target: any) {
target.prototype.hit = function(rival: SomeBody) {
const hitDamage: number = 100; //拳头攻击身体伤害
// 造成一次伤害,秒杀!
console.log(`${this.name}${rival.name}造成一次伤害: ${hitDamange}`);
}
}

@cheating
class SBody extends SomeBody {}

const s0 = new SomeBody('小蓝0');
const s1 = new SBody('小蓝1');
const rival = new SomeBody('小明');

s0.hit(rival);
s1.hit(rival);
// 小蓝0对小明造成一次伤害:10
// 小蓝1对小米造成一次伤害:100

这里保留了不适用作弊器的善良人们,被装饰器的目标是SomeBody的子类而不是Somebody,cheeting函数就是一个类装饰器,他修饰了目标类SBody的原型上的hit函数,使得SBody类的实例具有了一拳秒杀对手的能力。

有一个问题,装饰器是否可以带有参数呢?
还有一位更无耻的玩家,它在每次开局随意调整自己的速度,并且在调整成功后再微信发一条朋友圈:

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
const toWeixin = console.error

function cheating(speed: number = 200) {
return (target: any) => {
const oldTarget = target;
// 工具函数,生成类的实例
function factory(ctor, rest) {
const c: any = function() {
return ctor.apply(this, rest);
}
c.prototype = ctor.prototype;
return new c();
}
// 添加行为到构造器
const newTarget: any = function(...args) {
const instance = factory(oldTarget,args);
instance.speed = speed;
toWeixin(`绝地求生里我的角色${instance.name}开了移速挂:${speed},准备吃鸡了~`);
return instance;
}
// 指向原来的原型
const F:any = function () {}
F.prototype = oldTarget.prototype;
newTarget.prototype = new F();

// 修改方法
target.prototype.hit = function(rival: SomeBody) {
const hitDamage: number = 100; //拳头攻击身体伤害
// 造成一次伤害,秒杀
console.log(`${this.name}${rival.name}造成一次伤害:${hitDamage}`);
}
return newTarget;
}
}

@cheating(1000)
class SBodyS extends Somebody {}

const myHero = new SBodyS('Superman')

// 绝地求生里我的角色Superman开了移速挂: 1000,准备要吃鸡了~

这个例子比之前的复杂得多:

  1. 使用了decorator工厂函数–cheeting函数返回装饰函数,以达到带参数装饰器的语法目的。
  2. 保留了原始类的构造函数,生成新的类,并且这个类的实例,由一个工厂函数生成,在构造函数的中修改属性speed,并且做出额外的动作(发朋友圈)
  3. 新类指向原始类的原型,保留了原有类的大部分功能,最后修改方法,增强能力。
  4. 最后返回新类

通过@cheating(1000)装饰后的新类,移动速度修改为1000,同时拥有了一拳秒杀的能力,这里使用js,babel来实现使用return class extends SomeBody表达式,代码更加简洁。

由上述例子,类装饰器可以动态给构造函数添加额外的动作,或者新增,修改类的方法,灵活性很高。因此,类装饰器是比较常用的一种装饰器。

属性装饰器

1
2
// 属性装饰器
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void

从类型定义可以知道,属性装饰器接收目标类target和属性名称propertyKey两个参数,并没有返回值。

在typescript中,由于装饰阶段之只能访问类target和属性名称propertyKey两个参数,不能访问到实例this和目标的描述符descriptor,所以属性装饰器只能进行元数据的记录,如果需要进行更进一步操作,则需要借助一些hack方法,这里不进行赘述。
但是需要注意的是,js使用babel则有很大的不同,js babel中的属性装饰器的定义与方法定义相似,拥有第三个参数descriptor,并且需要返回descriptor,根据提案中来看,babel实现的行为似乎更为符合。

我们使用babel来编写例子, 用绝处逢生来做例子:

1
2
3
4
5
6
class Somebody {
leftArm: string = ''
rightArm: string = ''
constructor () {
}
}

我们希望ta在切换武器,捡起武器的时候,进行播报:

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
function announce (target, key, descriptor) {
let _val = descirptor.initializer;
const get = function () {
return _val;
}
const set = function (newVal) {
console.log(`切换武器 ${}`);
_val = newVal;
}
return {
get,
set,
enumerable: true,
configurable: true
}
}

class Somebody {
@announce
leftArm = ''
@announce
rightArm = ''
constructor () {
}
}

const s = new Somebody()
s.leftArm = 'arm';
s.rightArm = 'm416'
// 切换武器 akm
// 切换武器 m416

实际中,babel装饰的代码是使用Object.defineProperty来进行属性更改的并且通过修改getter与setter,来达到修饰属性的目的。

方法装饰器

1
2
declare type MethodDecorator = <T>(target:Object, propertyKey: )// 方法装饰器
declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;

根据类型定义可以知道方法装饰器接收目标类target、属性名称propertyKey和目标描述符三个参数,可选地返回描述符。可以看到它的入参与ES5的object.defineProperty方法的入参定义一致,这个是我们实际工程中使用地最多的装饰器类型。

我们这里就考虑之前的autoLog例子:

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 autoLog (target: any,
propertyKey: string,
descriptor: TypedPropertyDescriptor<any>): TypedPropertyDescriptor<any> {
const oldValue = descriptor.value

descriptor.value = function (...rest: any[]) {
console.log(`start ${func.name}`)
// 执行原方法
oldValue.apply(this, rest)
console.log(`end ${func.name}`)
}

return descriptor
}
}

@autoLog
function doSomeing1() {
// doSomeing1
console.log('doSomeing1')
}

@autoLog
function doSomeing2() {
// doSomeing2
console.log('doSomeing2')
}

这里主要就是先保留原方法,然后修改原方法,在原方法的前后加上功能,达到装饰方法得目的,可以看到,对比于之前的高阶函数编写方法,使用装饰器语法要优雅许多。需要提及的是,在descriptor的value中或getter/setter可以访问到类的实例this,因此方法装饰器的灵活性是相当强的。

同样的,使用decorator工厂函数(…rest) => MethodDecorator,可以实现带参方法数装饰器的目的。

参数装饰器

1
2
// 参数装饰器
declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;

根据类型定义可以知道方法装饰器接收目标类target、属性名称propertyKey和参数索引三个参数,无返回值。由于同样无法获取实例相关的信息,因此参数装饰器也是用于记录元数据信息,比较常见的有运行时参数校验:

1
2
3
4
5
6
7
8
9
10
11
12
class Somebody {
name: string

constructor (name) {
this.name = name
}

@validate
damageFrom(@required rival: Somebody) {
console.log(`damage from ${rival.name}`)
}
}

@required参数装饰器记录类方法中的参数的元数据在某处,再给方法加上@validate装饰器,在方法执行前访问这些元数据,并进行运行时校验。

可以看到,通过四种类型的装饰器语法,可以让我们在工程实现中有了更多的优雅方案选择,尤其是类装饰器与方法装饰器,效果是相当明显的。

装饰器顺序

装饰器是可以叠加执行的,如果有多个修饰器,会像剥洋葱一样,先从外到内进入,然后由内向外执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function decorator (id) {
console.log('进入', id)
return (target, property, descriptor) => console.log('装饰', id)
}

class Example {
@decorator(1)
@decorator(2)
@decorator(3)
justDoIt () {
}
}

// 进入 1
// 进入 2
// 进入 3
// 装饰 3
// 装饰 2
// 装饰 1

常用装饰器

下面我们再简单举几个常用的装饰器:

time

输出函数执行时间

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
class Example1 {
dowithTime () {
console.time('do')
...
console.timeEnd('do')
}
}

function time (tag: string) {
return (target: any,
propertyKey: string,
descriptor: TypedPropertyDescriptor<any>) => {
const oldValue = descriptor.value

descriptor.value = function (...rest: any[]) {
console.time(tag)
oldValue.apply(this, rest)
console.timeEnd(tag)
}

return descriptor
}
}

class Example2 {
@time('do')
do () {
...
}
}

autobind

JSX 回调函数中的 this,类的方法默认是不会绑定 this 的,可以使用autobind装饰器

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
function autobind(target, key, { value: fn, configurable, enumerable }) {
if (typeof fn !== 'function') {
throw new SyntaxError(`@autobind can only be used on functions, not: ${fn}`)
}

const { constructor } = target

return {
configurable,
enumerable,
get() {
if (this === target) {
return fn
}
if (this.constructor !== constructor && getPrototypeOf(this).constructor === constructor) {
return fn
}

if (this.constructor !== constructor && key in this.constructor.prototype) {
return getBoundSuper(this, fn)
}

const boundFn = bind(fn, this)

defineProperty(this, key, {
configurable: true,
writable: true,
enumerable: false,
value: boundFn
})

return boundFn;
},
set: createDefaultSetter(key)
}
}

class SignUpDialog extends React.Component {
constructor (props) {
super(props)
this.state = {login: ''}
}

render() {
return (
<Dialog title="Mars Exploration Program"
message="How should we refer to you?">
<input value={this.state.login}
onChange={this.handleChange} />

<button onClick={this.handleSignUp}>
Sign Me Up!
</button>
</Dialog>
);
}

@autobind
handleChange (e) {
this.setState({login: e.target.value})
}

@autobind
handleSignUp () {
alert(`Welcome aboard, ${this.state.login}!`)
}
}

这样就不需要在构造函数中或者render函数中手动bind this。

更多的常用装饰器

更多的常用装饰器,如@readonly、@throttle、@debounce、@memoize 等等,可以查看

一些实践

当然,在了解到装饰器的好处后,我们在项目中也运用了装饰器来完成我们的需求,下面举两个比较典型的例子。

爬虫request重试

在爬虫需求中,单个爬取请求有可能会出现爬取失败的情况,由于我们处理的是简单的爬虫需求,不同于一些常用的爬虫框架使用庞大的中间件系统,我们可以使用装饰器来添加简单的重试机制,并提供超过重试次数后的错误处理兜底钩子,较完善地处理请求失败的情况:

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
function retry (options: RetryOptions = { times: 3, delay: 100 }) {
return (target, key, descriptor) => {
const {
times: RUN_TIMES,
delay: DELAY_TIME,
onBeforeRetry,
onFallback
} = options
const originalMethod = descriptor.value
descriptor.value = async function (...args) {
let times = 1
while (1) {
try {
return await originalMethod.apply(this, args)
} catch (err) {
console.error(err)
times++
if (times <= RUN_TIMES) {
await timeout(DELAY_TIME)
if (onBeforeRetry) {
onBeforeRetry()
}
} else {
if (onFallback) {
onFallback(err)
} else {
throw err
}
}
}
}
}
return descriptor
}
}

这个重试装饰器也可以用于其他可重试场景中。

定义后台接口@route

在开发node后台系统时,可以借鉴java的spring、python的flask等框架,使用装饰器模式来编写后台接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@route(`
/api/v1/user/{id}:
get:
summary: 获取指定用户的详细信息
tags:
- user
parameters:
- in: path
name: id
description: 用户id
schema:
type: integer
minimum: 1
required: true
responses:
200:
description: ok
`)
@someLocalMiddleaware(arg)
async show () {
this.success(await this.service.user.findOne(this.ctx.params.id))
}

我们编写了route装饰器,该装饰器通过传入swagger定义字符串和一个可选的额外的options参数,装饰在控制器方法之上,同时实现了路由挂载swagger文档定义控制器参数校验权限控制日志记录功能:

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
export function route (apiSpec: ApiSpec, routeOption?: RouteOption) {

return function (target: any,
propertyKey: string,
descriptor: TypedPropertyDescriptor<any>): TypedPropertyDescriptor<any> {
// 收集接口级别的中间件,添加路由
...

// 添加swagger定义
...

const oldValue = descriptor.value
descriptor.value = async function (...rest: any[]): Promise<any> {
// 检查登录
...

// 检查权限
...

// 根据swagger定义校验请求参数
...

// 日志记录
...

// 执行控制器方法
return oldValue.apply(this, rest)
}

return descriptor
}
}

较好地分离了重复逻辑(切面),使得控制器逻辑十分精简,开发接口只需要专注实质逻辑。

在这里,可以看到我们使用了@someLocalMiddleaware(arg)来定义接口级别的中间件,我们的实现是装饰器执行阶段保存了中间件函数到一个元数据存储中心,根据装饰器执行顺序,在最后执行的route装饰器中,收集接口级别的中间件,挂载路由(该接口的中间件和最终处理逻辑方法),当然这里也可以使用修改descriptor.value来实现中间件:

1
2
3
4
5
6
7
descriptor.value = async function (...rest: any[]): Promise<any> {
// before

await oldValue.apply(this, rest)

// after
}

装饰器模式VS中间件模式

前面我们有说过,开发接口时,我们既可以使用中间件来实现逻辑抽离,也可以使用装饰器模式来实现。那么它们两者的异同是什么呢?我提供一些我的看法。首先,装饰器与中间件实质都是面向切面编程(AOP)的手段,可以将整体逻辑的一些切面部分抽离出来封装,使得核心代码更简洁、耦合度低。

而不同点在于,中间件更适用于集中配置,对与开发接口来说,我们往往有一些每一个接口都需要配置的中间件,比如记录日志,这些就可以试用中间件模式给所有接口集中配置中间件;装饰器模式则更加试用于分散配置,虽然它将逻辑集中处理了,但是它的装饰操作却是分散于各个目标之上的,并且,由于装饰在目标之上,我们可以很清晰的知道该接口拥有什么样的中间件,在对该接口做定制处理时也更加方便,对比于通常koa挂载路由时使用另一个文件来配置,我认为装饰器的做法更为清晰友好。

因此在接口开发中,我们折中使用两种模式,统一的逻辑我们使用中间件来处理,而接口级别的中间件我们使用装饰器来处理。

总结

装饰器语法可以很优雅地实现各种实用方便地功能,当前前端领域已经有很多框架和库都已经大规模使用了这个语法糖,可以预见装饰器语法一定会成为js/ts的一个重要的语言特性。