JavaScript「修飾器」提案簡介,包含一些基本示例和 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 物件,在控制檯中如下所示:
現在,如果我們像下面那樣將新值寫入 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 );
複製程式碼
Object.getOwnPropertyDescriptor
方法返回一個物件,該物件包含描述屬性許可權和當前狀態的鍵。 value
表示屬性的當前值,writable
表示使用者是否可以為屬性賦值,enumerable
表示該屬性是否會出現在 for in
迴圈或 for of
迴圈或 Object.keys
等遍歷方法中。configurable
表示使用者是否有權更改屬性描述符並更改 writable
和 enumerable
。屬性描述符還有 get
和 set
鍵,它們是獲取值或設定值的中介軟體函式,但這兩個是可選的。
要在物件上建立新屬性或使用自定義描述符修改現有屬性,我們使用 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
保持為false
、enumerable
為 true
,並將 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
屬性描述符的能力。如果不希望使用者操作物件的行為,這將非常有用。
get(getter)和 set(setter)也可以在屬性描述符中設定。但是當你定義一個 getter 時,也會帶來一些犧牲。你根本不能在描述符上有初始值或 value
,因為 getter 將返回該屬性的值。你也不能在描述符上使用 writable
,因為你的寫操作是通過 setter 完成的,可以防止寫入。看看 MDN 文件關於 getter 和 setter,或閱讀這篇文章,這裡不需要太多解釋。
可以使用帶有兩個引數的
Object.defineProperties
方法一次建立/更新多個屬性描述符。第一個引數是目標物件,在其中新增/修改屬性,第二個引數是一個物件,其中key
為屬性名,value
是它的屬性描述符。此函式返回目標物件。
你是否嘗試過使用 Object.create
方法來建立物件?這是建立沒有原型或自定義原型物件最簡單方法。它也是使用自定義屬性描述符從頭開始建立物件的更簡單方法之一。
Object.create
方法具有以下語法:
var obj = Object.create( prototype, { property: descriptor, ... } )
複製程式碼
這裡 prototype
是一個物件,它將成為 obj
的原型。如果 prototype
是 null
,那麼 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
類,這個方法通過組合 firstName
和 lastName
返回使用者的全名。
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)
語法來修改類方法的屬性描述符,但如何修改 **公有/私有屬性(類例項欄位)**呢?
與 typescript
或 java
不同,JavaScript 類沒有類例項欄位或者說沒有類屬性。這是因為任何在 class
裡面、constructor
外面定義的都屬於類的原型。但也有一個新的提案,它提議使用 public
和 private
訪問修飾符來啟用類例項欄位,目前處於第 3 階段,也可以通過 babel transformer plugin 這個外掛來使用它。
定義一個簡單的 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)的重要組成部分。我們提出相應的提案很好,但故事遠未結束。
與類方法處於類的原型上不同,類例項欄位處於物件/例項上。由於類例項欄位既不是類的一部分也不是它原型的一部分,因此操作它的描述符有點困難。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 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。