不久前,我开发了一个react
应用,使用mobx
做状态管理。这是一个时而兴奋时而困惑,但总体而言很享受的经历,很快我将会把它写出来。在使用mobx
开发时,我发现了一个非常有趣的独特之处,那就是它使用装饰器来注释类的属性。我之前在写javascript
时还没用过它,但自从我使用了mobx
提供的这个功能以及做了一些开发后,我发现这是一个有巨大潜力的功能。
装饰器现在还不是javascript
的核心特性,他们正通过ECMATC39的标准化流程进行工作。不过并不代表我们不能去熟悉它。
在不久的将来,它将得到浏览器和node
的原生支持,与此同时,babel
也得到支持。
什么是装饰器
Decorator
是decorator function/methored
的缩写。它是一个函数,它会通过返回一个新函数来修改传入的函数或方法的行为。
你可以在函数式编程的任何语言中实现装饰器,比如javascript
,你可以把函数绑定到一个变量上,也可以把函数当成函数的参数传递。这些语言中的几种有特殊的语法糖,用来定义和使用装饰器,其中一个就是python
:
def cashify(fn):
def wrap():
print("$$$$")
fn()
print("$$$$")
return wrap
@cashify
def sayHello():
print("hello!")
sayHello()
# $$$$
# hello!
# $$$$
让我们看看发生了什么,cashify
函数是一个装饰器,他接受一个函数作为参数,它的返回值也是函数。我们使用python
的pie syntax
把装饰器应用到sayHello
函数上,本质上和我们在sayHello
的定义下执行此操作是一样的:
def sayHello():
print("hello!")
sayHello = cashify(sayHello)
无论我们装饰的函数打印什么,最后的结果都会在他们前后打印$符号。
为什么我要使用python
的例子来介绍ECMAScript
的装饰器,很高兴你问这个问题!
-
python
是一个很好地方式去解释基础知识,因为它的装饰器的概念比它在JS中的工作方式更简单直接 -
js
和TS
都是用python
的pie syntax
把装饰器应用到类的函数和属性上,所以它们外观和语法格式都很相似
好了,那么js
装饰器有什么不同呢?
JS 装饰器和属性描述符
python
把传入的需要装饰的任何函数当做参数,但因为对象在js
中的特殊工作方式,js装饰器可以获取到更多信息。
对象在js
中有属性,并且这些属性有以下值:
const oatmeal = {
viscosity: 20,
flavor: 'Brown Sugar Cinnamon',
};
但除了它的值,每个属性还有一些其他隐藏的信息,用于定义它工作方式的不同方面,叫做属性描述符:
console.log(Object.getOwnPropertyDescriptor(oatmeal, 'viscosity'));
/*
{
configurable: true,
enumerable: true,
value: 20,
writable: true
}
*/
JS在追踪与这个属性有关的很多东西:
-
configurable
决定该属性的类型能否被修改,以及它能否从对象中删除 -
enumerable
控制当你在枚举对象属性时,该属性是否显示(比如当你调用Object.keys(oatmeal)
或者使用for
循环时) -
writable
控制你是否可以通过赋值操作符=
修改该属性的值 -
value
是你访问这个属性时,所看到的静态值。通常,这是你经常看到和关心的属性描述符的唯一部分。它可以是任何JS
值,包括一个函数,这会使这个属性成为其所属对象的方法。
属性描述符也有两个其他的属性,为访问器描述符(通常称为getter
和setter
):
-
get
是一个返回属性值而不是用静态value
属性的的函数 -
set
是一个特殊的函数,当你给这个属性赋值时,该函数会将你在等号右边放置的任何内容作为参数
没有多余的装饰
js
从es5
就已经有了操作属性描述符的API
,通过Object.getOwnPropertyDescriptor
和Object.defineProperty
的形式。比如我喜欢我的燕麦片的浓度,我可以使用这个API
像下边这样把它变成只读的:
Object.defineProperty(oatmeal, 'viscosity', {
writable: false,
value: 20,
});
// 当我试图设置oatmeal.viscosity为不同的值时,它将会默默地报错
oatmeal.viscosity = 30;
console.log(oatmeal.viscosity);
// => 20
我甚至可以写一个通用的decorate
函数,可以修改任何对象的任何属性的修饰符
function decorate(obj, property, callback) {
var descriptor = Object.getOwnPropertyDescriptor(obj, property);
Object.defineProperty(obj, property, callback(descriptor));
}
decorate(oatmeal, 'viscosity', function(desc) {
desc.configurable = false;
desc.writable = false;
desc.value = 20;
return desc;
});
Adding the Shiplap and Crown Molding(巴拉巴拉...)
第一个主要的装饰器的提案只与ES
的类有关,而非普通对象。让我们设计一些类来代表我们的粥:
class Porridge {
constructor(viscosity = 10) {
this.viscosity = viscosity;
}
stir() {
if (this.viscosity > 15) {
console.log('This is pretty thick stuff.');
} else {
console.log('Spoon goes round and round.');
}
}
}
class Oatmeal extends Porridge {
viscosity = 20;
constructor(flavor) {
super();
this.flavor = flavor;
}
}
我们使用一个类来代表我们的燕麦粥,他继承自一个更通用的的 Porridge
类。Oatmeal
设置了默认的浓度来覆盖Porridge
的默认值,并且添加了新的口味属性。我们也使用了另一个es
提案 class fields去覆盖浓度属性。
我们可以重新创建我们原始的燕麦粥了:
const oatmeal = new Oatmeal('Brown Sugar Cinnamon');
/*
Oatmeal {
flavor: 'Brown Sugar Cinnamon',
viscosity: 20
}
*/
很好,我们得到了我们的es6
燕麦粥,我们要准备写装饰器了!
如何去写一个装饰器
js
装饰器函数被传入三个参数:
-
target
是我们对象所继承的类 -
key
是我们应用装饰器的属性的名称,为字符串。 -
descriptor
是属性描述符对象
我们在装饰器内做什么依赖于我们装饰器的目的。为了装饰对象的方法和属性,我们需要返回一个新的属性描述器。我们可以通过以下方式写一个装饰器来使一个属性为只读:
function readOnly(target, key, descriptor) {
return {
...descriptor,
writable: false,
};
}
我们可以像这样修改我们的oatmeal类:
class Oatmeal extends Porridge {
@readOnly viscosity = 20;
// 你也可以吧@readonly放在属性上一行
constructor(flavor) {
super();
this.flavor = flavor;
}
}
现在我们燕麦粥像胶水一样的浓度不会被干预了,谢天谢地。
如果我们想做一些真正有用的东西呢?我在最近的项目时遇到了一种情况,其中装饰器节省了我很多开发和维护的开销。
处理API错误
在我开头提到的Mobx/React app
中,我有一些不同的类作为数据中心。他们各自都代表与用户交互的不同类别的集合,并且与不同的API
端点对话以获取服务端的数据。为了处理API
错误,我使每个数据中心在与网络通信时都准守一个协议:
- 设置
ui
中心的networkStatus
属性为loading
- 发送
api
请求 - 处理结果
- 如果成功,使用结果更新本地状态
- 如果报错了,设置
ui
中心的apiError
属性为接收到的错误
- 设置
ui
中心的networkStatus
属性为idle
我发现在我注意到之前,已经重复了很多次这种模式:
class WidgetStore {
async getWidget(id) {
this.setNetworkStatus('loading');
try {
const { widget } = await api.getWidget(id);
// Do something with the response to update local state:
this.addWidget(widget);
} catch (err) {
this.setApiError(err);
} finally {
this.setNetworkStatus('idle');
}
}
}
这是很多错误处理的样板。因为我已经在所有更新可观察属性的方法上使用了MobX
的@action
装饰器了(为了简单起见,此处未显示),所以也可以再添加一个装饰器用来节省我错误处理的代码。我想出了这个:
function apiRequest(target, key, descriptor) {
const apiAction = async function(...args) {
// More about this line shortly:
const original = descriptor.value || descriptor.initializer.call(this);
this.setNetworkStatus('loading');
try {
const result = await original(...args);
return result;
} catch (e) {
this.setApiError(e);
} finally {
this.setNetworkStatus('idle');
}
};
return {
...descriptor,
value: apiAction,
initializer: undefined,
};
}
然后我就可以像这样替换那些写在每个API
操作方法上的模板:
class WidgetStore {
@apiRequest
async getWidget(id) {
const { widget } = await api.getWidget(id);
this.addWidget(widget);
return widget;
}
}
我的错误处理代码依然在那,但是我只需要写一次,并且确保每个使用它的class
都有setNetworkStatus
和setApiError
方法即可。
babel解决方案
我选择descriptor.value
和调用descriptor.initializer
其中之一的那一行发生了什么?这是与babel相关的事。我的预感是,这种方式在js
原生支持装饰器的时候不会起作用,但当考虑到babel处理作为类属性的箭头函数的方式时,就会很有必要。
当你定义一个类属性,并且给它赋值一个箭头函数时,babel
会巧妙地把函数绑定到类正确的实例上并且提供你正确的this
值。通过设置descriptor.initializer
为一个函数,它会返回你写的那个函数,并且在其作用域内为正确的this
值。
一个例子会让事情变简单:
class Example {
@myDecorator
someMethod() {
// 在这个例子中,我们的方法可以由descriptor.value引用到
}
@myDecorator
boundMethod = () => {
// 在这里,descriptor.initializer是一个函数,他会返回我们的boundMethod函数,并且this执行已经被调整为Example的实例
};
}
装饰类
除了属性和方法,你还可以装饰整个类。想要装饰类,你只需要传入装饰器函数的第一个参数target
。比如,我想写一个自动把类注册为自定义html
标签的装饰器,我在这里使用了一个闭包,来保证装饰器能够接收我们想要为标签提供参数的任何名称:
function customElement(name) {
return function(target) {
// customElements是一个全局API,用来创建自定义标签
customElements.define(name, target);
};
}
我们将这样使用它:
@customElement('intro-message');
class IntroMessage extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
this.wrapper = this.createElement('div', 'intro-message');
this.header = this.createElement('h1', 'intro-message__title');
this.content = this.createElement('div', 'intro-message__text');
this.header.textContent = this.getAttribute('header');
this.content.innerHTML = this.innerHTML;
shadow.appendChild(this.wrapper);
this.wrapper.appendChild(this.header);
this.wrapper.appendChild(this.content);
}
createElement(tag, className) {
const elem = document.createElement(tag);
elem.classList.add(className);
return elem;
}
}
把它加入到我们的html中,可以这样使用它:
<intro-message header="Welcome to Decorators">
<p>Something something content...</p>
</intro-message>
浏览器中显示如下:
总结
如今在你的项目中使用装饰器需要一些转译配置。我所见的最直接的教程就在MobX
的文档中,它有TS
和两个主要版本的babel
信息。
请记住装饰器当前还是发展中的提议,如果你在生产代码中使用它,你可能需要做一些更新或者持续使用babel
装饰器插件,直到它成为ECMA
官方的正式规范。甚至babel也没有很好地支持,最新版的装饰器提案包含很大的改动,并没有很好地向后兼容上一个版本。
装饰器像很多最新的js特性一样,是你工具箱中很有用的工具,他很大程度的简化了不同和不相关的类的行为共享。然而过早的采用总需要一些成本。所以使用装饰器,也需要了解它对你代码库的影响。