第一次听说装饰器是在多年前玩python的时候,在python的众多web框架中都大量使用了装饰器来抽象功能逻辑或注入元数据,代码耦合度低且相当优雅。鉴于装饰器在python中大显身手,js也从中借鉴并提出了装饰器提案,结合babel或者使用typescript,就能让我们也能在项目中愉快地使用上装饰器了。下面我们简要了解一下装饰器模式,装饰器是什么,js/ts有什么类型的装饰器,使用装饰器的好处是什么,最后再以几个我们项目中的装饰器实践作为结尾,给大家一点参考。
装饰器模式能够在不改变对象自身的基础上,在程序运行期间给对像动态的添加职责;装饰器仅仅包装现有的模块,使之 “更加华丽” ,并不会影响原有的功能;并且装饰器模式与继承相比,是一种更轻便灵活的做法。
装饰器模式就是要在不修改原有对象(接口)的情况下让其表现得更好,例如:
装饰器模式的主要作用就是面向切面编程,增加一种解耦的角度,解决只用继承增加额外职责导致子类膨胀的问题。它的常用场景有且不局限于:
最简单的例子就是给一段逻辑前后进行日志记录,即有一段逻辑代码要写,在这段代码之前要写log,代码完成之后要写log,这样,结局就是有一大堆的log代码淹没了我们的逻辑代码:
function doSomething1 () {
console.log('start doSomething1')
// doSomething1
...
console.log('end doSomething1')
}
function doSomething2 () {
console.log('start doSomething2')
// doSomething2
...
console.log('end doSomething2')
}
代码重复率高。这个时候,我们就应该考虑一个方法,把记录日志功能逻辑给抽离出来,保留功能代码的干净整洁。
显然,我们可以使用装饰器模式来解决这个问题,抽离日志逻辑:
function autoLog (func) {
return function () {
console.log(`start ${func.name}`)
func()
console.log(`end ${func.name}`)
}
}
功能代码为:
function doSomething1 () {
// doSomething1
...
}
function doSomething2 () {
// doSomething2
...
}
只需要给功能函数装饰上autoLog即可达到记录日志目的:
autoLog(doSomething1)()
autoLog(doSomething2)()
对比之前的版本,抽离了耦合代码,原方法更专注实质逻辑。反过来理解,我们也可以看到,装饰器模式在原有逻辑上添加了记录日志功能,即符合之前所说的仅包装现有的模块,使之 “更加华丽”
。
再来看看一个常见的例子:后台接口的登录控制、权限控制逻辑。我们在开发后台接口时,在进入接口主要逻辑的时候都需要进行登录判断,通常我们可以添加一个判断:
if (!logined) {
return ctx.error(401, 'need login')
}
但是接口不可能是每一个登录的用户都能访问的,这就需要判断用户的权限字典,这时我们可以再添加一个判断:
if (!logined) {
return ctx.error(401, 'need login')
}
if (!checkPermission(XXX)) {
return ctx.error(403, 'no permission')
}
但是这就会出现一个问题,在编写接口的时候,每次都需要添加登录判断和权限控制,使得代码相当繁琐,并且上述伪代码其实省略了鉴权过程,实际情况代码结构会更复杂,这样功能开发人员除了要了解功能实现,还要了解权限控制等其他方面,增加了开发繁琐度,换一句话说,鉴权只是干扰核心代码的一个方面(切面),因此有必要抽离非功能实现的切面部分,使得开发人员更专注于实质逻辑。这个时候就可以用上装饰器模式来解决(也可以用中间件模式,后续会有简单讨论):
function controllerFunc () {
}
const finalFunc = checkLogin(checkPermission(XXX)(controllerFunc))
/*
@checkLogin
@checkPermission(XXX)
controllerFunc () {
...
}
*/
可以看到,使用高阶函数可以达到装饰器模式的效果,但是缺点是需要重新手动赋值,看起来不优雅,这个时候就需要祭出我们的大杀器了:装饰器语法@Decorator。
装饰器语法是ES7的提案,长时间处于Stage2阶段,说明装饰器语法基本上完成了但是仍允许有改变,根据提案所说,装饰器是:
一个求值结果为函数的表达式,接受目标对象、名称和装饰器描述作为参数,可选地返回一个装饰器描述来安装到目标对象上。
当前js/ts中装饰器有以下四种类型:
我们可以主要从typescript对它们的定义进行初步的了解:
// 类装饰器
declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void
// 属性装饰器
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;
// 方法装饰器
declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;
// 参数装饰器
declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;
即装饰器可以装饰类、属性、方法和参数。让我们考虑一些简单的场景来简要了解各类型装饰器。
// 类装饰器
declare type ClassDecorator = (target: TFunction) => TFunction | void
根据类型定义可以知道类装饰器接收类本身,并可选地返回一个新的构造函数。
考虑绝地求生中的角色:
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:
function cheating (target: any) {
target.prototype.hit = function (rival: Somebody) {
const hitDamage: number = 100 // 拳头攻击身体伤害
// 造成一次伤害,秒杀!
console.log(`${this.name}对${rival.name}造成一次伤害: ${hitDamage}`)
}
}
@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的子类SBody而不是Somebody,cheeting函数就是一个类装饰器,它修饰了目标类SBody的原型上的hit函数,使得SBody类的实例具有了一拳秒杀对手的能力。
有一个问题,装饰器是否可以带有参数呢?答案是肯定的,我们考虑下面这位更无耻的玩家,它可以在每次开局随意调整自己的速度,并且在调整成功时给微信发一条朋友圈:
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,准备要吃鸡了~
这个例子比之前的要复杂地多:
通过cheating(1000)装饰后新类,移动速度修改为1000,同时也拥有了一拳秒杀的能力。这里使用js、babel来实现可以使用return class extends Somebody表达式,代码更简洁。
由上述例子我们可以知道,类装饰器可以动态给构造函数添加额外的动作或者新增、修改类的方法,灵活性很高,因此是比较常用的一种装饰器。
// 属性装饰器
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来编写例子,同样考虑绝地求生中的角色:
class Somebody {
leftArm: string = ''
rightArm: string = ''
constructor () {
}
}
我们希望ta在切换武器或者捡武器时自动播报:
function announce (target, key, descriptor) {
// 存储属性值
let _val = descriptor.initializer
const get = function () {
return _val
}
const set = function (newVal) {
console.log(`切换武器 ${newVal}`)
_val = newVal
}
return {
get,
set,
enumerable: true,
configurable: true
}
}
class Somebody {
@announce
leftArm = ''
@announce
rightArm = ''
constructor () {
}
}
const s = new Somebody()
s.leftArm = 'akm'
s.rightArm = 'm416'
// 切换武器 akm
// 切换武器 m416
实际中babel装换的代码是使用Object.defineProperty来进行属性修改的,通过修改getter、setter,来达到装饰属性的目的。
// 方法装饰器
declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;
根据类型定义可以知道方法装饰器接收目标类target、属性名称propertyKey和目标描述符三个参数,可选地返回描述符。可以看到它的入参与ES5的object.defineProperty方法的入参定义一致,这个是我们实际工程中使用地最多的装饰器类型。
我们这里就考虑之前的autoLog例子:
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,可以实现带参方法数装饰器的目的。
// 参数装饰器
declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;
根据类型定义可以知道方法装饰器接收目标类target、属性名称propertyKey和参数索引三个参数,无返回值。由于同样无法获取实例相关的信息,因此参数装饰器也是用于记录元数据信息,比较常见的有运行时参数校验:
class Somebody {
name: string
constructor (name) {
this.name = name
}
@validate
damageFrom(@required rival: Somebody) {
console.log(`damage from ${rival.name}`)
}
}
@required参数装饰器记录类方法中的参数的元数据在某处,再给方法加上@validate装饰器,在方法执行前访问这些元数据,并进行运行时校验。
可以看到,通过四种类型的装饰器语法,可以让我们在工程实现中有了更多的优雅方案选择,尤其是类装饰器与方法装饰器,效果是相当明显的。
装饰器是可以叠加执行的,如果有多个修饰器,会像剥洋葱一样,先从外到内进入,然后由内向外执行:
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
下面我们再简单举几个常用的装饰器:
输出函数执行时间
class Example1 {
dowithTime () {
console.time('do')
...
console.timeEnd('do')
}
}
function time (tag: string) {
return (target: any,
propertyKey: string,
descriptor: TypedPropertyDescriptor) => {
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 () {
...
}
}
JSX 回调函数中的 this,类的方法默认是不会绑定 this 的,可以使用autobind装饰器
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 (
);
}
@autobind
handleChange (e) {
this.setState({login: e.target.value})
}
@autobind
handleSignUp () {
alert(`Welcome aboard, ${this.state.login}!`)
}
}
这样就不需要在构造函数中或者render函数中手动bind this。
更多的常用装饰器,如@readonly、@throttle、@debounce、@memoize 等等,可以查看
当然,在了解到装饰器的好处后,我们在项目中也运用了装饰器来完成我们的需求,下面举两个比较典型的例子。
在爬虫需求中,单个爬取请求有可能会出现爬取失败的情况,由于我们处理的是简单的爬虫需求,不同于一些常用的爬虫框架使用庞大的中间件系统,我们可以使用装饰器来添加简单的重试机制,并提供超过重试次数后的错误处理兜底钩子,较完善地处理请求失败的情况:
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
}
}
这个重试装饰器也可以用于其他可重试场景中。
在开发node后台系统时,可以借鉴java的spring、python的flask等框架,使用装饰器模式来编写后台接口:
@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文档定义、控制器参数校验、权限控制、日志记录功能:
export function route (apiSpec: ApiSpec, routeOption?: RouteOption) {
return function (target: any,
propertyKey: string,
descriptor: TypedPropertyDescriptor): TypedPropertyDescriptor {
// 收集接口级别的中间件,添加路由
...
// 添加swagger定义
...
const oldValue = descriptor.value
descriptor.value = async function (...rest: any[]): Promise {
// 检查登录
...
// 检查权限
...
// 根据swagger定义校验请求参数
...
// 日志记录
...
// 执行控制器方法
return oldValue.apply(this, rest)
}
return descriptor
}
}
较好地分离了重复逻辑(切面),使得控制器逻辑十分精简,开发接口只需要专注实质逻辑。
在这里,可以看到我们使用了@someLocalMiddleaware(arg)来定义接口级别的中间件,我们的实现是装饰器执行阶段保存了中间件函数到一个元数据存储中心,根据装饰器执行顺序,在最后执行的route装饰器中,收集接口级别的中间件,挂载路由(该接口的中间件和最终处理逻辑方法),当然这里也可以使用修改descriptor.value来实现中间件:
descriptor.value = async function (...rest: any[]): Promise {
// before
await oldValue.apply(this, rest)
// after
}
前面我们有说过,开发接口时,我们既可以使用中间件来实现逻辑抽离,也可以使用装饰器模式来实现。那么它们两者的异同是什么呢?我提供一些我的看法。首先,装饰器与中间件实质都是面向切面编程(AOP)的手段,可以将整体逻辑的一些切面部分抽离出来封装,使得核心代码更简洁、耦合度低。
而不同点在于,中间件更适用于集中配置,对与开发接口来说,我们往往有一些每一个接口都需要配置的中间件,比如记录日志,这些就可以试用中间件模式给所有接口集中配置中间件;装饰器模式则更加试用于分散配置,虽然它将逻辑集中处理了,但是它的装饰操作却是分散于各个目标之上的,并且,由于装饰在目标之上,我们可以很清晰的知道该接口拥有什么样的中间件,在对该接口做定制处理时也更加方便,对比于通常koa挂载路由时使用另一个文件来配置,我认为装饰器的做法更为清晰友好。
因此在接口开发中,我们折中使用两种模式,统一的逻辑我们使用中间件来处理,而接口级别的中间件我们使用装饰器来处理。
装饰器语法可以很优雅地实现各种实用方便地功能,当前前端领域已经有很多框架和库都已经大规模使用了这个语法糖,可以预见装饰器语法一定会成为js/ts的一个重要的语言特性。