歡迎光臨
每天分享高質量文章

ES Decorators簡介

作者:百度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方法加一個快取,快取使用第一個引數作為對應的鍵。

可以看出來,上面程式碼的重點在於:

  1. 有一個memoize函式。
  2. 在類的某個方法上加了@memoize;這樣一個標記。

而這個@memoize就是所謂的Decorator,我稱之為裝飾器。一個裝飾器有以下特點:

  1. 首先它是一個函式。
  2. 這個函式會接收3個引數,分別是target、key和descriptor,具體的作用後面再說。
  3. 它可以修改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的輸出,從這裡可以看到:

  1. key很明顯就是當前方法名,我們可以推斷出來用於屬性的時候就是屬性名
  2. descriptor顯然是一個PropertyDescriptor,就是我們用於defineProperty時的那個東西。
  3. target確實不是那麼容易看出來,所以我用了3行程式碼。首先這是一個物件,然後是一個有constructor屬性的物件,最後constructur指向的是Bar這個函式。所以我們也能推測出來這貨就是Bar.prototype沒跑了。

那如果裝飾器放在物件字面量上,而不是類上呢?這邊就不再給程式碼,直接放結論了:

key和descriptor和放在類屬性/方法上一樣沒變,這當然也不應該變。

target是Object物件,相信我你不會想用這個引數的。

當裝飾器放在屬性、方法、訪問器上時,都符合上面的原則,但放在類上的時候,有一些不同:

  1. key和descriptor不會提供,只有target引數。
  2. 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,這種對“元資料”的修改使得我們有很大的靈活性,包括但不侷限於:

  1. 透過descriptor.value的修改直接給改成不同的值,適用於方法的裝飾器。
  2. 透過descriptor.get或descriptor.set修改邏輯,適用於訪問器的裝飾器。
  3. 透過descriptor.initializer修改屬性值,適用於屬性的裝飾器。
  4. 修改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;

}

想一想如果因為特性比較新,壓縮工具一個沒做好沒給補上分號壓成了一行,這是一個怎麼樣的程式碼……

總結

我不寫總結,就醬。

贊(0)

分享創造快樂