视频1 视频21 视频41 视频61 视频文章1 视频文章21 视频文章41 视频文章61 推荐1 推荐3 推荐5 推荐7 推荐9 推荐11 推荐13 推荐15 推荐17 推荐19 推荐21 推荐23 推荐25 推荐27 推荐29 推荐31 推荐33 推荐35 推荐37 推荐39 推荐41 推荐43 推荐45 推荐47 推荐49 关键词1 关键词101 关键词201 关键词301 关键词401 关键词501 关键词601 关键词701 关键词801 关键词901 关键词1001 关键词1101 关键词1201 关键词1301 关键词1401 关键词1501 关键词1601 关键词1701 关键词1801 关键词1901 视频扩展1 视频扩展6 视频扩展11 视频扩展16 文章1 文章201 文章401 文章601 文章801 文章1001 资讯1 资讯501 资讯1001 资讯1501 标签1 标签501 标签1001 关键词1 关键词501 关键词1001 关键词1501 专题2001
Javascript装饰器的用法
2020-11-27 19:34:08 责编:小采
文档


因为@符号后边跟的是一个函数的引用,所以对于mixin的实现,我们可以很轻易的使用闭包来实现:

class A { say() { return 1 } }
class B { hi() { return 2 } }

@mixin(A, B)
class C { }

function mixin(...args) {
 // 调用函数返回装饰器实际应用的函数
 return function(constructor) {
 for (let arg of args) {
 for (let key of Object.getOwnPropertyNames(arg.prototype)) {
 if (key === 'constructor') continue // 跳过构造函数
 Object.defineProperty(constructor.prototype, key, Object.getOwnPropertyDescriptor(arg.prototype, key))
 }
 }
 }
}

let c = new C()
console.log(c.say(), c.hi()) // 1, 2

多个装饰器的应用

装饰器是可以同时应用多个的(不然也就失去了最初的意义)。
用法如下:

@decorator1
@decorator2
class { }

执行的顺序为decorator2 -> decorator1,离class定义最近的先执行。
可以想像成函数嵌套的形式:

decorator1(decorator2(class {}))

@Decorator 在 Class 成员中的使用

类成员上的 @Decorator 应该是应用最为广泛的一处了,函数,属性,getset访问器,这几处都可以认为是类成员。
在TS文档中被分为了Method DecoratorAccessor DecoratorProperty Decorator,实际上如出一辙。

关于这类装饰器,会接收如下三个参数:

  1. 如果装饰器挂载于静态成员上,则会返回构造函数,如果挂载于实例成员上则会返回类的原型

  2. 装饰器挂载的成员名称

  3. 成员的描述符,也就是Object.getOwnPropertyDescriptor的返回值

Property Decorator不会返回第三个参数,但是可以自己手动获取
前提是静态成员,而非实例成员,因为装饰器都是运行在类创建时,而实例成员是在实例化一个类的时候才会执行的,所以没有办法获取对应的descriptor

静态成员与实例成员在返回值上的区别

可以稍微明确一下,静态成员与实例成员的区别:

class Model {
 // 实例成员
 method1 () {}
 method2 = () => {}

 // 静态成员
 static method3 () {}
 static method4 = () => {}
}

method1method2是实例成员,method1存在于prototype之上,而method2只在实例化对象以后才有。
作为静态成员的method3method4,两者的区别在于是否可枚举描述符的设置,所以可以简单地认为,上述代码转换为ES5版本后是这样子的:

function Model () {
 // 成员仅在实例化时赋值
 this.method2 = function () {}
}

// 成员被定义在原型链上
Object.defineProperty(Model.prototype, 'method1', {
 value: function () {}, 
 writable: true, 
 enumerable: false, // 设置不可被枚举
 configurable: true
})

// 成员被定义在构造函数上,且是默认的可被枚举
Model.method4 = function () {}

// 成员被定义在构造函数上
Object.defineProperty(Model, 'method3', {
 value: function () {}, 
 writable: true, 
 enumerable: false, // 设置不可被枚举
 configurable: true
})

可以看出,只有method2是在实例化时才赋值的,一个不存在的属性是不会有descriptor的,所以这就是为什么TS在针对Property Decorator不传递第三个参数的原因,至于为什么静态成员也没有传递descriptor,目前没有找到合理的解释,但是如果明确的要使用,是可以手动获取的。

就像上述的示例,我们针对四个成员都添加了装饰器以后,method1method2第一个参数就是Model.prototype,而method3method4的第一个参数就是Model

class Model {
 // 实例成员
 @instance
 method1 () {}
 @instance
 method2 = () => {}

 // 静态成员
 @static
 static method3 () {}
 @static
 static method4 = () => {}
}

function instance(target) {
 console.log(target.constructor === Model)
}

function static(target) {
 console.log(target === Model)
}

函数,访问器,和属性装饰器三者之间的区别

函数

首先是函数,函数装饰器的返回值会默认作为属性的value描述符存在,如果返回值为undefined则会忽略,使用之前的descriptor引用作为函数的描述符。
所以针对我们最开始的统计耗时的逻辑可以这么来做:

