【翻譯】ECMAScript裝飾器的簡單指南

騰訊IVWEB團隊發表於2018-07-19

簡要介紹JavaScript中的“裝飾器”的提案的一些基礎示例以及ECMAScript相關的內容

為什麼用ECMAScript裝飾器代替標題中的JavaScript裝飾器? 因為ECMAScript是用於編寫指令碼語言(如JavaScript)的標準,所以它不強制JavaScript支援所有規範,但JavaScript引擎(由不同瀏覽器使用)可能支援或不支援由ECMAScript引入的功能,或者支援一些不同的行為。

將ECMAScript視為您所說的某種語言,例如英語。 那麼JavaScript就像英式英語一樣。 方言本身就是一種語言,但是它是基於它所源自的語言的原則而應運而生。 因此,ECMAScript是烹飪/書寫JavaScript的“烹飪書”,由主廚/開發人員決定遵循或不遵守所有配料/規則。

通常而言,JavaScript採用者遵循用語言編寫的所有規範(不然開發人員將會被逼瘋),並在新版本的JavaScript引擎出現後,並且直到確保一切正常,才會釋出它。 ECMA International的TC39或技術委員會39負責維護ECMAScript語言規範。 一般來說,該團隊的成員是由ECMA International、瀏覽器供應商和對網路感興趣的公司而組成。

由於ECMAScript是開放標準,任何人都可以提出新的想法或功能,並對其進行推動實行。 因此,一個新功能的提案會經歷4個主要階段,並且TC39會參與這個過程,直到該功能準備好施行。

階段 名稱 任務
0 strawman 提出新功能(建議) 到TC39委員會。 一般由TC39成員或TC39撰稿人提供。
1 proposal 定義提案,依賴,挑戰,示例,polyfills等使用用例。某個擁護者(TC39成員)將負責此提案。
2 draft 這是最終版本的草稿版本。 因此需要提供該功能的描述和語法。另外 例如Babel這樣的語法編譯器需要進行支援。
3 candidate 提案已經準備就緒,可以針對採用者和TC39委員會提出的關鍵問題做出一些修訂。
4 finished 提案已經準備被納入規範中

直到現在(2018年6月),裝飾器處於第二階段,我們做了一個Babel外掛babel-plugin-transform-decorators-legacy來轉化裝飾器功能。在第二階段,功能的語法可能會改變,因此不建議在現在的生產專案中使用這個功能。無論如何,我覺得裝飾器在快速達成目標上都是優雅的和有效的。

從現在開始,我們試驗實驗性質的JavaScript, 因此你的node.js的版本可能不支援這些功能。所以,我們會需要Babel或者TypeScript等語法編譯器。使用js-plugin-starter外掛來建立一個非常基本的專案,我在裡面加了些東西來支援這片文章。


為了理解裝飾器,我們需要首先理解什麼是JavaScript物件屬性的property descriptor。 property descriptor是一個物件屬性的一組規則,例如屬性是可寫的還是可列舉的。 當我們建立一個簡單的物件並新增一些屬性時,每個屬性都有預設的property descriptor。

var myObj = {
    myPropOne: 1,
    myPropTwo: 2
};
複製程式碼

myObj是如下控制檯所示的一個簡單JavaScript物件。

Alt text

現在,如果我們向下面的myPropOne屬性寫入新值,操作將會成功,我們將得到更改後的值。

myObj.myPropOne = 10;
console.log( myObj.myPropOne ); //==> 10
複製程式碼

要獲取屬性的property descriptor,我們需要使用Object.getOwnPropertyDescriptor(obj,propName)方法。 這裡的Own表示僅當屬性屬於物件obj而不屬於原型鏈時才返回propName屬性的property descriptor。

let descriptor = Object.getOwnPropertyDescriptor(
    myObj,
    'myPropOne'
);
console.log( descriptor );
複製程式碼

Alt text

Object.getOwnPropertyDescriptor方法返回一個具有描述屬性許可權和當前狀態的鍵的物件。 value是屬性的當前值,writable是使用者是否可以為屬性賦予新值,enumerable是該屬性是否會在如for in迴圈或for of迴圈或Object.keys等列舉中顯示。configurable的是使用者是否具有更改property descriptor的許可權,並對writableenumerable進行更改。 property descriptor也有getset中介軟體函式來返回值或更新值的鍵,但這些是可選的。

