[譯] ECMAScript 修飾器微指南

JavaScript「修飾器」提案簡介,包含一些基本示例和 ECMAScript 的一些示例

爲何標題是 ECMAScript 修飾器,而不是 JavaScript 修飾器?由於,ECMAScript 是編寫像 JavaScript 這種腳本語言的標準,它不強制 JavaScript 支持全部規範內容,JavaScript 引擎(不一樣瀏覽器使用不一樣引擎)不必定支持 ECMAScript 引入的功能,或者支持行爲不一致。javascript

能夠將 ECMAScript 理解爲咱們說的語言,好比英語。那 JavaScript 就是一種方言,相似英國英語。方言自己就是一種語言,但它是基於語言衍生出來的。因此,ECMAScript 是烹飪/編寫 JavaScript 的烹飪書,是否遵循其中全部成分/規則徹底取決於廚師/開發者。前端

理論上來講,JavaScript 使用者應該遵循語言規範中全部規則(開發者或許會瘋掉吧),但實際上新版 JavaScript 引擎很晚纔會實現這些規則,開發者要確保一切正常後(纔會切換)。TC39 也就是 ECMA 國際技術委員會第 39 號 負責維護 ECMAScript 語言規範。該團隊的成員大多來自於 ECMA 國際、瀏覽器廠商和對 Web 感興趣的公司。java

因爲 ECMAScript 是開放標準,任何人均可以提出新的想法或功能並對其進行處理。所以,新功能的提議將經歷 4 個主要階段,TC39 將參與此過程,直到該功能準備好發佈。node

+-------+-----------+----------------------------------------+  
| stage | name      | mission                                |  
+-------+-----------+----------------------------------------+  
| 0     | strawman  | Present a new feature (proposal)       |  
|       |           | to TC39 committee. Generally presented |  
|       |           | by TC39 member or TC39 contributor.    |  
+-------+-----------+----------------------------------------+  
| 1     | proposal  | Define use cases for the proposal,     |  
|       |           | dependencies, challenges, demos,       |  
|       |           | polyfills etc. A champion              |  
|       |           | (TC39 member) will be                  |  
|       |           | responsible for this proposal.         |  
+-------+-----------+----------------------------------------+  
| 2     | draft     | This is the initial version of         |  
|       |           | the feature that will be               |  
|       |           | eventually added. Hence description    |  
|       |           | and syntax of feature should           |  
|       |           | be presented. A transpiler such as     |  
|       |           | Babel should support and               |  
|       |           | demonstrate implementation.            |  
+-------+-----------+----------------------------------------+  
| 3     | candidate | Proposal is almost ready and some      |  
|       |           | changes can be made in response to     |  
|       |           | critical issues raised by adopters     |  
|       |           |  and TC39 committee.                   |  
+-------+-----------+----------------------------------------+  
| 4     | finished  | The proposal is ready to be            |  
|       |           | included in the standard.              |  
+-------+-----------+----------------------------------------+
複製代碼

如今(2018 年 6 月),修飾器提案正處於第二階段,咱們可使用 babel-plugin-transform-decorators-legacy 這個 Babel 插件來轉換它。在第二階段,因爲功能的語法會發生變化,所以不建議在生產環境中使用它。不管如何,修飾器都很優美,也有助於更快地完成任務。android

從如今開始,咱們要開始研究實驗性的 JavaScript 了,所以你的 node.js 版本可能不支持這個新特性。因此咱們須要使用 Babel 或 TypeScript 轉換器。可使用我準備的 js-plugin-starter 插件來設置項目,其中包括了這篇文章中用到的插件。ios


要理解修飾器,首先須要瞭解 JavaScript 對象屬性的屬性描述符屬性描述符是對象屬性的一組規則,例如屬性是可寫仍是可枚舉。當咱們建立一個簡單的對象並向其添加一些屬性時,每一個屬性都有默認的屬性描述符。git

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

myObj是一個簡單的 JavaScript 對象,在控制檯中以下所示:github

如今,若是咱們像下面那樣將新值寫入 myPropOne 屬性,操做能夠成功,咱們能夠得到更改後的值。typescript

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

爲了獲取屬性的屬性描述符,咱們須要使用 Object.getOwnPropertyDescriptor(obj, propName) 方法。這裏 Own 的意思是隻有 propName 屬性是 obj 對象自有屬性而不是在原型鏈上查找的屬性時,纔會返回 propName 的屬性描述符。編程

let descriptor = Object.getOwnPropertyDescriptor(  
    myObj,  
    'myPropOne'  
);

console.log( descriptor );
複製代碼

Object.getOwnPropertyDescriptor 方法返回一個對象,該對象包含描述屬性權限和當前狀態的鍵。 value 表示屬性的當前值,writable 表示用戶是否能夠爲屬性賦值,enumerable 表示該屬性是否會出如今 for in 循環或 for of 循環或 Object.keys 等遍歷方法中。configurable 表示用戶是否有權更改屬性描述符並更改 writableenumerable。屬性描述符還有 getset 鍵,它們是獲取值或設置值的中間件函數,但這兩個是可選的。

