不久前,我開發了一個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特性一樣,是你工具箱中很有用的工具,他很大程度的簡化了不同和不相關的類的行爲共享。然而過早的採用總需要一些成本。所以使用裝飾器,也需要了解它對你代碼庫的影響。