要在物件上建立新屬性或使用自定義descriptor更新現有屬性,我們使用Object.defineProperty。 讓我們修改一個現有屬性myPropOne,其中的writable屬性設定為false,這會禁止寫入myObj.myPropOne

'use strict';
var myObj = {
    myPropOne: 1,
    myPropTwo: 2
};
// modify property descriptor
Object.defineProperty( myObj, 'myPropOne', {
    writable: false
} );
// print property descriptor
let descriptor = Object.getOwnPropertyDescriptor(
    myObj, 'myPropOne'
);
console.log( descriptor );
// set new value
myObj.myPropOne = 2;
複製程式碼

Alt text

從上面的錯誤可以看出,我們的屬性myPropOne是不可寫的,因此如果使用者試圖為其分配新值,它將丟擲錯誤。

如果Object.defineProperty正在更新現有property descriptor,則原始的descriptor將被新的修改覆蓋。 更改之後,Object.defineProperty返回原始物件myObj

下面再看一下如果enumerable被設定成false後會發生什麼?

var myObj = {
    myPropOne: 1,
    myPropTwo: 2
};
// modify property descriptor
Object.defineProperty( myObj, 'myPropOne', {
    enumerable: false
} );
// print property descriptor
let descriptor = Object.getOwnPropertyDescriptor(
    myObj, 'myPropOne'
);
console.log( descriptor );
// print keys
console.log(
    Object.keys( myObj )
);
複製程式碼

Alt text

正如你看到的那樣,在Object.keys的列舉中,我們看不見myPropOne這個屬性了。

當你用Object.defineProperty定義一個物件的新屬性的時候,傳遞一個空的{}descriptor,預設的descriptor會看起來向下面的那樣。

Alt text

現在,讓我們定義一個帶有自定義descriptor的新屬性,其中configurable設為falsewritable保持為falseenumerabletrue,並將valu設為3。

var myObj = {
    myPropOne: 1,
    myPropTwo: 2
};
// modify property descriptor
Object.defineProperty( myObj, 'myPropThree', {
    value: 3,
    writable: false,
    configurable: false,
    enumerable: true
} );
// print property descriptor
let descriptor = Object.getOwnPropertyDescriptor(
    myObj, 'myPropThree'
);
console.log( descriptor );
// change property descriptor
Object.defineProperty( myObj, 'myPropThree', {
    writable: true
} );
複製程式碼

Alt text

通過將configurable設定為false,我們失去了更改屬性myPropThreedescriptor的能力。 如果不希望使用者操縱物件的預設行為,這非常有用。

get(getter)和set(setter)屬性也可以在property descriptor中設定。 但是當你定義一個getter時,它會帶來一些損失。 descriptor上不能有初始值或值鍵,因為getter會返回該屬性的值。 您也不能在descriptor上使用writable屬性,因為您的寫入是通過setter完成的,您可以在那裡阻止寫入。 可以看看相關gettersetter的MDN文件,或閱讀此文,這裡不多作贅訴。

您可以使用帶有兩個引數的Object.defineProperties一次建立和/或更新多個屬性。 第一個引數是屬性被新增/修改的目標物件,第二個引數是屬性名作為key,值為property descriptor的物件。 該函式返回第一個目標物件。

你有沒有嘗試過Object.create函式來建立物件? 這是建立沒有或自定義原型的物件的最簡單方法。 它也是使用自定義property descriptor從頭開始建立物件的更簡單的方法之一。 以下是Object.create函式的語法。

var obj = Object.create( prototype, { property: descriptor, ... } )
複製程式碼

這裡的prototype是一個物件,它將成為obj的原型。 如果原型為null,那麼obj將不會有任何原型。 當用var obj = {}定義一個空或非空物件時,預設情況下,obj .__ proto__指向Object.prototype,因此obj具有Object類的原型。

這與使用Object.create,用Object.prototype作為第一個引數(正在建立的物件的原型)類似。

