> 原文:http://www.infoq.com/cn/articles/es6-in-depth-classes
[TOC]
你可能覺得之前講解的內容略顯復雜,今天我們就講解一些相對簡單的內容,不再是[生成器(Generator)](http://www.infoq.com/cn/articles/es6-in-depth-generators-continued/)這樣前所未聞的全新編碼方式,也不是諸如[代理(Proxy)](http://www.infoq.com/cn/articles/es6-in-depth-proxies-and-reflect/)這種為JavaScript內部算法工作原理提供鉤子的全能對象,更不是能夠為開發提供便利的新型數據結構。簡單來說,我們將要一起討論如何根據語言習慣簡化對象構造函數的創建過程。
## 目前面臨的問題
假如我們想要創建一個經典的面向對象設計示例:Circle類。想象一下我們正在為一個簡單的Canvas庫編寫這個Circle類,在眾多需要考慮的因素中,我們可能更想了解以下功能的實現方式:
* 在給定的Canvas上繪制一個給定圓。
* 跟蹤記錄生成圓的總數。
* 跟蹤記錄給定圓的半徑,以及如何使其值成為圓的[不變條件](https://zh.wikipedia.org/wiki/%E4%B8%8D%E5%8F%98%E6%9D%A1%E4%BB%B6)。
* 計算給定圓的面積。
按照目前常見的JS編碼風格,我們首先應該以函數的形式創建一個構造函數,然后給該函數添加任何我們可能想要的屬性,然后用一個對象替換構造函數的`prototype`屬性。這個`prototype`對象將包含構造函數創建的實例的所有初始化屬性。下面是一個簡單的示例,可以直接作為樣板文件(boilerplate)重復使用:
~~~
function Circle(radius) {
this.radius = radius;
Circle.circlesMade++;
}
Circle.draw = function draw(circle, canvas) { /* Canvas繪制代碼 */ }
Object.defineProperty(Circle, "circlesMade", {
get: function() {
return !this._count ? 0 : this._count;
},
set: function(val) {
this._count = val;
}
});
Circle.prototype = {
area: function area() {
return Math.pow(this.radius, 2) * Math.PI;
}
};
Object.defineProperty(Circle.prototype, "radius", {
get: function() {
return this._radius;
},
set: function(radius) {
if (!Number.isInteger(radius))
throw new Error("圓的半徑必須為整數。");
this._radius = radius;
}
});
~~~
這段代碼非常繁瑣且不符合人的直覺,要想讀懂必須對函數的運行方式有著非凡的掌握,然后你才能理解各種已裝載的屬性與生成的實例對象進行綁定的方式。如果這種方法看起來很復雜,不要擔心,這篇文章會為你展示一種更簡單的方法來實現所有這些功能。
## 方法定義語法
ES6提供一種向對象添加特殊屬性的新語法,可以幫助我們清理這些方法。給`Circle.prototype`添加`area`方法非常簡單,但是給`radius`添加getter/setter方法對就很難。隨著JS引入越來越多的面向對象方法,人們開始對簡化給對象添加訪問器的方法感興趣。我們需要一種功能類似`obj.prop = method`的新方法來給對象添加“方法”,同時不借助`Object.defineProperty`的力量。人們想要能夠簡單地實現以下功能:
* 給對象添加標準的函數屬性。
* 給對象添加生成器函數屬性。
* 給對象添加標準的訪問器函數屬性。
* 給對象添加任意使用`[]`語法添加的函數屬性,我們稱其為預計算(computed)屬性名。
其中一些功能在以前無法實現,例如:我們不能通過給`obj.prop`賦值來定義getter或setter。因此,我們亟需新語法來編寫以下代碼:
~~~
var obj = {
// 現在不再使用function關鍵字給對象添加方法
// 而是直接使用屬性名作為函數名稱。
method(args) { ... },
// 只需在標準函數的基礎上添加一個“*”,就可以聲明一個生成器函數。
*genMethod(args) { ... },
// 借助|get|和|set|可以在行內定義訪問器。
// 只是定義內聯函數,即使沒有生成器。
// 注意通過這種方式裝載的getter不能接受參數
get propName() { ... },
// 注意通過這種方式裝載的setter至少接受一個參數
set propName(arg) { ... },
// []語法可以用于任意支持預計算屬性名的地方,來滿足上面的第4中情況。
// 這意味著你可以使用symbol,調用函數,聯結字符串
// 或其它可以給property.id求值的表達式。
// 這個語法對訪問器或生成器同樣有效,我在這里只是舉個例子。
[functionThatReturnsPropertyName()] (args) { ... }
};
~~~
現在,我們可以用這種新語法重寫上面的代碼片段:
~~~
function Circle(radius) {
this.radius = radius;
Circle.circlesMade++;
}
Circle.draw = function draw(circle, canvas) { /* Canvas繪制代碼 */ }
Object.defineProperty(Circle, "circlesMade", {
get: function() {
return !this._count ? 0 : this._count;
},
set: function(val) {
this._count = val;
}
});
Circle.prototype = {
area() {
return Math.pow(this.radius, 2) * Math.PI;
},
get radius() {
return this._radius;
},
set radius(radius) {
if (!Number.isInteger(radius))
throw new Error("圓的半徑必須為整數。");
this._radius = radius;
}
};
~~~
講究地說,這段代碼與上面的代碼段并不完全相同,裝載后的對象字面量中的方法定義是可配置(configurable)和可枚舉(enumerable) 的,然而在第一段代碼段中卻不是這樣。事實上,很少有人會注意到這個問題,我決定為了簡潔起見暫時省略可枚舉性和可配置性。
不過,這段代碼依然變得更好了,不是么?不幸的是,即使有了新的方法定義語法,我們仍然不能武裝到牙齒,所以仍然需要通過定義函數來定義`Circle`類。沒有一種方法能夠讓你在定義函數時就獲取它的屬性。
## 類定義語法
盡管這比以前更好,但是它仍然不能滿足人們對于簡潔的JavaScript面向對象解決方案的渴望。在其它語言中,有一個句法結構可以用來處理面向對象設計的問題,經過一番討論后他們將其命名為類(Class)。
好吧,讓我們也來添加一些類。
我們需要這樣一個系統:給命名構造函數添加方法的同時給函數的`.prototype`屬性也添加相應方法,從而用這個類構造出的實例也包含相應的方法。既然我們掌握了一種嶄新的方法定義語言,我們一定要物盡其用。在類的所有實例中,我們只需要一種區分普通函數與特殊函數的方法,在C++或Java中,這種功能對應的關鍵字是`static`。這種方法看起來不錯,讓我們用起來!
我們還需要一個方法,可以在一堆方法中指定出唯一的構造函數。在C++或Java中,構造函數與類同名,并且沒有返回類型。既然JS沒有返回類型,我們無論如何都需要一個`.constructor`屬性來支持向后兼容性,你可以稱之為方法`構造函數`(method constructor)。
將所有的概念組合到一起后,我們可以重寫Circle類并實現所有功能:
~~~
class Circle {
constructor(radius) {
this.radius = radius;
Circle.circlesMade++;
};
static draw(circle, canvas) {
// Canvas繪制代碼
};
static get circlesMade() {
return !this._count ? 0 : this._count;
};
static set circlesMade(val) {
this._count = val;
};
area() {
return Math.pow(this.radius, 2) * Math.PI;
};
get radius() {
return this._radius;
};
set radius(radius) {
if (!Number.isInteger(radius))
throw new Error("圓的半徑必須為整數。");
this._radius = radius;
};
}
~~~
哇嗷!我們不僅可以實現`Circle`所需的功能,還能使代碼如此簡潔,這比剛開始好多了!
雖然如此,有的人有可能會遇到問題或碰到邊緣用例。我會嘗試著預測你們將會遇到的問題并一一解答:
* 分號是怎么回事?—— 在一次“打造傳統類”的嘗試中,我們決定編寫一個更傳統的分隔符。如果不喜歡可以不寫,分隔符是可選的。
* 如果我不想要一個構造函數,但是仍然想在創建的對象中放置方法呢?—— 好吧,`constructor`方法也是可選的,對象中會默認聲明一個空的構造函數`constructor() {}`。
* 可以用生成器作為`構造函數`么?—— 堅決不可以!構造器不是普通方法,隨意添加將會觸發`類型錯誤(TypeError)`,這條規則同樣適用于生成器和訪問器。
* 我可以用預計算屬性名來定義`構造函數`么?—— 很不幸的是不可以!那將會變得很難預測,所以我們不去嘗試。如果你用預計算屬性名定義一個方法來命名`構造函數`,你將得到一個名為`constructor`的方法,它就不是類的構造函數了。
* 如果我改變了`Circle`的值,會導致`new Circle`的行為異常么?—— 不會!類與函數表達式類似,會得到一個給定名稱的內部綁定,這個綁定不會被外部力量改變,所以無論你在外圍作用域給`Circle`變量設置什么值,構造器中的`Circle.circlesMade++`依然會像預期一樣運行。
* 好的,但是我可能直接給函數傳一個對象字面量作為參數,類是不是就不能實例化了?—— 幸運的是,ES6中也支持類表達式!可以是命名或匿名表達式,且行為與上述一致,唯一的區別是它們不會在你聲明它們的作用域中創建變量。
* 上面提到的可枚舉性、可配置性又如何解釋呢?—— 人們希望在類中裝載的方法是可配置、不可枚舉的。一來你可以在對象中裝載方法,二來當你枚舉對象屬性時,不會將裝載的方法枚舉出來,得到的只是附加的數據屬性,這樣做是有道理的。
* 嗨,等等……什么……?我的實例變量在哪兒?`靜態`和常量呢?—— 好吧,你問住我了。ES6目前的定義中不存在相關信息。但是有個好消息,在諸多的規范進程中,我強烈支持在類語法中加入可選的`static`和`const`關鍵字,該提案已經正式向規范會議遞交并處于議程中,我認為我們可以期待在未來會產生更多的相關討論。
* 好的,即使這樣,這些內容都很贊!我們現在可以使用這些技術么?—— 不完全可以。目前,你們可以借助polyfill(尤其是[Babel](https://babeljs.io/))來熟悉特性的相關語法,等到所有主流瀏覽器原生支持還需要一段時間。我已經在[Firefox的Nightly版本](https://nightly.mozilla.org/)中實現了我們所討論的所有特性;同樣,這些特性在Edge和Chrome中也已實現,只是默認不啟用;目前Safari尚未實現相關特性。
* 在這里我們沒有提及Java和C+++中的子類(subclassing)和`super`關鍵字,JS也有么?– 是的,它有!我們完全可以在另一篇文章中詳細討論,后續歡迎回來與我們一起探索子類,挖掘更多JavaScript類實現的強大之處。
感謝[Jason Orendorff](https://hacks.mozilla.org/author/jorendorffmozillacom/)和[Jeff Walden](https://whereswalden.com/)引導我設計這一功能并為我所有的實現做代碼審查,正是有了他們我才能實現類的相關特性。
下一次,Jason Orendorff將會為我們深入淺出講解let和const,歡迎繼續加入我們!