我跟你說,我最討厭“簡介”這種文章了,要不是語文是體育老師教的,早就換標題了!
Decorators是ECMAScript現在處於Stage 1的一個提案。當然ECMAScript會有很多新的特性,特地介紹這一個是因為它能夠在實際的程式設計中提供很大的幫助,甚至於改變不少功能的設計。
先說說怎麼回事
如果光從概念上來介紹的話,官方是這麼說的:
Decorators make it possible to annotate and modify classes and properties at design time.
我翻譯一下:
裝飾器讓你可以在設計時對類和類的屬性進行註解和修改。
什麼鬼,說人話!
所以我們還是用一段程式碼來看一下好了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
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
和屬性或者物件字面量的屬性上,比如這樣也是可以的:
1 2 3 4 5 6 |
let foo = { @memoize getFooById(id) { // ... } }; |
不過注意放在物件字面量時,裝飾器後面不能寫分號,這是個比較怪異的問題,後面還會說到更怪異的情況,我也在和提案的作者溝通這是為啥。
之所以這麼分,是因為不同情況下,裝飾器接收的3個引數代表的意義並不相同。
裝飾器的3個引數是什麼
裝飾器接收3個引數,分別是target
、key
和descriptor
,他們各自分別是什麼值,用一段程式碼就能很容易表達出來:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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
上放物件或者陣列的思路是一致的,要避免這一問題。
裝飾器在什麼時候執行
既然裝飾器本身是一個函式,那麼自然要有函式被執行的時候。
現階段,裝飾器只能放在一個類或者一個物件上,我們可以用程式碼看一下什麼時候執行:
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 26 27 |
// 既然裝飾器是函式,我當然可以用函式工廠了 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() {} }; |
輸出如下:
1 2 3 4 5 6 7 |
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
屬性對應程式碼是類似這樣的:
1 2 3 4 5 6 |
class Foo { constructor() { let descriptor = Object.getPropertyDescriptor(this, 'foo'); this.foo = descriptor.initializer.call(this); } } |
所以我們也可以寫很簡單的裝飾器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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
這個屬性的修改。我們拿一些原始的例子來看,比如你要給一個物件宣告一個屬性:
1 2 3 4 5 6 7 |
let property = { enumerable: false, configurable: true, value: 3 }; Object.defineProperty(foo, 'foo', property); |
但是我們現在又不高興了,我們希望這個屬性是隻讀的,OK這是個非常簡單的問題:
1 2 3 4 5 6 7 8 |
let property = { writable: false, // 加一行解決問題 enumerable: false, configurable: true, value: 3 }; Object.defineProperty(foo, 'foo', property); |
但是有時候,我們面對幾百幾千個屬性,真心不想一個一個寫writable: false
,看著也不容易明白。或者這個descriptor
根本是其他地方給我們的,我們只有defineProperty
的權利,無法修改原來的東西,所以我們希望是這樣的:
1 |
Object.defineProperty(foo, 'foo', readOnly(property)); |
通過函式式的程式設計進行函式轉換,既能讀程式碼時就看出來這是隻讀的,又能用在所有以前的descriptor
上而不需要改以前的程式碼,將“定義”和“使用”分離了開來。
而裝飾器無非是將這件事放到了語法的層面上,我們有一個機會在類或者屬性、訪問器、方法定義的時候去修改它的descriptor
,這種對“後設資料”的修改使得我們有很大的靈活性,包括但不侷限於:
- 通過
descriptor.value
的修改直接給改成不同的值,適用於方法的裝飾器。 - 通過
descriptor.get
或descriptor.set
修改邏輯,適用於訪問器的裝飾器。 - 通過
descriptor.initializer
修改屬性值,適用於屬性的裝飾器。 - 修改
configurable
、writable
、enumerable
控制屬性本身的特性,常見的就是修改為只讀。
裝飾器是最後的修改descriptor
的機會,再往後如果configurable
被設為false
的話,就再也沒機會去改變這些後設資料了。
類裝飾器的使用
類裝飾器不大一樣,因為沒有descriptor
給你,你唯一能獲得的是類本身,也就是一個函式。
但是有了類本身,我們可以做一件事,就是繼承:
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 |
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
時執行。
參考程式碼如下,以一個簡單的登入表單為例:
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 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
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的回答能比較好地解釋這個區別。
幾個坑
在我看來,裝飾器現在有幾個坑得注意一下。
首先,語法上很奇怪,特別是在裝飾器後面的分號上。屬性、訪問器、方法的裝飾器後面是可以加分號的,並且個人推薦加上,不然你可能遇到這樣的問題:
1 2 3 4 |
class Foo { @bar [1 + 2]() {} } |
上面的程式碼到底是@bar
作為裝飾器的方法呢,還是@bar[1 + 2]()
後跟著一個空的Block{}
呢?
但是,放在類上的裝飾器,以及放在物件字面量的屬性、訪問器、方法上的裝飾器,是不能加分號的, 不然就是語法錯誤。我不明白為啥就不能加分號,以至於這個語法簡直精神分裂……
其次,如果你把裝飾器用在類的屬性上,建議一定加上分號,看看下面的程式碼:
1 2 3 4 |
class Foo { @bar foo = 1; } |
想一想如果因為特性比較新,壓縮工具一個沒做好沒給補上分號壓成了一行,這是一個怎麼樣的程式碼……
總結
我不寫總結,就醬。