'use strict';
var o = Object.create( Object.prototype, {
    a: { value: 1, writable: false },
    b: { value: 2, writable: true }
} );
console.log( o.__proto__ );
console.log( 
    'o.hasOwnProperty( "a" ) =>  ', 
    o.hasOwnProperty( "a" ) 
);
複製程式碼

Alt text

但是當我們將原型設定為null時,我們會得到以下錯誤。

'use strict';
var o = Object.create( null, {
    a: { value: 1, writable: false },
    b: { value: 2, writable: true }
} );
console.log( o.__proto__ );
console.log( 
    'o.hasOwnProperty( "a" ) =>  ', 
    o.hasOwnProperty( "a" ) 
);
複製程式碼

Alt text


###Class Method Decorator 現在我們瞭解瞭如何定義和配置物件的新屬性或現有屬性,讓我們將注意力轉移到裝飾器上,以及為什麼我們討論了property descriptor

Decorator是一個JavaScript函式(推薦的純函式),用於修改類屬性/方法或類本身。 當您在類屬性,方法或類本身的頂部新增@decoratorFunction語法時,decoratorFunction由一些引數來呼叫,我們可以使用它們修改類或類的屬性。 讓我們建立一個簡單的readonly裝飾器功能。 但在此之前,讓我們使用getFullName方法建立簡單的User類,該方法通過組合firstNamelastName來返回使用者的全名。

class User {
    constructor( firstname, lastName ) {
        this.firstname = firstname;
        this.lastName = lastName;
    }
    getFullName() {
        return this.firstname + ' ' + this.lastName;
    }
}
// create instance
let user = new User( 'John', 'Doe' );
console.log( user.getFullName() );
複製程式碼

上面的程式碼列印John Doe到控制檯。 但是存在巨大的問題,任何人都可以修改getFullName方法。

User.prototype.getFullName = function() {
    return 'HACKED!';
}
複製程式碼

於是,現在我們得到了以下結果。

HACKED!
複製程式碼

為了避免公共訪問覆蓋我們的任何方法,我們需要修改位於User.prototype物件上的getFullName方法的property descriptor

Object.defineProperty( User.prototype, 'getFullName', {
    writable: false
} );
複製程式碼

現在,如果任何使用者嘗試覆蓋getFullName方法,將會得到以下錯誤。

Alt text

但是,如果我們在User類中有很多方法,那麼手動執行這些操作就不會那麼好。 這就是裝飾者的由來。我們可以通過在下面的getFullName方法的頂部放置@readonly語法來實現同樣的事情。

function readonly( target, property, descriptor ) {
    descriptor.writable = false;
    return descriptor;
}
class User {
    constructor( firstname, lastName ) {
        this.firstname = firstname;
        this.lastName = lastName;
    }
    @readonly
    getFullName() {
        return this.firstname + ' ' + this.lastName;
    }
}
User.prototype.getFullName = function() {
    return 'HACKED!';
}
複製程式碼

看看readonly方法。 它接受三個引數。 property是屬於目標物件的屬性/方法的名稱(與User.prototype相同),descriptor是該屬性的property descriptor。 從裝飾器功能中,我們必須不惜代價返回descriptor。 這裡的descriptor將替換該屬性的現有property descriptor

還有另一個版本的裝飾器語法,就像@decoratorWrapperFunction(... customArgs)一樣。 但是在這個語法中,decoratorWrapperFunction應該返回一個與之前示例中使用的相同的decoratorFunction

function log( logMessage ) {
    // return decorator function
    return function ( target, property, descriptor ) {
        // save original value, which is method (function)
        let originalMethod = descriptor.value;
        // replace method implementation
        descriptor.value = function( ...args ) {
            console.log( '[LOG]', logMessage );
            // here, call original method
            // `this` points to the instance
            return originalMethod.call( this, ...args );
        };
        return descriptor;
    }
}
class User {
    constructor( firstname, lastName ) {
        this.firstname = firstname;
        this.lastName = lastName;
    }
    @log('calling getFullName method on User class')
    getFullName() {
        return this.firstname + ' ' + this.lastName;
    }
}
var user = new User( 'John', 'Doe' );
console.log( user.getFullName() );
複製程式碼

