Javascript装潢器的妙用

近来新开了一个Node项目,采纳TypeScript来开辟,在数据库及路由治理方面用了不少的装潢器,觉察这的确是一个好东西。
装潢器是一个还处于草案中的特征,如今木有直接支撑该语法的环境,然则可以经由过程 babel 之类的举行转换为旧语法来完成结果,所以在TypeScript中,可以宁神的运用@Decorator

什么是装潢器

装潢器是对类、函数、属性之类的一种装潢,可以针对其增加一些分外的行动。
浅显的明白可以以为就是在原有代码外层包装了一层处置惩罚逻辑。
个人以为装潢器是一种解决计划,而并非是狭义的@Decorator,后者仅仅是一个语法糖罢了。

装潢器在身旁的例子随处可见,一个简朴的例子,水龙头上边的起泡器就是一个装潢器,在装上今后就会把氛围混入水流中,搀杂许多泡泡在水里。
然则起泡器装置与否对水龙头本身并没有什么影响,纵然拆掉起泡器,也会还是事变,水龙头的作用在于阀门的掌握,至于水中掺不搀杂气泡则不是水龙头须要体贴的。

所以,关于装潢器,可以简朴地明白为黑白侵入式的行动修正。

为何要用装潢器

可以有些时刻,我们会对传入参数的范例推断、对返回值的排序、过滤,对函数增加撙节、防抖或其他的功用性代码,基于多个类的继续,林林总总的与函数逻辑本身无关的、反复性的代码。

函数中的作用

可以想像一下,我们有一个东西类,供应了一个猎取数据的函数:

class Model1 {
  getData() {
    // 此处省略猎取数据的逻辑
    return [{
      id: 1,
      name: 'Niko'
    }, {
      id: 2,
      name: 'Bellic'
    }]
  }
}

console.log(new Model1().getData())     // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]
console.log(Model1.prototype.getData()) // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]

如今我们想要增加一个功用,纪录该函数实行的耗时。
由于这个函数被许多人运用,在挪用方增加耗时统计逻辑是不可取的,所以我们要在Model1中举行修正:

class Model1 {
  getData() {
+   let start = new Date().valueOf()
+   try {
      // 此处省略猎取数据的逻辑
      return [{
        id: 1,
        name: 'Niko'
      }, {
        id: 2,
        name: 'Bellic'
      }]
+   } finally {
+     let end = new Date().valueOf()
+     console.log(`start: ${start} end: ${end} consume: ${end - start}`)
+   }
  }
}

// start: XXX end: XXX consume: XXX
console.log(new Model1().getData())     // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]
// start: XXX end: XXX consume: XXX
console.log(Model1.prototype.getData()) // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]

如许在挪用要领后我们就可以在掌握台看到耗时的输出了。
然则如许直接修正原函数代码有以下几个问题:

  1. 统计耗时的相干代码与函数本身逻辑并没有一点关联,影响到了对原函数本身的明白,对函数组织造成了破坏性的修正
  2. 假如后期另有更多相似的函数须要增加统计耗时的代码,在每一个函数中都增加如许的代码显然是低效的,保护本钱太高

所以,为了让统计耗时的逻辑变得越发天真,我们将建立一个新的东西函数,用来包装须要设置统计耗时的函数。
经由过程将Class与目标函数的name通报到函数中,完成了通用的耗时统计:

function wrap(Model, key) {
  // 猎取Class对应的原型
  let target = Model.prototype

  // 猎取函数对应的描述符
  let descriptor = Object.getOwnPropertyDescriptor(target, key)

  // 天生新的函数,增加耗时统计逻辑
  let log = function (...arg) {
    let start = new Date().valueOf()
    try {
      return descriptor.value.apply(this, arg) // 挪用之前的函数
    } finally {
      let end = new Date().valueOf()
      console.log(`start: ${start} end: ${end} consume: ${end - start}`)
    }
  }

  // 将修正后的函数从新定义到原型链上
  Object.defineProperty(target, key, {
    ...descriptor,
    value: log      // 掩盖描述符重的value
  })
}

wrap(Model1, 'getData')
wrap(Model2, 'getData')

// start: XXX end: XXX consume: XXX
console.log(new Model1().getData())     // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]
// start: XXX end: XXX consume: XXX
console.log(Model2.prototype.getData()) // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]

接下来,我们想掌握个中一个Model的函数不可被其他人修正掩盖,所以要增加一些新的逻辑:

function wrap(Model, key) {
  // 猎取Class对应的原型
  let target = Model.prototype

  // 猎取函数对应的描述符
  let descriptor = Object.getOwnPropertyDescriptor(target, key)

  Object.defineProperty(target, key, {
    ...descriptor,
    writable: false      // 设置属性不可被修正
  })
}

wrap(Model1, 'getData')

Model1.prototype.getData = 1 // 无效

可以看出,两个wrap函数中有不少反复的处所,而修正递次行动的逻辑,现实上依靠的是Object.defineProperty中通报的三个参数。
所以,我们针对wrap在举行一次修正,将其变成一个通用类的转换:

function wrap(decorator) {
  return function (Model, key) {
    let target = Model.prototype
    let dscriptor = Object.getOwnPropertyDescriptor(target, key)

    decorator(target, key, descriptor)
  }
}

