簡要介紹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物件。
現在,如果我們向下面的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 );
複製程式碼
Object.getOwnPropertyDescriptor
方法返回一個具有描述屬性許可權和當前狀態的鍵的物件。 value
是屬性的當前值,writable
是使用者是否可以為屬性賦予新值,enumerable
是該屬性是否會在如for in
迴圈或for of
迴圈或Object.keys
等列舉中顯示。configurable
的是使用者是否具有更改property descriptor
的許可權,並對writable
和enumerable
進行更改。 property descriptor
也有get
和set
中介軟體函式來返回值或更新值的鍵,但這些是可選的。
要在物件上建立新屬性或使用自定義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;
複製程式碼
從上面的錯誤可以看出,我們的屬性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 )
);
複製程式碼
正如你看到的那樣,在Object.keys
的列舉中,我們看不見myPropOne
這個屬性了。
當你用Object.defineProperty
定義一個物件的新屬性的時候,傳遞一個空的{}descriptor
,預設的descriptor
會看起來向下面的那樣。
現在,讓我們定義一個帶有自定義descriptor
的新屬性,其中configurable
設為false
,writable
保持為false
,enumerable
為true
,並將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
} );
複製程式碼
通過將configurable
設定為false,我們失去了更改屬性myPropThree
的descriptor
的能力。 如果不希望使用者操縱物件的預設行為,這非常有用。
get(getter)和set(setter)屬性也可以在property descriptor
中設定。 但是當你定義一個getter時,它會帶來一些損失。 descriptor
上不能有初始值或值鍵,因為getter會返回該屬性的值。 您也不能在descriptor
上使用writable
屬性,因為您的寫入是通過setter完成的,您可以在那裡阻止寫入。 可以看看相關getter和setter的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" )
);
複製程式碼
但是當我們將原型設定為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" )
);
複製程式碼
###Class Method Decorator
現在我們瞭解瞭如何定義和配置物件的新屬性或現有屬性,讓我們將注意力轉移到裝飾器上,以及為什麼我們討論了property descriptor
。
Decorator
是一個JavaScript函式(推薦的純函式),用於修改類屬性/方法或類本身。 當您在類屬性,方法或類本身的頂部新增@decoratorFunction
語法時,decoratorFunction
由一些引數來呼叫,我們可以使用它們修改類或類的屬性。
讓我們建立一個簡單的readonly
裝飾器功能。 但在此之前,讓我們使用getFullName
方法建立簡單的User
類,該方法通過組合firstName
和lastName
來返回使用者的全名。
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
方法,將會得到以下錯誤。
但是,如果我們在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() );
複製程式碼
裝飾者不區分靜態和非靜態方法。 下面的程式碼能執行得很好,唯一會改變的是你如何訪問該方法。 這同樣適用於我們將在下面看到的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
,但是公共/私有屬性(類例項欄位)呢?
與typescript
或java
不同,JavaScript類沒有如我們所知道的類例項欄位類屬性。 這是因為在類中和建構函式外定義的任何東西都應該屬於類原型。 但是有一個新的方案使用公共和私人訪問修飾符來啟用類例項欄位,現在已經進入階段3,並且我們有對應的babel轉換器外掛。
讓我們定義一個簡單的User
類,但是這次我們不需要為建構函式中的firstName
和lastName
設定預設值。
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
類的原型,將無法看到firstName
和lastName
屬性。
類例項欄位是物件導向程式設計(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() );
複製程式碼
我們也可以使用裝飾器函式和引數來使其更具可定製性。
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() );
複製程式碼
這裡有一篇很棒的文章,用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 );
複製程式碼
類裝飾器函式將接收目標類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 );
複製程式碼
你可以通過將一個裝飾器放到另一個上面,鏈式地使用多個裝飾器。執行順序與他們出現的位置順序一致。
裝飾者是更快達成目標的巧妙方式。 不久的將來它們便會被新增到ECMAScript規範中。
翻譯自A minimal guide to ECMAScript Decorators, 祝好。
《IVWEB 技術週刊》 震撼上線了,關注公眾號:IVWEB社群,每週定時推送優質文章。