Alt text

裝飾者不區分靜態和非靜態方法。 下面的程式碼能執行得很好,唯一會改變的是你如何訪問該方法。 這同樣適用於我們將在下面看到的Instance Field Decorators

@log('calling getVersion static method of User class')
static getVersion() {
    return 'v1.0.0';
}
console.log( User.getVersion() );
複製程式碼

Class Instance Field Decorator

到目前為止,我們已經看到使用@decorator@decorator(.. args)語法更改方法的property descriptor,但是公共/私有屬性(類例項欄位)呢? 與typescriptjava不同,JavaScript類沒有如我們所知道的類例項欄位類屬性。 這是因為在類中和建構函式外定義的任何東西都應該屬於類原型。 但是有一個新的方案使用公共和私人訪問修飾符來啟用類例項欄位,現在已經進入階段3,並且我們有對應的babel轉換器外掛。 讓我們定義一個簡單的User類,但是這次我們不需要為建構函式中的firstNamelastName設定預設值。

class User {
    firstName = 'default_first_name';
    lastName = 'default_last_name';
    constructor( firstName, lastName ) {
        if( firstName ) this.firstName = firstName;
        if( lastName ) this.lastName = lastName;
    }
    getFullName() {
        return this.firstName + ' ' + this.lastName;
    }
}
var defaultUser = new User();
console.log( '[defaultUser] ==> ', defaultUser );
console.log( '[defaultUser.getFullName] ==> ', defaultUser.getFullName() );
var user = new User( 'John', 'Doe' );
console.log( '[user] ==> ', user );
console.log( '[user.getFullName] ==> ', user.getFullName() );
複製程式碼

Alt text

現在,如果檢查User類的原型,將無法看到firstNamelastName屬性。

Alt text

類例項欄位是物件導向程式設計(OOP)的非常有用和重要的部分。 我們有這樣的提案是很好的,但“革命還尚未成功”啊各位。

與位於類原型的類方法不同,類例項欄位位於物件/例項上。 由於類例項欄位既不是類的一部分也不是它的原型,因此操作它的descriptor並不簡單。 Babel給我們的是類例項欄位的property descriptor上的初始化函式,而不是值鍵。 為什麼初始化函式而不是值,這個主題是爭論的,因為裝飾器處於第2階段,沒有釋出最終草案來概述這個,但你可以按照Stack Overflow上的這個答案來理解整個背景故事。

話雖如此,讓我們修改我們的早期的示例並建立簡單的@upperCase修飾器,它將改變類例項欄位的預設值的大小寫。

function upperCase( target, name, descriptor ) {
    let initValue = descriptor.initializer();
    descriptor.initializer = function(){
        return initValue.toUpperCase();
    }
    return descriptor;
}
class User {
    
    @upperCase
    firstName = 'default_first_name';
    
    lastName = 'default_last_name';
    constructor( firstName, lastName ) {
        if( firstName ) this.firstName = firstName;
        if( lastName ) this.lastName = lastName;
    }
    getFullName() {
        return this.firstName + ' ' + this.lastName;
    }
}
console.log( new User() );
複製程式碼

Alt text

我們也可以使用裝飾器函式和引數來使其更具可定製性。

function toCase( CASE = 'lower' ) {
    return function ( target, name, descriptor ) {
        let initValue = descriptor.initializer();
    
        descriptor.initializer = function(){
            return ( CASE == 'lower' ) ? 
            initValue.toLowerCase() : initValue.toUpperCase();
        }
    
        return descriptor;
    }
}
class User {
    @toCase( 'upper' )
    firstName = 'default_first_name';
    lastName = 'default_last_name';
    constructor( firstName, lastName ) {
        if( firstName ) this.firstName = firstName;
        if( lastName ) this.lastName = lastName;
    }
    getFullName() {
        return this.firstName + ' ' + this.lastName;
    }
}
console.log( new User() );
複製程式碼

descriptor.initializer函式由Babel內部使用來建立物件屬性的property descriptor的值。 該函式返回分配給類例項欄位的初始值。 在裝飾器內部,我們需要返回另一個返回最終值的初始化函式。