class Model {
 @log1
 getData1() {}
 @log2
 getData2() {}
}

// 方案一,返回新的value描述符
function log1(tag, name, descriptor) {
 return {
 ...descriptor,
 value(...args) {
 let start = new Date().valueOf()
 try {
 return descriptor.value.apply(this, args)
 } finally {
 let end = new Date().valueOf()
 console.log(`start: ${start} end: ${end} consume: ${end - start}`)
 }
 }
 }
}

// 方案二、修改现有描述符
function log2(tag, name, descriptor) {
 let func = descriptor.value // 先获取之前的函数

 // 修改对应的value
 descriptor.value = function (...args) {
 let start = new Date().valueOf()
 try {
 return func.apply(this, args)
 } finally {
 let end = new Date().valueOf()
 console.log(`start: ${start} end: ${end} consume: ${end - start}`)
 }
 }
}

访问器

访问器就是添加有getset前缀的函数,用于控制属性的赋值及取值操作,在使用上与函数没有什么区别,甚至在返回值的处理上也没有什么区别。
只不过我们需要按照规定设置对应的get或者set描述符罢了:

class Modal {
 _name = 'Niko'

 @prefix
 get name() { return this._name }
}

function prefix(target, name, descriptor) {
 return {
 ...descriptor,
 get () {
 return `wrap_${this._name}`
 }
 }
}

console.log(new Modal().name) // wrap_Niko

属性

对于属性的装饰器,是没有返回descriptor的,并且装饰器函数的返回值也会被忽略掉,如果我们想要修改某一个静态属性,则需要自己获取descriptor

class Modal {
 @prefix
 static name1 = 'Niko'
}

function prefix(target, name) {
 let descriptor = Object.getOwnPropertyDescriptor(target, name)

 Object.defineProperty(target, name, {
 ...descriptor,
 value: `wrap_${descriptor.value}`
 })
}

console.log(Modal.name1) // wrap_Niko

对于一个实例的属性,则没有直接修改的方案,不过我们可以结合着一些其他装饰器来曲线救国。

比如,我们有一个类,会传入姓名和年龄作为初始化的参数,然后我们要针对这两个参数设置对应的格式校验:

const validateConf = {} // 存储校验信息

@validator
class Person {
 @validate('string')
 name
 @validate('number')
 age

 constructor(name, age) {
 this.name = name
 this.age = age
 }
}

function validator(constructor) {
 return class extends constructor {
 constructor(...args) {
 super(...args)

 // 遍历所有的校验信息进行验证
 for (let [key, type] of Object.entries(validateConf)) {
 if (typeof this[key] !== type) throw new Error(`${key} must be ${type}`)
 }
 }
 }
}

function validate(type) {
 return function (target, name, descriptor) {
 // 向全局对象中传入要校验的属性名及类型
 validateConf[name] = type
 }
}

new Person('Niko', '18') // throw new error: [age must be number]

首先,在类上边添加装饰器@validator,然后在需要校验的两个参数上添加@validate装饰器,两个装饰器用来向一个全局对象传入信息,来记录哪些属性是需要进行校验的。
然后在validator中继承原有的类对象,并在实例化之后遍历刚才设置的所有校验信息进行验证,如果发现有类型错误的,直接抛出异常。
这个类型验证的操作对于原Class来说几乎是无感知的。

函数参数装饰器

最后,还有一个用于函数参数的装饰器,这个装饰器也是像实例属性一样的,没有办法单独使用,毕竟函数是在运行时调用的,而无论是何种装饰器,都是在声明类时(可以认为是伪编译期)调用的。

函数参数装饰器会接收三个参数:

  1. 类似上述的操作,类的原型或者类的构造函数

  2. 参数所处的函数名称

  3. 参数在函数中形参中的位置(函数签名中的第几个参数)

一个简单的示例,我们可以结合着函数装饰器来完成对函数参数的类型转换:

const parseConf = {}
class Modal {
 @parseFunc
 addOne(@parse('number') num) {
 return num + 1
 }
}

// 在函数调用前执行格式化操作
function parseFunc (target, name, descriptor) {
 return {
 ...descriptor,
 value (...arg) {
 // 获取格式化配置
 for (let [index, type] of parseConf) {
 switch (type) {
 case 'number': arg[index] = Number(arg[index]) break
 case 'string': arg[index] = String(arg[index]) break
 case 'boolean': arg[index] = String(arg[index]) === 'true' break
 }

 return descriptor.value.apply(this, arg)
 }
 }
 }
}

// 向全局对象中添加对应的格式化信息
function parse(type) {
 return function (target, name, index) {
 parseConf[index] = type
 }
}

console.log(new Modal().addOne('10')) // 11

使用装饰器实现一个有趣的Koa封装

比如在写Node接口时,可能是用的koa或者express,一般来说可能要处理很多的请求参数,有来自headers的,有来自body的,甚至有来自querycookie的。
所以很有可能在router的开头数行都是这样的操作:

router.get('/', async (ctx, next) => {
 let id = ctx.query.id
 let uid = ctx.cookies.get('uid')
 let device = ctx.header['device']
})