let log = function (target, key, descriptor) {
  // 将修正后的函数从新定义到原型链上
  Object.defineProperty(target, key, {
    ...descriptor,
    value: function (...arg) {
      let start = new Date().valueOf()
      try {
        return descriptor.value.apply(this, arg) // 挪用之前的函数
      } finally {
        let end = new Date().valueOf()
        console.log(`start: ${start} end: ${end} consume: ${end - start}`)
      }
    }
  })
}

let seal = function (target, key, descriptor) {
  Object.defineProperty(target, key, {
    ...descriptor,
    writable: false
  })
}

// 参数的转换处置惩罚
log = wrap(log)
seal = warp(seal)

// 增加耗时统计
log(Model1, 'getData')
log(Model2, 'getData')

// 设置属性不可被修正
seal(Model1, 'getData')

到了这一步今后,我们就可以称logseal为装潢器了,可以很随意马虎的让我们对一些函数增加行动。
而拆分出来的这些功用可以用于将来可以会有须要的处所,而不必从新开辟一遍雷同的逻辑。

Class 中的作用

就像上边提到了,现阶段在JS中继续多个Class是一件头疼的事变,没有直接的语法可以继续多个 Class。

class A { say () { return 1 } }
class B { hi () { return 2 } }
class C extends A, B {}        // Error
class C extends A extends B {} // Error

// 如许才是可以的
class C {}
for (let key of Object.getOwnPropertyNames(A.prototype)) {
  if (key === 'constructor') continue
  Object.defineProperty(C.prototype, key, Object.getOwnPropertyDescriptor(A.prototype, key))
}
for (let key of Object.getOwnPropertyNames(B.prototype)) {
  if (key === 'constructor') continue
  Object.defineProperty(C.prototype, key, Object.getOwnPropertyDescriptor(B.prototype, key))
}

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

所以,在React中就有了一个mixin的观点,用来将多个Class的功用复制到一个新的Class上。
大抵思绪就是上边列出来的,然则这个mixinReact中内置的一个操纵,我们可以将其转换为更靠近装潢器的完成。
在不修正原Class的情况下,将其他Class的属性复制过来:

function mixin(constructor) {
  return function (...args) {
    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))
      }
    }
  }
}

mixin(C)(A, B)

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

以上,就是装潢器在函数、Class上的完成要领(最少如今是的),然则草案中另有一颗迥殊甜的语法糖,也就是@Decorator了。
可以帮你省去许多烦琐的步骤来用上装潢器。

@Decorator的运用要领

草案中的装潢器、或许可以说是TS完成的装潢器,将上边的两种进一步地封装,将其拆分成为更细的装潢器运用,如今支撑以下几处运用:

  1. Class
  2. 函数
  3. get set接见器
  4. 实例属性、静态函数及属性
  5. 函数参数

@Decorator的语法划定比较简朴,就是经由过程@标记后边跟一个装潢器函数的援用:

@tag
class A { 
  @method
  hi () {}
}

function tag(constructor) {
  console.log(constructor === A) // true
}

function method(target) {
  console.log(target.constructor === A, target === A.prototype) // true, true
}

函数tagmethod会在class A定义的时刻实行。

@Decorator 在 Class 中的运用

该装潢器会在class定义前挪用,假如函数有返回值,则会以为是一个新的组织函数来替代之前的组织函数。

函数吸收一个参数:

  1. constructor 之前的组织函数

我们可以针对原有的组织函数举行一些革新:

新增一些属性

假如想要新增一些属性之类的,有两种计划可以挑选:

  1. 建立一个新的class继续自原有class,并增加属性
  2. 针对当前class举行修正

后者的适用范围更窄一些,更靠近mixin的处置惩罚方式。

@name
class Person {
  sayHi() {
    console.log(`My name is: ${this.name}`)
  }
}

// 建立一个继续自Person的匿名类
// 直接返回并替代原有的组织函数
function name(constructor) {
  return class extends constructor {
    name = 'Niko'
  }
}

new Person().sayHi()

修正原有属性的描述符

@seal
class Person {
  sayHi() {}
}

function seal(constructor) {
  let descriptor = Object.getOwnPropertyDescriptor(constructor.prototype, 'sayHi')
  Object.defineProperty(constructor.prototype, 'sayHi', {
    ...descriptor,
    writable: false
  })
}

Person.prototype.sayHi = 1 // 无效

运用闭包来加强装潢器的功用

在TS文档中被称为装潢器工场

由于@标记后边跟的是一个函数的援用,所以关于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中有个相似的玩艺儿叫做注解)。

总结

合理应用装潢器可以极大的进步开辟效力,对一些非逻辑相干的代码举行封装提炼可以协助我们疾速完成反复性的事变,节省时间。
然则糖再好吃,也不要吃太多,轻易坏牙齿的,一样的滥用装潢器也会使代码本身逻辑变得虚无缥缈,假如肯定一段代码不会在其他处所用到,或许一个函数的中心逻辑就是这些代码,那末就没有必要将它取出来作为一个装潢器来存在。

参考资料

  1. typescript | decorators
  2. koa示例的原版,简化代码便于举例

One more thing

我司如今大批招人咯,前端、Node方向都有HC
公司名:Blued,坐标帝都旭日双井
重要手艺栈是React,也会有机会玩ReactNative和Electron
Node方向8.x版本+koa 新项目会以TS为主
有兴致的小伙伴可以联络我详谈:
email: jiashunming@blued.com
wechat: github_jiasm

    原文作者:贾顺名
    原文地址: https://segmentfault.com/a/1190000015566627
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