要在對象上建立新屬性或使用自定義描述符修改現有屬性,咱們使用 Object.defineProperty 方法。讓咱們修改 myPropOne 這個現有屬性,writable 設置爲 false,這會禁止myObj.myPropOne 寫入值。

'use strict';

var myObj = {  
    myPropOne: 1,  
    myPropTwo: 2  
};

// 修改屬性描述符  
Object.defineProperty( myObj, 'myPropOne', {  
    writable: false  
} );

// 打印屬性描述符  
let descriptor = Object.getOwnPropertyDescriptor(  
    myObj, 'myPropOne'  
);  
console.log( descriptor );

// 設置新值  
myObj.myPropOne = 2;
複製代碼

從上面的報錯中能夠看出,myPropOne 屬性是不可寫入的。所以若是用戶嘗試給它賦予新值,就會拋出錯誤。

若是使用 Object.defineProperty 來修改現有屬性的描述符,那原始描述符會被新的修改覆蓋Object.defineProperty 方法會返回修改後的 myObj 對象。

讓咱們看看若是將 enumerable 描述符鍵設置爲 false 會發生什麼。

var myObj = {  
    myPropOne: 1,  
    myPropTwo: 2  
};

// 修改描述符  
Object.defineProperty( myObj, 'myPropOne', {  
    enumerable: false  
} );

// 打印描述符  
let descriptor = Object.getOwnPropertyDescriptor(  
    myObj, 'myPropOne'  
);  
console.log( descriptor );

// 打印遍歷對象  
console.log(  
    Object.keys( myObj )  
);
複製代碼

從上面的結果能夠看出,咱們在 Object.keys 枚舉中看不到對象的 myPropOne 屬性。

使用 Object.defineProperty 在對象上定義新屬性並傳遞空 {} 描述符時,默認描述符以下所示:

如今,讓咱們使用自定義描述符定義一個新屬性,其中 configurable 鍵設置爲 false。咱們將 writable 保持爲falseenumerabletrue,並將 value 設置爲 3

var myObj = {  
    myPropOne: 1,  
    myPropTwo: 2  
};

// 設置新屬性描述符  
Object.defineProperty( myObj, 'myPropThree', {  
    value: 3,  
    writable: false,  
    configurable: false,  
    enumerable: true  
} );

// 打印屬性描述符
let descriptor = Object.getOwnPropertyDescriptor(  
    myObj, 'myPropThree'  
);  
console.log( descriptor );

// 修改屬性描述符 
Object.defineProperty( myObj, 'myPropThree', {  
    writable: true  
} );
複製代碼

經過將 configurable 設置爲 false,咱們失去了更改 myPropThree 屬性描述符的能力。若是不但願用戶操做對象的行爲,這將很是有用。

getgetter)和 setsetter)也能夠在屬性描述符中設置。可是當你定義一個 getter 時,也會帶來一些犧牲。你根本不能在描述符上有初始值value,由於 getter 將返回該屬性的值。你也不能在描述符上使用 writable,由於你的寫操做是經過 setter 完成的,能夠防止寫入。看看 MDN 文檔關於 gettersetter,或閱讀這篇文章,這裏不須要太多解釋。

可使用帶有兩個參數的 Object.defineProperties 方法一次建立/更新多個屬性描述符。第一個參數是目標對象,在其中添加/修改屬性,第二個參數是一個對象,其中 key屬性名value 是它的屬性描述符。此函數返回目標對象。

你是否嘗試過使用 Object.create 方法來建立對象?這是建立沒有原型或自定義原型對象最簡單方法。它也是使用自定義屬性描述符從頭開始建立對象的更簡單方法之一。

Object.create 方法具備如下語法:

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

這裏 prototype 是一個對象,它將成爲 obj 的原型。若是 prototypenull,那麼 obj 將沒有任何原型。使用 var obj = {} 語法定義空或非空對象時,默認狀況下,obj.__proto__ 指向 Object.prototype,所以 obj 具備 Object類的原型。

這相似於用 Object.prototype 做爲第一個參數(正在建立對象的原型)使用 Object.create 方法 。

'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" )   
);
複製代碼

但當咱們把 prototype 參數設置爲 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" )   
);
複製代碼


✱ 類方法修飾器

如今咱們已經瞭解瞭如何定義/配置對象的新屬性/現有屬性,讓咱們把注意力轉移到修飾器以及爲何討論屬性描述符上。

修飾器是一個 JavaScript 函數(建議是純函數),它用於修改類屬性/方法或類自己。當你在類屬性方法類自己頂部添加 @decoratorFunction 語法後,decoratorFunction 方法會以一些參數被調用,而後就可使用這些參數來修改類或類屬性了

讓咱們建立一個簡單的 readonly修飾器函數。但在此以前,先建立一個包含 getFullName 方法簡單的 User 類,這個方法經過組合 firstNamelastName 返回用戶的全名。

class User {  
    constructor( firstname, lastName ) {  
        this.firstname = firstname;  
        this.lastName = lastName;  
    }

