ECMAScript装饰器实战

迭代器、生成器、数组操作,JavaScript和Python变得越来越像。今天我们来探讨ECMAScript的装饰器 —— 又一个Python化的JavaScript特性。

Yehuda Katz

装饰器模式

装饰器到底是什么东西?在Python中,装饰器是调用高阶函数的一种非常简单的语法。 一个Python 的装饰器,实际上就是以另一个函数为参数的函数, 它以非常简洁的语法来增强参数函数的行为。 最简单的Python装饰器看起来像这样:

1
2
3
@mydecorator
def myfunc():
pass

第一行的@mydecorator就是一个装饰器。@用来告诉Python解析器,我们将调用一个名为mydecorator 的函数来增强函数myfunc,这将得到一个新的函数 —— 它可以增强myfunc的功能。

ES5和ES2015中的装饰器

在ES5中,实现指令式的装饰器(作为纯函数)非常琐碎。 在ES2015中,由于类支持继承, 我们需要更好的 方法来实现在多个类之间共用同一功能。

Yehuda的装饰器实现是通过使用注解并在设计时修改JavaScript类、属性和对象来实现,他保持了装饰器的 声明式风格。

现在,让我们看一下ES2016中的装饰器的实战!

ES2016装饰器实战

请记住我们从Python中学到的东西。一个ES2016装饰器就是一个返回函数的表达式,它的参数为target、name 和descriptor。 使用@符号来为类或属性应用一个装饰器。

装饰一个属性

让我们看一个非常简单的类,Cat:

1
2
3
class Cat {
meow(){ return `${this.name} says Meow!`; }
}

执行这段语句,就会在原型对象Cat.prototype上添加一个meow方法,类似于:

1
2
3
4
5
6
Object.defineProperty(Cat.prototype,'meow',{
value: specifiedFunction,
enumerable: false,
configurable: true,
writable: true
});

假设我们希望将一个属性或方法标记为只读的。我们可以定义一个@readonly装饰器:

1
2
3
4
function readonly(target,name,descriptor){
descriptor.writable = false;
return descriptor;
}

然后,我们可以在meow方法上应用这个装饰器:

1
2
3
4
class Cat {
@readonly
meow(){ return `${this.name} says Meow!`; }
}

一个装饰器不过就是一个返回函数的表达式。因此,装饰器可以带参数,比如@readonly@something(parameter)都是合法的。

现在,在把描述子安装到Cat.prototype上面之前,引擎将首先调用装饰器:

1
2
3
4
5
6
7
8
let descriptor = {
value: specifiedFunction,
enumerable: false,
configurable: true,
writable: true
};
descriptor = readonly(Cat.prototype,'meow',descriptor) || descriptor;
Object.defineProperty(Cat.prototype,'meow',descriptor);

上面的代码执行后,meow方法就变成只读了。我们可以验证一下:

1
2
3
4
var garfield = new Cat();
garfield.meow = function(){
console.log("I want lasagne!");
}

执行到第2行时将抛出异常:Attempted to assign to readonly property

太棒了,对吗?

我们接下来将对类进行装饰。不过插一句,尽管JavaScript的装饰器概念还很新, ES2016的装饰器库已经开始出现了,比如Jay Phelps开发的core-decorators

类似于我们上面的只读属性的尝试, Jay Phelps的库中已经包含了@readonly的实现,只需要引入就可以直接使用:

1
2
3
4
5
6
7
8
9
import { readonly } from 'core-decorators';

class Meal{
@readonly
entree = 'steak';
}

var dinner = new Meal();
dinner.entree = 'salmon';

类似的,运行上面的代码,将在试图对dinner.entree进行改写时抛出异常:Cannot assign to read only property ‘entree’ of [object Object]。

Jay Phelps的库中还包含了一些其他常用的装饰器,例如@deprecate,用来对可能变化的API提供警示信息:

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
import { deprecate } from 'core-decorators';

class Person {
@deprecate facepalm() {}

@deprecate('We stopped facepalming')
facepalmHard(){}

@deprecate('We stopped facepalming',{url: 'http://knowyourname.com/memes/facepalm'})
facepalmHarder(){}
}

let captainPicard = new Person();

captainPicard.facepalm();
//DEPRECATION Person#facepalm: This function will be removed in future version.

captainPicard.facepalmHard();
//DEPRECATION Person#facepalm: We stopped facepalming

captainPicard.facepalmHarder();
//DEPRECATION Person#facepalm: We stopped facepalming
//
// See http://knowyourname.com/memes/facepalm
//

装饰一个类

接下来让我们看一下如何对类进行装饰。在这个案例中,一个装饰器将使用被装饰类的构造 函数作为参数。 对于一个假想的MySuperHero类,我们可以定义一个简单的装饰器@superhero

1
2
3
4
5
6
7
8
9
function superhero(target){
target.isSuperHero = true;
target.power = 'flight';
}

@superhero
class MySuperHero{}

console.log(MySuperHero.isSuperHero); //true

我们可以更进一步, 把装饰器函数定义为一个可接受参数的工厂函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
function superhero(isSuperHero){
return function(target){
target.isSuperHero = isSuperHero;
}
}

@superhero(true)
class MySuperHeroClass{}
console.log(MySuperHeroClass.isSuperHero); //true

@superhero(false)
class MySuperHeroClass{}
console.log(MySuperHeroClass.isSuperHero); //false

ES2016装饰器可以作用于属性描述子和类。它们可以自动地获得传入的属性名和目标对象, 我们很快介绍这一点。 由于可以获取到属性描述子,这使得一个装饰器可以做很多事情,例如 可以将一个属性改变为使用getter,这对于实现自动的数据绑定相当有用(mobx就是这么干的)。