以及如果我们有大量的接口,可能就会有大量的router.getrouter.post
以及如果要针对模块进行分类,可能还会有大量的new Router的操作。

这些代码都是与业务逻辑本身无关的,所以我们应该尽可能的简化这些代码的占比,而使用装饰器就能够帮助我们达到这个目的。

装饰器的准备

// 首先,我们要创建几个用来存储信息的全局List
export const routerList = []
export const controllerList = []
export const parseList = []
export const paramList = []

// 虽说我们要有一个能够创建Router实例的装饰器
// 但是并不会直接去创建,而是在装饰器执行的时候进行一次注册
export function Router(basename = '') {
 return (constrcutor) => {
 routerList.push({
 constrcutor,
 basename
 })
 }
}

// 然后我们在创建对应的Get Post请求监听的装饰器
// 同样的,我们并不打算去修改他的任何属性,只是为了获取函数的引用
export function Method(type) {
 return (path) => (target, name, descriptor) => {
 controllerList.push({
 target,
 type,
 path,
 method: name,
 controller: descriptor.value
 })
 }
}

// 接下来我们还需要用来格式化参数的装饰器
export function Parse(type) {
 return (target, name, index) => {
 parseList.push({
 target,
 type,
 method: name,
 index
 })
 }
}

// 以及最后我们要处理的各种参数的获取
export function Param(position) {
 return (key) => (target, name, index) => {
 paramList.push({
 target,
 key,
 position,
 method: name,
 index
 })
 }
}

export const Body = Param('body')
export const Header = Param('header')
export const Cookie = Param('cookie')
export const Query = Param('query')
export const Get = Method('get')
export const Post = Method('post')

Koa服务的处理

上边是创建了所有需要用到的装饰器,但是也仅仅是把我们所需要的各种信息存了起来,而怎么利用这些装饰器则是下一步需要做的事情了:

const routers = []

// 遍历所有添加了装饰器的Class,并创建对应的Router对象
routerList.forEach(item => {
 let { basename, constrcutor } = item
 let router = new Router({
 prefix: basename
 })

 controllerList
 .filter(i => i.target === constrcutor.prototype)
 .forEach(controller => {
 router[controller.type](controller.path, async (ctx, next) => {
 let args = []
 // 获取当前函数对应的参数获取
 paramList
 .filter( param => param.target === constrcutor.prototype && param.method === controller.method )
 .map(param => {
 let { index, key } = param
 switch (param.position) {
 case 'body': args[index] = ctx.request.body[key] break
 case 'header': args[index] = ctx.headers[key] break
 case 'cookie': args[index] = ctx.cookies.get(key) break
 case 'query': args[index] = ctx.query[key] break
 }
 })

 // 获取当前函数对应的参数格式化
 parseList
 .filter( parse => parse.target === constrcutor.prototype && parse.method === controller.method )
 .map(parse => {
 let { index } = parse
 switch (parse.type) {
 case 'number': args[index] = Number(args[index]) break
 case 'string': args[index] = String(args[index]) break
 case 'boolean': args[index] = String(args[index]) === 'true' break
 }
 })

 // 调用实际的函数,处理业务逻辑
 let results = controller.controller(...args)

 ctx.body = results
 })
 })

 routers.push(router.routes())
})

const app = new Koa()

app.use(bodyParse())
app.use(compose(routers))

app.listen(12306, () => console.log('server run as http://127.0.0.1:12306'))

上边的代码就已经搭建出来了一个Koa的封装,以及包含了对各种装饰器的处理,接下来就是这些装饰器的实际应用了:

import { Router, Get, Query, Parse } from "../decorators"

@Router('')
export default class {
 @Get('/')
 index (@Parse('number') @Query('id') id: number) {
 return {
 code: 200,
 id,
 type: typeof id
 }
 }

 @Post('/detail')
 detail (
 @Parse('number') @Query('id') id: number, 
 @Parse('number') @Body('age') age: number
 ) {
 return {
 code: 200,
 age: age + 1
 }
 }
}

很轻易的就实现了一个router的创建,路径、method的处理,包括各种参数的获取,类型转换。
将各种非业务逻辑相关的代码统统交由装饰器来做,而函数本身只负责处理自身逻辑即可。
这里有完整的代码:GitHub。安装依赖后npm start即可看到效果。

这样开发带来的好处就是,让代码可读性变得更高,在函数中更专注的做自己应该做的事情。
而且装饰器本身如果名字起的足够好的好,也是在一定程度上可以当作文档注释来看待了(Java中有个类似的玩意儿叫做注解)。

总结

合理利用装饰器可以极大的提高开发效率,对一些非逻辑相关的代码进行封装提炼能够帮助我们快速完成重复性的工作,节省时间。
但是糖再好吃,也不要吃太多,容易坏牙齿的,同样的滥用装饰器也会使代码本身逻辑变得扑朔迷离,如果确定一段代码不会在其他地方用到,或者一个函数的核心逻辑就是这些代码,那么就没有必要将它取出来作为一个装饰器来存在。

下载本文
显示全文
专题