類例項欄位提案具有高度的實驗性,並且直到它進入第4階段之前很有可能它的語法可能會發生變化。 因此,將類例項欄位與裝飾器一起使用並不是一個好習慣。

Class Decorator

現在我們熟悉裝飾者可以做什麼。 它們可以改變類方法和類例項欄位的屬性和行為,使我們可以靈活地使用更簡單的語法動態實現這些內容。

類裝飾器與我們之前看到的裝飾器略有不同。 之前,我們使用property descriptor來修改屬性或方法的行為,但在類裝飾器的情況下,我們需要返回一個建構函式。

讓我們來了解一下建構函式是什麼。 在下面,JavaScript類只不過是一個函式,用於新增原型方法併為欄位定義一些初始值。

function User( firstName, lastName ) {
    this.firstName = firstName;
    this.lastName = lastName;
}
User.prototype.getFullName = function() {
    return this.firstName + ' ' + this.lastName;
}
let user = new User( 'John', 'Doe' );
console.log( user );
console.log( user.__proto__ );
console.log( user.getFullName() );
複製程式碼

Alt text

這裡有一篇很棒的文章,用JavaScript來理解這一點。

所以當我們呼叫new User時,User函式是通過我們傳遞的引數來呼叫的,結果我們得到了一個物件。 因此,User是一個建構函式。 順便說一句,JavaScript中的每個函式都是建構函式,因為如果你檢查function.prototype,你將獲得建構函式屬性。 只要我們在函式中使用new的關鍵字,我們應該期待得到一個物件的返回結果。

如果從建構函式返回有效的JavaScript物件,則將使用該值而不是使this分配建立的新物件。 這將打破原型鏈,因為重新調整的物件將不具有建構函式的任何原型方法。 考慮到這一點,讓我們關注類裝飾器可以做什麼。 類裝飾器必須位於類的頂部,就像之前我們在方法名稱或欄位名稱上看到裝飾器一樣。 這個裝飾器也是一個函式,但它應該返回一個建構函式或一個類。

假設我有一個簡單的User類,如下所示。

class User {
    constructor( firstName, lastName ) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
}
複製程式碼

我們的User類目前沒有任何方法。 如前所述,類裝飾器必須返回一個建構函式。

function withLoginStatus( UserRef ) {
    return function( firstName, lastName ) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.loggedIn = false;
    }
}
@withLoginStatus
class User {
    constructor( firstName, lastName ) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
}
let user = new User( 'John', 'Doe' );
console.log( user );
複製程式碼

Alt text

類裝飾器函式將接收目標類UserRef,它是上面示例中的User(應用了裝飾器的)中的User,並且必須返回一個建構函式。 這為裝飾者開啟了無限可能的大門。 因此類裝飾器比方法/屬性裝飾器更受歡迎。

上面的例子比較基礎,當我們的User類可能有大量的屬性和原型方法時,我們不想建立一個新的建構函式。 比較好的是,我們可以引用了裝飾器函式中的類,即UserRef。 我們可以從建構函式返回新類,並且該類將可以擴充套件User類(更準確地說UserRef類)。 因此,類也是一個建構函式,這是合法的。

function withLoginStatus( UserRef ) {
    return class extends UserRef {
        constructor( ...args ) {
            super( ...args );
            this.isLoggedIn = false;
        }
        setLoggedIn() {
            this.isLoggedIn = true;
        }
    }
}
@withLoginStatus
class User {
    constructor( firstName, lastName ) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
}
let user = new User( 'John', 'Doe' );
console.log( 'Before ===> ', user );
// set logged in
user.setLoggedIn();
console.log( 'After ===> ', user );
複製程式碼

Alt text

你可以通過將一個裝飾器放到另一個上面,鏈式地使用多個裝飾器。執行順序與他們出現的位置順序一致。

裝飾者是更快達成目標的巧妙方式。 不久的將來它們便會被新增到ECMAScript規範中。

翻譯自A minimal guide to ECMAScript Decorators, 祝好。


《IVWEB 技術週刊》 震撼上線了,關注公眾號:IVWEB社群,每週定時推送優質文章。

相關文章