作者:百度EFE – otakustay
網址:http://efe.baidu.com/blog/introduction-to-es-decorator/?qq-pf-to=pcqq.c2c
點選“閱讀原文”可檢視本文網頁版
我跟你說,我最討厭“簡介”這種文章了,要不是語文是體育老師教的,早就換標題了!
Decorators是ECMAScript現在處於Stage 1的一個提案。當然ECMAScript會有很多新的特性,特地介紹這一個是因為它能夠在實際的程式設計中提供很大的幫助,甚至於改變不少功能的設計。
先說說怎麼回事
如果光從概念上來介紹的話,官方是這麼說的:
Decorators make it possible to annotate and modify classes and properties at design time.
我翻譯一下:
裝飾器讓你可以在設計時對類和類的屬性進行註解和修改。
什麼鬼,說人話!
所以我們還是用一段程式碼來看一下好了:
function memoize(target, key, descriptor) {
let cache = new Map();
let oldMethod = descriptor.value;
descriptor.value = function (…args) {
let hash = args[0];
if (cache.has(hash)) {
return cache.get(hash);
}
let value = oldMethod.apply(this, args);
cache.set(hash, value);
return value;
};
}
class Foo {
@memoize;
getFooById(id) {
// …
}
}
別去試上面的程式碼,瞎寫的,估計跑不起來就是了。這個程式碼的作用其實看函式的命名就能明白,我們要給Foo#getFooById方法加一個快取,快取使用第一個引數作為對應的鍵。
可以看出來,上面程式碼的重點在於:
- 有一個memoize函式。
- 在類的某個方法上加了@memoize;這樣一個標記。
而這個@memoize就是所謂的Decorator,我稱之為裝飾器。一個裝飾器有以下特點:
- 首先它是一個函式。
- 這個函式會接收3個引數,分別是target、key和descriptor,具體的作用後面再說。
- 它可以修改descriptor做一些額外的邏輯。
看到了基本用法其實並不能說明什麼,我們有幾個核心的問題有待說明:
有幾種裝飾器
現階段官方說有2種裝飾器,但從實際使用上來看是有4種,分別是:
- 放在class上的“類裝飾器”。
- 放在屬性上的“屬性裝飾器”,這需要配合另一個Stage 0的類屬性語法提案,或者只能放在物件字面量上了。
- 放在方法上的“方法裝飾器”。
- 放在getter或setter上的“訪問器裝飾器”。
其中類裝飾器只能放在class上,而另外3種可以同時放在class和屬性或者物件字面量的屬性上,比如這樣也是可以的:
let foo = {
@memoize
getFooById(id) {
// …
}
};
不過註意放在物件字面量時,裝飾器後面不能寫分號,這是個比較怪異的問題,後面還會說到更怪異的情況,我也在和提案的作者溝通這是為啥。
之所以這麼分,是因為不同情況下,裝飾器接收的3個引數代表的意義並不相同。
裝飾器的3個引數是什麼
裝飾器接收3個引數,分別是target、key和descriptor,他們各自分別是什麼值,用一段程式碼就能很容易表達出來:
function log(target, key, descriptor) {
console.log(target);
console.log(target.hasOwnProperty(‘constructor’));
console.log(target.constructor);
console.log(key);
console.log(descriptor);
}
class Bar {
@log;
bar() {}
}
// {}
// true
// function Bar() { …
// bar
// {“enumerable”:false,”configurable”:true,”writable”:true}
這是使用babel轉換的JavaScript的輸出,從這裡可以看到:
- key很明顯就是當前方法名,我們可以推斷出來用於屬性的時候就是屬性名
- descriptor顯然是一個PropertyDescriptor,就是我們用於defineProperty時的那個東西。
- target確實不是那麼容易看出來,所以我用了3行程式碼。首先這是一個物件,然後是一個有constructor屬性的物件,最後constructur指向的是Bar這個函式。所以我們也能推測出來這貨就是Bar.prototype沒跑了。
那如果裝飾器放在物件字面量上,而不是類上呢?這邊就不再給程式碼,直接放結論了:
key和descriptor和放在類屬性/方法上一樣沒變,這當然也不應該變。
target是Object物件,相信我你不會想用這個引數的。
當裝飾器放在屬性、方法、訪問器上時,都符合上面的原則,但放在類上的時候,有一些不同:
- key和descriptor不會提供,只有target引數。
- target會變成Bar這個方法,而不是其prototype。
其實對於屬性、方法和訪問器,真正有用的就是descriptor,其它幾個無視問題也不大就是了。而對於類,由於target是唯一能用的,所以會需要它。
對於這一環節,我們需要特別註意一點,由於target是類的prototype,所以往它上面新增屬性是,要註意繼承時是會被繼承下去的,而子類上再加同樣屬性又會有改寫甚至物件、陣列同取用混在一起的問題。這和我們平時儘量不在prototype上放物件或者陣列的思路是一致的,要避免這一問題。
裝飾器在什麼時候執行
既然裝飾器本身是一個函式,那麼自然要有函式被執行的時候。
現階段,裝飾器只能放在一個類或者一個物件上,我們可以用程式碼看一下什麼時候執行:
// 既然裝飾器是函式,我當然可以用函式工廠了
function log(message) {
return function() {
console.log(message);
}
}
console.log(‘before class’);
@log(‘class Bar’)
class Bar {
@log(‘class method bar’);
bar() {}
@log(‘class getter alice’);
get alice() {}
@log(‘class property bob’);
bob = 1;
}
console.log(‘after class’);
let bar = {
@log(‘object method bar’)
bar() {}
};
輸出如下:
before class
class method bar
class getter alice
class property bob
class Bar
after class
object method bar
從輸出上,我們可以看到幾個規則:
- 裝飾器是在宣告期就起效的,並不需要類進行實體化。類實體化並不會致使裝飾器多次執行,因此不會對實體化帶來額外的開銷。
- 按編碼時的宣告順序執行,並不會將屬性、方法、訪問器進行重排序。
因為以上這2個規則,我們需要特別註意一點,在裝飾器執行時,你所能得到的環境是空的,在Bar.prototype或者Bar上的屬性是獲取不到的,也就是說整個target裡其實只有constructor這一個屬性。換句話說,裝飾器執行時所有的屬性和方法均未定義。
descriptor裡有啥
我們都知道,PropertyDescriptor的基本內容如下:
- configurable控制是不是能刪、能修改descriptor本身。
- writable控制是不是能修改值。
- enumerable控制是不是能列舉出屬性。
- value控制對應的值,方法只是一個value是函式的屬性。
- get和set控制訪問咕嚕的讀和寫邏輯。
根據裝飾器放的位置不同,descriptor引數中就會有上面的這些屬性,其中前3個是必然存在的,隨後根據放在屬性、方法上還是放在訪問器上決定是value還是get/set。
再說說類屬性的情況,由於類屬性本身是一個比裝飾器更不靠譜的Stage 0的提案,所以情況就會變成2個提案的相互作用了。
當裝飾器用於類屬性時,descriptor將變成一個叫“類屬性描述符”的東西,其區別在於沒有value和get或set,且多出一個initializer屬性,型別是函式,在類建構式執行時,initializer傳回的值作為屬性的值,也就是說一個foo屬性對應程式碼是類似這樣的:
class Foo {
constructor() {
let descriptor = Object.getPropertyDescriptor(this, ‘foo’);
this.foo = descriptor.initializer.call(this);
}
}
所以我們也可以寫很簡單的裝飾器:
function randomize(target, key, descriptor) {
let raw = descriptor.initializer;
descriptor.initializer = function() {
let value = raw.call(this);
value += ‘-‘ + Math.floor(Math.random() * 1e6);
return value;
};
}
class Alice {
@randomize;
name = ‘alice’;
}
console.log((new Alice()).name); // alice-776521
再說說怎麼用
在基本把概念說完後,其實我們並沒有說裝飾器怎麼用,雖然前面有一些程式碼,但並不能邏輯完善地說明問題。
descriptor的使用
對於屬性、方法、訪問器的裝飾器,真正的作用在於對descriptor這個屬性的修改。我們拿一些原始的例子來看,比如你要給一個物件宣告一個屬性:
let property = {
enumerable: false,
configurable: true,
value: 3
};
Object.defineProperty(foo, ‘foo’, property);
但是我們現在又不高興了,我們希望這個屬性是隻讀的,OK這是個非常簡單的問題:
let property = {
writable: false, // 加一行解決問題
enumerable: false,
configurable: true,
value: 3
};
Object.defineProperty(foo, ‘foo’, property);
但是有時候,我們面對幾百幾千個屬性,真心不想一個一個寫writable: false,看著也不容易明白。或者這個descriptor根本是其他地方給我們的,我們只有defineProperty的權利,無法修改原來的東西,所以我們希望是這樣的:
Object.defineProperty(foo, ‘foo’, readOnly(property));
透過函式式的程式設計進行函式轉換,既能讀程式碼時就看出來這是隻讀的,又能用在所有以前的descriptor上而不需要改以前的程式碼,將“定義”和“使用”分離了開來。
而裝飾器無非是將這件事放到了語法的層面上,我們有一個機會在類或者屬性、訪問器、方法定義的時候去修改它的descriptor,這種對“元資料”的修改使得我們有很大的靈活性,包括但不侷限於:
- 透過descriptor.value的修改直接給改成不同的值,適用於方法的裝飾器。
- 透過descriptor.get或descriptor.set修改邏輯,適用於訪問器的裝飾器。
- 透過descriptor.initializer修改屬性值,適用於屬性的裝飾器。
- 修改configurable、writable、enumerable控制屬性本身的特性,常見的就是修改為只讀。
裝飾器是最後的修改descriptor的機會,再往後如果configurable被設為false的話,就再也沒機會去改變這些元資料了。
類裝飾器的使用
類裝飾器不大一樣,因為沒有descriptor給你,你唯一能獲得的是類本身,也就是一個函式。
但是有了類本身,我們可以做一件事,就是繼承:
function countInstance(target) {
let counter = new Map();
return class extends target {
constructor(…args) {
super(…args);
let count = counter.get(target) || 0;
counter.set(target, count + 1);
}
static getInstanceCount() {
return counter.get(target) || 0;
}
};
}
@countInstance
class Bob {
// …
}
new Bob();
new Bob();
console.log(Bob.getInstanceCount()); // 2
實際的使用場景
上面的程式碼可能都很扯談,誰會有這種奇怪的需求,所以舉一些真正實用的程式碼來看看。
一個比較可能的場合是在製作一個檢視類的時候,我們可以:
- 透過訪問器裝飾器來宣告類屬性與DOM元素之間的系結關係。
- 透過方法裝飾器指定方法處理某個DOM元素的某個事件。
- 透過類裝飾器指定一個類為檢視處理類,且在DOMContentLoaded時執行。
參考程式碼如下,以一個簡單的登入表單為例:
const DOM_EVENTS = Symbol(‘domEvents’);
function view(ViewClass) {
class AutoView extends ViewClass {
initialize() {
super.initialize();
// 註冊所有事件
for (let {id, type, handler} of this[DOM_EVENTS]) {
let element = document.getElementById(id);
element.addEventListener(type, handler, false);
}
}
}
let executeView = () => {
let view = new AutoView();
view.initialize();
};
window.addEventListener(‘DOMConentLoaded’, executeView);
return AutoView;
}
function dom(id) {
return function (target, key, descriptor) {
descriptor.get = () => document.getElementById(id || key);
};
}
function event(id, type) {
return (target, key, descriptor) {
// 註意target是prototype,所以如果原來已經有了物件要做複製,不能直接汙染
target[DOM_EVENTS] = target.hasOwnProperty(DOM_EVENTS) ? target[DOM_EVENTS].slice() : [];
target[DOM_EVENTS].push({id, type, handler: descriptor.value});
};
}
@view
class LoginForm {
@dom()
get username() {}
@dom()
get password() {}
@dom()
get captcha() {}
@dom(‘captcha-code’)
get captchaImage() {}
@event(‘form’, ‘submit’)
[Symbol()](e) {
let isValid = this.validateForm();
if (!isValid) {
e.preventDefault();
}
}
@event(‘captcha-code’, ‘click’)
[Symbol()]() {
// 點選掃清驗證碼
this.captchaImage.src = this.captchaImage.src + ‘x’;
}
validateForm() {
let isValid = true;
if (!this.username.value.trim()) {
showError(username, ‘請輸入使用者名稱’);
isValid = false;
}
if (!this.password.value.trim()) {
showError(username, ‘請輸入密碼’);
isValid = false;
}
if (!this.captcha.value.trim()) {
showError(username, ‘請輸入驗證碼’);
isValid = false;
}
return isValid;
}
}
這種程式設計方式我們經常稱之為“宣告式程式設計”,好處是更為直觀,且能夠透過裝飾器等手段復用邏輯。
這隻是一個很簡單直觀的例子,我們用裝飾器可以做更多的事,有待在實際開發中慢慢發掘,同時DecorateThis專案給我們做了不少的示範,雖然我覺得這個庫提供的裝飾器並沒有什麼卯月……
題外話的概念和坑
到這邊基本把裝飾器的概念和使用都講了,我理解有不少FE一時不好接受這些(QWrap那邊的人倒應該能非常迅速地接受這種函式式的玩法),後面說一些題外話,主要是裝飾器與其它語言類似功能的比較,以及一些坑爹的坑。
和其它語言的比較
大部分開發者都會覺得裝飾器這個語法很眼熟,因為我們在Java中有Annotation這個東西,而在C#中也有Attribute這個東西。
所以我說為啥一個語言搞一個東西還要名字不一樣啊……我推薦PHP也來一個,就叫GreenHat好了……
不過有些同學可能會受此誤導,其實裝飾器和Java、C#裡的東西不一樣。
其區別在於Annotation和Attribute是一種元資料的宣告,僅包含資訊(資料),而不包含任何邏輯,必須有外部的邏輯來讀取這些資訊產生分支才有作用,比如@override這個Annotation相對應的邏輯在編譯器是實現,而不是在對應的class中實現。
而裝飾器,和Python的相同功能同名(赤裸裸的抄襲),其核心是一段邏輯,起源於裝飾器設計樣式,讓你有機會去改變一個方法、屬性、類的邏輯,StackOverflow上Python的回答能比較好地解釋這個區別。
幾個坑
在我看來,裝飾器現在有幾個坑得註意一下。
首先,語法上很奇怪,特別是在裝飾器後面的分號上。屬性、訪問器、方法的裝飾器後面是可以加分號的,並且個人推薦加上,不然你可能遇到這樣的問題:
class Foo {
@bar
[1 + 2]() {}
}
上面的程式碼到底是@bar作為裝飾器的方法呢,還是@bar[1 + 2]()後跟著一個空的Block{}呢?
但是,放在類上的裝飾器,以及放在物件字面量的屬性、訪問器、方法上的裝飾器,是不能加分號的, 不然就是語法錯誤。我不明白為啥就不能加分號,以至於這個語法簡直精神分裂……
其次,如果你把裝飾器用在類的屬性上,建議一定加上分號,看看下麵的程式碼:
class Foo {
@bar
foo = 1;
}
想一想如果因為特性比較新,壓縮工具一個沒做好沒給補上分號壓成了一行,這是一個怎麼樣的程式碼……
總結
我不寫總結,就醬。