ES2016装饰器和Mixins

我认真阅读了Reg Braithwaite最近发表的关于ES2016装饰器用作mixin的文章。Reg提出了一种方法 可以将功能混入任何目标(类原型或独立对象),并且描述了一个类特定的版本。函数式mixin可以 将实例行为混入一个类的原型,看起来大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function mixin(behaviour, sharedBehaviour = {}) {
const instanceKeys = Reflect.ownKeys(behaviour);
const sharedKeys = Reflect.ownKeys(sharedBehaviour);
const typeTag = Symbol('isa');

function _mixin(clazz){
for(let property of instanceKeys) {
Object.defineProperty(clazz.prototype,property, {value: behaviour[property]});
}
Object.defineProperty(clazz.prototype, typeTag,{value:true});
return clazz;
}
for(let property of sharedKeys){
Object.defineProperty(_mixin,property,{
value: sharedBehaviour[property],
enumerable: sharedBehaviour.propertyIsEnumerable(property)
});
}
Object.defineProperty(_mixin, Symbol.hasInstance, {
value: (i) => !!i[typeTag]
});
return _mixin;
}

特别好。我们现在可以定义一些mixin,然后使用它们来装饰类。现在假设有一个类 ComicBookCharacter

1
2
3
4
5
6
7
8
9
class ComicBookCharacter{
constructor(first,last){
this.firstName = first;
this.lastName = last;
}
realName(){
return this.firstName + ' ' + this.lastName;
}
}

也许这时世界上最乏味的角色, 但是我们可以定义一些mixins来给这个角色 赋予超能SuperPowers和工具带UtilityBelt。让我们使用Reg的mixin辅助函数来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const SuperPowers = mixin({
addPower(name){
this.powers().push(name);
return this;
},
powers(){
return this._powers_processed || (this._powers_processed = []);
}
});

const UtilityBelt = mixin(){
addToBelt(name) {
this.utilities().push(name);
return this;
},
utilities(){
return this._utility_items || (this._utility_items = []);
}
};

有了这些做基础,我们可以使用@语法来赋予 ComicBookCharacter能力。注意 我们是如何为类应用多个装饰器的:

1
2
3
4
5
6
7
8
9
10
11
@SuperPowers
@UtilityBelt
class ComicBookCharacter {
constructor(first,last){
this.firstName = first;
this.lastName = last;
},
realName(){
return this.firstName + ' ' + this.lastName;
}
}

现在,让我们使用这个类来创建一个蝙蝠侠角色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const batman = new ComicBookCharacter('Bruce','Wayne');
console.log(batman.realName);
//Bruce Wayne

batman
.addToBelt('batarang')
.addToBelt('cape');

console.log(batman.utilities()) ;
//['batarang','cape']

batman
.addPower('detective')
.addPower('voic sounds like Gollum has asthma');

console.log(batman.powers());
//['detective','voice sounds like Gollum has asthma']

这些用于类的装饰器相对紧凑,可以用它们来代替高阶函数调用。

开启Babel的装饰器功能

装饰器目前还停留在建议阶段,并未被最终批准。但是多种编译器都已经 支持这一特性,例如Babel、traceur和tsc。

如果在命令行中使用Babel,可以如下方式打开装饰器功能:

1
$ babel --optional es7.decorators

或者,也使用转换器打开这个功能:

1
babel.transform("code",{optional:["es7.decorators"]})

有趣的实验

坐在我旁边的Paul Lewis尝试使用装饰器来重新调度读写DOM的代码。他借鉴了Wilson Page的FastDOM实现思路,但是提供了非常简单的API。

下面是Paul的一个实验示例,试图在@read中改变DOM将在控制台输出警告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class MyComponent{
@read
readSomeStuff(){
console.log('read');
//throws a warning
document.querySelector('.button').style.top = '100px';
}

@write
writeSomeStuff(){
console.log('write');

//throws a warning
document.querySelector('.button').focus();
}
}

开始尝试装饰器!

短期来看,ES2016装饰器对于声明式装饰、注解、类型检查等都很有用。从 长远的角度,它们将对静态语言分析(编译时类型检查、自动完成等)非常有用。

ES2016的装饰器和经典OOP中的装饰器没有特别的差异。在传统OOP中,装饰器模式 允许为一个对象赋以额外的行为能力,这既可以是静态的装饰,也可以是动态的装饰—— 在这种情况下该类的其他实例不受影响。ES2016的装饰器语法还在变迁当中,可以留意 Yehuda的repo更新。

库作者们现在在讨论在什么地方可以使用装饰器代替mixins,显然,这些装饰器也可以用于 React中的高阶组件。

我个人对于围绕装饰器的实验和应用相当兴奋,也希望你们使用Babel来试一下,找到 并实现可以复用的装饰器,没准你也可以和Paul一样,分享你的成果!

进一步的阅读和参考

  • https://github.com/wycats/javascript-decorators
  • https://github.com/jayphelps/core-decorators.js
  • http://blog.developsuperpowers.com/eli5-ecmascript-7-decorators/
  • http://elmasse.github.io/js/decorators-bindings-es7.html
  • http://raganwald.com/2015/06/26/decorators-in-es7.html
  • Jay’s function expression ES2016 Decorators example

感谢Jay Phelps, Sebastian McKenzie, Paul Lewis 和 Surma 帮助审核这篇文章并提供了详细的反馈意见。

原文:Exploring EcmaScript Decorators by Addy Osmani