javascript装饰器的正确打开方式

javascript装饰器的正确打开方式

综述

最近,在若干各项目中启用了装饰器,我自己也尝试了一下用装饰器来封装基础库(yusangeng/litchy)。效果还不错,代码的可读性和开发便捷性都有提升,更重要的是装饰器给js提供了一种比较“面向切面”的编程风格,可以扩展开发时的思路。

所以,这篇文章想谈一谈装饰器的“正确打开方式”——其实就是我怎么用它的。

理解

装饰器是什么?

概念科普就省了,大家可以看这里:

wycats/javascript-decoratorsgithub.com图标

简单举个例子,假设你有一个类,你想让其中某个成员函数开始运行时打个log,那么你有两个选择:

第一种

class Foobar  {
  tom () {
    console.log('this is tom.') // 直接打log
    // 业务代码
  }
}

第二种,就是使用装饰器

// 装饰器函数
function logable(target, key, descriptor) {
  return Object.assign({}, descriptor, {
    value: function (...params) {
      console.log(`this is ${descriptor.value.name || [unknown]}`)
      descriptor.value.apply(this, params)
    }
  })
}

class Foobar {
  // 使用装饰器
  @logable
  tom () {
    // 业务代码
  }
} 

通过代码可以看到,js装饰器和java的注解语法很像,用途也差不多,主要就是用来变继承为组合,实现装饰模式什么的。具体就不说了,各种设计模式大家都有自己的理解,无需陈词滥调。

抽象理解和选型标准

不难发现,装饰器其实是个很“黑科技”的东西,只要运行时修改类能实现的功能,都能用装饰器实现。所以,在这里说一下我对装饰器的抽象理解,也是我判断什么时候该使用装饰器的选型标准:

js的整个部署运行过程,可以分为构建期和运行期。而装饰器在构建期和运行期之间,给开发者开辟了一个“后构建期”、或者叫“前运行期”的空间,允许你在业务真正开始运行之前,加工你的代码。

也就是说,目前装饰器在运行期生效,不一定是最终方案,或许未来会有构建期生效的实现(typescript就有那种纯粹静态检查的装饰器),这样的改变并不会改变装饰器的本质。那么我们可以由此得出一个大致的判断标准——你觉得你想要的功能以后放在构建脚本里跑合不合适?如果不合适,你就不应该使用装饰器来实现。

我写了哪些装饰器

到目前为止,我尝试开发的并且多少有点用的装饰器,大约是这样的:

  • @disposable:类装饰器,给类增加一个dispose方法和一个disposed属性,用来清理资源。
  • @undisposed:方法装饰器:加工被装饰的成员函数,在入口处增加对disposed属性的判断,如果已经被清理则抛出异常,主要用来方便排查编码错误。
  • @eventable:类装饰器,给类增加一整套事件监听和发送机制。
  • @eventListener(type):方法装饰器,表示被装饰的方法是一个事件回调。
  • @prop:属性装饰器,将一个属性转换为一个可监听属性,只要改变就发出事件。
  • @computed:属性装饰器,声明计算属性用的。
  • @reaction:方法装饰器,表示被装饰的方法是一个监听prop改变的回调。

还有一些装饰器,虽然写了,但是发现设计失败,不能用。比如我写了一个叫做hasid的装饰器,是给类增加一个id属性,自动使用shortid生成id。然而我发现根本就用不了,每个需要id的业务,要求都不一样。

这就是因为id这个东西太“业务”了,回到刚才的选型标准,这种和后台数据相关的业务功能,永远不该在装饰器里实现。

使用和踩坑

工具链

转译decorator需要babel插件,一般来说如果你使用babel6,你应该执行:

npm i babel-plugin-transform-decorators-legacy --save-dev 

但是,如果你去npmjs.org搜索,会发现有两个关于decorator的插件,分别是babel-plugin-transform-decorators-legacy和babel-plugin-transform-decorators。如果你使用的是babel-plugin-transform-decorators,运行babel就会出现这样的报错:



简单解释一下这是什么意思。

decorator的proposal,应该是15年进入tc39的,随后就出现了babel插件babel-plugin-transform-decorators,然而后来,tc39大神们的想法又变了,decorator proposal现在是这样的:

tc39/proposal-decoratorsgithub.com图标

目前这个proposal在stage-2阶段。

babel版本进入6.x之后,babel-plugin-transform-decorators并没有做相应的更新,应该是还在等待proposal固化,而babel6是不支持当前版本的babel-plugin-transform-decorators的。所以就出现了刚才的报错。

而babel-plugin-transform-decorators-legacy,就是让babel6.x在decorator proposal固化之前,能使用老版本decorator的一个插件。

类装饰器

类装饰器写起来比较简单,写在类定义前即可。

@xxx
class Foobar {
  // ...
}

此时装饰器函数的三个参数,只有第一个是有效的。

function xxx(target, key, descriptor) {
  console.log(taget, key, descriptor) // 会打印class{}, undefined, undefined
}

target即是被装饰的类,你可以随便做什么修改。

但是这里有个问题,就是target的constructor是不能修改的,如果要修改constructor,比如你要在constructor后面再调用一个你自己的函数,不能靠改target,而应该返回一个新的target,比如这样:

function xxx(target, key, descriptor) {
  return class extends target {
    constructor(...params) {
      super(...params)
      this.yyy() // 你自定义的调用放在这里
    }
  }
}

属性装饰器

class的属性定义有两种语法可以用:

class Foobar {
  xxx = 1 // 属性定义

  // 第二种属性定义
  get yyy() {
    return 2
  }
}

添加装饰的方法一样,直接把装饰写在属性定义前面就可以了:

class Foobar {
  @prop
  xxx = 1

  @computed
  get yyy() {
    return 2
  }
}

但是这里有个坑,就是第一种(xxx)属性定义需要一个babel插件babel-plugin-transform-class-properties,而这插件是有可能和decorator冲突的。简单说,你要把decorator插件配置在property插件前面,比如这样“:

{
  "presets": ["env"],
  "plugins": [
    "transform-decorators-legacy",
    "transform-class-properties"
  ]
} 

否则decorator定义会失效,原因是babel-plugin-transform-class-properties会把属性定义转移到constructor中,改为一个this.xxx=1语句,轮到decorator插件,就找不到了属性。

装饰器生成器

如果你希望你的装饰器是有参数的,比如:

@eventable
class Foobar {
  @eventListener('change')
  onChange() {
    // ...
  }
}

这里的@eventListener就不能定义为一个装饰器,而应该定义为一个装饰器生成器,即生成装饰器的函数:

export default function eventListener (eventType) {
  return function decorator (target) {
    // ...
  }
}

怪异用法

这算是我的一种非规范用法,是否使用,大家自己判断。

有时候,我们希望类装饰器有参数,然而参数特别大,如果安装传统的方式写,会变成这样:

@xxx({
  a:1,
  b: 2,
  // ...
  z: 26
})
class Foobar {
}

这样的代码就太恶心了,我们希望class decorator能写在类定义的里面,比如这样:

 class Foobar {
  @xxx({
    a:1,
    b: 2,
    // ...
    z: 26
  })

  yyy() {
  }
}

但是这时候,@xxx显然会被解释成yyy方法的装饰器,而不是Foobar的装饰器,怎么办呢?

其实我们可以这样:

export default function xxx (param) {
  return function decorator (target, key, descriptor) {
    target.__decorated_xxx__ = clone(param)
    return descriptor
  }
}

也就是说,在方法和属性装饰其中,完全可以修改类定义,不用管xxx原来是个什么类型的装饰器,只要最后原样返回descriptor即可。

总结

以上文字总结了一下近期对装饰器的使用经验,希望对各位有帮助。用装饰器,很适合用来做基础库的语法糖,用来简化上层代码。如果有更新鲜的用法,我会再写文章更新。

发布于 2018-01-11

文章被以下专栏收录