[譯] ECMAScript 修飾器微指南

jonjia發表於2018-07-22

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

[譯] ECMAScript 修飾器微指南

為什麼標題是 ECMAScript 修飾器,而不是 JavaScript 修飾器?因為,ECMAScript 是編寫像 JavaScript 這種指令碼語言的標準,它不強制 JavaScript 支援所有規範內容,JavaScript 引擎(不同瀏覽器使用不同引擎)不一定支援 ECMAScript 引入的功能,或者支援行為不一致。

可以將 ECMAScript 理解為我們說的語言,比如英語。那 JavaScript 就是一種方言,類似英國英語。方言本身就是一種語言,但它是基於語言衍生出來的。所以,ECMAScript 是烹飪/編寫 JavaScript 的烹飪書,是否遵循其中所有成分/規則完全取決於廚師/開發者。

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

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

+-------+-----------+----------------------------------------+  
| 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 外掛來轉換它。在第二階段,由於功能的語法會發生變化,因此不建議在生產環境中使用它。無論如何,修飾器都很優美,也有助於更快地完成任務。

從現在開始,我們要開始研究實驗性的 JavaScript 了,因此你的 node.js 版本可能不支援這個新特性。所以我們需要使用 Babel 或 TypeScript 轉換器。可以使用我準備的 js-plugin-starter 外掛來設定專案,其中包括了這篇文章中用到的外掛。


要理解修飾器,首先需要了解 JavaScript 物件屬性的屬性描述符屬性描述符是物件屬性的一組規則,例如屬性是可寫還是可列舉。當我們建立一個簡單的物件並向其新增一些屬性時,每個屬性都有預設的屬性描述符。

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

myObj是一個簡單的 JavaScript 物件,在控制檯中如下所示:

[譯] ECMAScript 修飾器微指南

現在,如果我們像下面那樣將新值寫入 myPropOne 屬性,操作可以成功,我們可以獲得更改後的值。

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 );
複製程式碼

[譯] ECMAScript 修飾器微指南

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;
複製程式碼

[譯] ECMAScript 修飾器微指南

從上面的報錯中可以看出,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 )  
);
複製程式碼

[譯] ECMAScript 修飾器微指南

從上面的結果可以看出,我們在 Object.keys 列舉中看不到物件的 myPropOne 屬性。

使用 Object.defineProperty 在物件上定義新屬性並傳遞空 {} 描述符時,預設描述符如下所示:

[譯] ECMAScript 修飾器微指南

現在,讓我們使用自定義描述符定義一個新屬性,其中 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  
} );
複製程式碼

[譯] ECMAScript 修飾器微指南

通過將 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" )   
);
複製程式碼

[譯] ECMAScript 修飾器微指南

但當我們把 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" )   
);
複製程式碼

[譯] ECMAScript 修飾器微指南


✱ 類方法修飾器

現在我們已經瞭解瞭如何定義/配置物件的新屬性/現有屬性,讓我們把注意力轉移到修飾器以及為什麼討論屬性描述符上。

修飾器是一個 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 方法,他/她就會得到下面的錯誤。

[譯] ECMAScript 修飾器微指南

但如果 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() );
複製程式碼

[譯] ECMAScript 修飾器微指南

修飾器不區分靜態和非靜態方法。下面的程式碼同樣可以工作,唯一不同是你如何訪問這些方法。這個結論也適用於我們下面要討論的類例項欄位修飾器

@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() );
複製程式碼

[譯] ECMAScript 修飾器微指南

現在,如果檢視 User 類的原型,你不會看到 firstNamelastName 這兩個屬性。

[譯] ECMAScript 修飾器微指南

類例項欄位非常有用,還是物件導向程式設計(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() );
複製程式碼

[譯] ECMAScript 修飾器微指南

我們也可以使用帶引數的修飾器函式,讓它更有定製性。

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() );
複製程式碼

[譯] ECMAScript 修飾器微指南

這篇文章 對理解 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 );
複製程式碼

[譯] ECMAScript 修飾器微指南

類修飾器函式會接收目標類 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 修飾器微指南


你可以將多個修飾器放在一起,執行順序和它們外觀順序一致。


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

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


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

相關文章