    getFullName() {  
        return this.firstname + ' ' + this.lastName;  
    }  
}

// 建立實例  
let user = new User( 'John', 'Doe' );  
console.log( user.getFullName() );
複製代碼

運行上面的代碼,控制檯中會打印出 John Doe。但這樣有一個問題:任何人均可以修改 getFullName 方法。

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

通過上面的修改,就會獲得如下輸出:

HACKED!
複製代碼

爲了限制修改咱們任何方法的權限,須要修改 getFullName 方法的屬性描述符,這個屬性屬於 User.prototype 對象。

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

如今,若是還有用戶嘗試覆蓋 getFullName 方法,他/她就會獲得下面的錯誤。

但若是 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 是屬性/方法的名字,target 是這些屬性/方法屬於的對象(就和 User.prototype 同樣),descriptor 是這個屬性的描述符。在修飾器函數中,咱們必須返回 descriptor 對象。這個修改後的 descriptor 會替換該屬性原來的屬性描述符。

修飾器寫法還有另外一種版本,相似 @decoratorWrapperFunction( ...customArgs ) 這樣。但這樣寫,decoratorWrapperFunction 函數應該返回一個 decoratorFunction 修飾器函數,它的使用和上面的例子相同。

function log( logMessage ) {
    // 返回修飾器函數
    return function ( target, property, descriptor ) {
        // 保存屬性原始值,它是一個方法(函數)
        let originalMethod = descriptor.value;
        // 修改方法實現
        descriptor.value = function( ...args ) {
            console.log( '[LOG]', logMessage );
            // 這裏,調用原始方法
            // `this` 指向調用實例
            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() );
複製代碼

修飾器不區分靜態和非靜態方法。下面的代碼一樣能夠工做,惟一不一樣是你如何訪問這些方法。這個結論也適用於咱們下面要討論的類實例字段修飾器

@log('calling getVersion static method of User class')  
static getVersion() {  
    return 'v1.0.0';  
}

console.log( User.getVersion() );
複製代碼

類實例字段修飾器

目前爲止,咱們已經看到經過 @decorator@decorator(..args) 語法來修改類方法的屬性描述符,但如何修改 **公有/私有屬性(類實例字段)**呢?

typescriptjava 不一樣,JavaScript 類沒有類實例字段或者說沒有類屬性。這是由於任何在 class 裏面、constructor 外面定義的都屬於類的原型。但也有一個新的提案,它提議使用 publicprivate 訪問修飾符來啓用類實例字段,目前處於第 3 階段,也能夠經過 babel transformer plugin 這個插件來使用它。

定義一個簡單的 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() );
複製代碼

如今,若是查看 User 類的原型,你不會看到 firstNamelastName 這兩個屬性。

類實例字段很是有用,仍是面向對象編程(OOP)的重要組成部分。咱們提出相應的提案很好,但故事遠未結束。

類方法處於類的原型上不一樣,類實例字段處於對象/實例上。因爲類實例字段既不是類的一部分也不是它原型的一部分,所以操做它的描述符有點困難。Babel 爲類實例字段的屬性描述符提供了 initializer 方法來替代 value。爲何要用 initializer 方法來替代 value 呢?這個問題有些爭議,由於修飾器提案還處於第二階段,尚未發佈最終草案來講明這個問題,但你能夠經過查看 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() );
複製代碼

咱們也可使用帶參數的修飾器函數,讓它更有定製性。

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 內部實現對象屬性描述符的 value 的建立。它會返回分配給類實例字段的初始值。在修飾器函數內部,咱們須要返回另外一個 initializer 方法,它會返回最終值。

類實例字段提案具備高度實驗性,在到達第 4 階段前,它的語法頗有可能會改變。所以,將類實例字段與修飾器一塊兒使用還不是一個好習慣。


✱ 類修飾器

如今咱們已經熟悉了修飾器能作什麼。它能夠改變屬性、類方法行爲和類實例字段,使咱們能靈活地經過簡單的語法來實現這些。

類修飾器和咱們以前看到的修飾器有些不一樣。以前,咱們使用屬性修飾器來修改屬性或方法的實現,但類修飾器函數中,咱們須要返回一個構造函數。

咱們先來理解下什麼是構造函數。在下面,一個 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() );
複製代碼

這篇文章 對理解 JavaScript 中的 this 頗有幫助。

所以,當咱們調用 new User 時,就會使用傳遞的參數調用 User 這個函數,返回結果是一個對象。因此,User 就是一個構造函數。順便說一句,JavaScript 中每一個函數都是一個構造函數,由於若是你查看 function.prototype,你會發現 constructor 屬性。只要咱們使用 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 );
複製代碼

類修飾器函數會接收目標類 UserRef,在上面的示例中是 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 );
// 設置爲已登陸
user.setLoggedIn();
console.log( 'After ===> ', user );
複製代碼


你能夠將多個修飾器放在一塊兒,執行順序和它們外觀順序一致。


修飾器是更快地達到目的的奇特方式。在它們正式加入 ECMAScript 規範以前,咱們先期待一下吧。

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索