對於javascript來講,類是一種可選(而不是必須)的設計模式,並且在JavaScript這樣的[[Prototype]] 語言中實現類是很蹩腳的。javascript
這種蹩腳的感受不僅是來源於語法,雖然語法是很重要的緣由。js裏面有許多語法的缺點:繁瑣雜亂的.prototype 引用、試圖調用原型鏈上層同名函數時的顯式僞多態以及不可靠、不美觀並且容易被誤解成「構造函數」的.constructor。css
除此以外,類設計其實還存在更進一步的問題。傳統面向類的語言中父類和子類、子類和實例之間實際上是複製操做,可是在[[Prototype]] 中並無複製。java
對象關聯代碼和行爲委託使用了[[Prototype]] 而不是將它藏起來,對比其簡潔性能夠看出,類並不適用於JavaScript。設計模式
classapp
不過並不須要再糾結於這個問題;能夠看看ES6 的class機制。這裏會介紹它的工做原理並分析class是否改進了以前提到的那些缺點。框架
下面是一個例子:dom
class Widget { constructor(width,height) { this.width = width || 50; this.height = height || 50; this.$elem = null; } render($where){ if (this.$elem) { this.$elem.css( { width: this.width + "px", height: this.height + "px" } ).appendTo( $where ); } } } class Button extends Widget { constructor(width,height,label) { super( width, height ); this.label = label || "Default"; this.$elem = $( "<button>" ).text( this.label ); } render($where) { super( $where ); this.$elem.click( this.onClick.bind( this ) ); } onClick(evt) { console.log( "Button '" + this.label + "' clicked!" ); } }
能夠看出,語法較prototype優雅了許多。那還能解決什麼問題呢?函數
1. (基本上)再也不引用雜亂的.prototype了。
2. Button 聲明時直接「 繼承」 了Widget, 再也不須要經過Object.create(..) 來替換.prototype 對象,也不須要設置.__proto__ 或者Object.setPrototypeOf(..)。
3. 能夠經過super(..) 來實現相對多態,這樣任何方法均可以引用原型鏈上層的同名方法。這能夠解決一個問題:構造函數不屬於類,因此沒法互相引用——super() 能夠完美解決構造函數的問題。
4. class 字面語法不能聲明屬性(只能聲明方法)。看起來這是一種限制,可是它會排除掉許多很差的狀況,若是沒有這種限制的話,原型鏈末端的「實例」可能會意外地獲取其餘地方的屬性(這些屬性隱式被全部「實例」所「共享」)。因此,class 語法實際上能夠幫助你避免犯錯。
5. 能夠經過extends 很天然地擴展對象(子)類型,甚至是內置的對象(子)類型,好比Array 或RegExp。沒有class ..extends 語法時,想實現這一點是很是困難的,基本上只有框架的做者才能搞清楚這一點。可是如今能夠垂手可得地作到!性能
class 語法確實解決了典型原型風格代碼中許多顯而易見的語法問題。this
class陷阱
然而,class 語法並無解決全部的問題,在JavaScript 中使用「類」設計模式仍然存在許多深層問題。
首先,你可能會認爲ES6 的class 語法是向JavaScript 中引入了一種新的「類」機制,其實不是這樣。class 基本上只是現有[[Prototype]]機制的一種語法糖。
也就是說,class 並不會像傳統面向類的語言同樣在聲明時靜態複製全部行爲。若是你(有意或無心)修改或者替換了父「類」中的一個方法,那子「類」和全部實例都會受到影響,由於它們在定義時並無進行復制,只是使用基於[[Prototype]] 的實時委託:
class C { constructor() { this.num = Math.random(); } rand() { console.log( "Random: " + this.num ); } } var c1 = new C(); c1.rand(); // "Random: 0.4324299..." C.prototype.rand = function() { console.log( "Random: " + Math.round( this.num * 1000 )); }; var c2 = new C(); c2.rand(); // "Random: 867" c1.rand(); // "Random: 432" ——噢!
若是已經明白委託的原理,並不會指望獲得「類」的副本的話,那這種行爲纔看起來比較合理。因此會問:爲何要使用本質上不是類的class語法呢?
ES6 中的class語法不是會讓傳統類和委託對象之間的區別更加難以發現和理解嗎?
class 語法沒法定義類成員屬性(只能定義方法),若是爲了跟蹤實例之間共享狀態必需要這麼作,那你只能使用醜陋的.prototype 語法,像這樣:
class C { constructor() { // 確保修改的是共享狀態而不是在實例上建立一個屏蔽屬性! C.prototype.count++; // this.count 能夠經過委託實現咱們想要的功能 console.log( "Hello: " + this.count ); } } // 直接向prototype 對象上添加一個共享狀態 C.prototype.count = 0; var c1 = new C(); // Hello: 1 var c2 = new C(); // Hello: 2 c1.count === 2; // true c1.count === c2.count; // true
這種方法最大的問題是, 它違背了class 語法的本意, 在實現中暴露了.prototype。
若是使用this.count++ 的話,會發如今對象c1 和c2 上都建立了.count 屬性,而不是更新共享狀態。class 沒有辦法解決這個問題,而且乾脆就不提供相應的語法支持,因此根本就不該該這樣作。
此外,class 語法仍然面臨意外屏蔽的問題:
class C { constructor(id) { // 噢,鬱悶,咱們的id 屬性屏蔽了id() 方法 this.id = id; } id() { console.log( "Id: " + id ); } } var c1 = new C( "c1" ); c1.id(); // TypeError -- c1.id 如今是字符串"c1"
除此以外,super也存在一些細微的問題。你可能認爲super的綁定方法和this 相似,也就是說,不管目前的方法在原型鏈中處於什麼位置,super 總會綁定到鏈中的上一層。
然而,出於性能考慮(this 綁定已是很大的開銷了),super 並非動態綁定的,它會在聲明時「靜態」綁定。沒什麼大不了的,是吧?
可能不是這樣。若是你和大多數JavaScript 開發者同樣,會用許多不一樣的方法把函數應用在不一樣的(使用class 定義的)對象上,那你可能不知道,每次執行這些操做時都必須從新綁定super。
此外,根據應用方式的不一樣,super 可能不會綁定到合適的對象(至少和你想的不同),因此你可能須要用toMethod(..) 來手動綁定super(相似用bind(..) 來綁定this)。
你已經習慣了把方法應用到不一樣的對象上,從而能夠自動利用this 的隱式綁定規則。可是這對於super 來講是行不通的。
思考下面代碼中super 的行爲(D 和E 上):
class P { foo() { console.log( "P.foo" ); } } class C extends P { foo() { super(); } } var c1 = new C(); c1.foo(); // "P.foo" var D = { foo: function() { console.log( "D.foo" ); } }; var E = { foo: C.prototype.foo }; // 把E 委託到D Object.setPrototypeOf( E, D ); E.foo(); // "P.foo"
若是你認爲super 會動態綁定(很是合理!),那你可能指望super() 會自動識別出E 委託了D,因此E.foo() 中的super() 應該調用D.foo()。
但事實並非這樣。出於性能考慮,super 並不像this 同樣是晚綁定(late bound, 或者說動態綁定)的,它在[[HomeObject]].[[Prototype]] 上,[[HomeObject]] 會在建立時靜態綁定。
在本例中,super() 會調用P.foo(),由於方法的[[HomeObject]] 仍然是C,C.[[Prototype]]是P。
確實能夠手動修改super 綁定,使用toMethod(..) 綁定或從新綁定方法的[[HomeObject]](就像設置對象的[[Prototype]] 同樣!)就能夠解決本例的問題:
var D = { foo: function() { console.log( "D.foo" ); } }; // 把E 委託到 D var E = Object.create( D ); // 手動把foo 的[[HomeObject]] 綁定到E,E.[[Prototype]] 是D, 因此 super() 是D.foo() E.foo = C.prototype.foo.toMethod( E, "foo" ); E.foo(); // "D.foo"
toMethod(..) 會複製方法並把homeObject 看成第一個參數(也就是咱們傳入的E),第二個參數(可選)是新方法的名稱(默認是原方法名)。
除此以外,開發者還有可能會遇到其餘問題,這有待觀察。不管如何,對於引擎自動綁定的super 來講,你必須時刻警戒是否須要進行手動綁定。唉!
靜態大於動態嗎
經過上面的這些特性能夠看出,ES6 的class 最大的問題在於,(像傳統的類同樣)它的語法有時會讓你認爲,定義了一個class 後,它就變成了一個(將來會被實例化的)東西的靜態定義。你會完全忽略C 是一個對象,是一個具體的能夠直接交互的東西。
在傳統面向類的語言中,類定義以後就不會進行修改,因此類的設計模式就不支持修改。
可是JavaScript 最強大的特性之一就是它的動態性,任何對象的定義均可以修改(除非你把它設置成不可變)。
class 彷佛不同意這樣作,因此強制讓你使用醜陋的.prototype 語法以及super 問題,等等。並且對於這種動態產生的問題,class 基本上都沒有提供解決方案。
換句話說,class 彷佛想告訴你:「動態太難實現了,因此這可能不是個好主意。這裏有一種看起來像靜態的語法,因此編寫靜態代碼吧。」
對於JavaScript 來講這是多麼悲傷的評論啊:動態太難實現了,咱們僞裝成靜態吧。(可是實際上並非!)
總地來講,ES6 的class 想假裝成一種很好的語法問題的解決方案,可是實際上卻讓問題更難解決並且讓JavaScript 更加難以理解。
總結
class 很好地假裝成JavaScript 中類和繼承設計模式的解決方案,可是它實際上起到了副作用:它隱藏了許多問題而且帶來了更多更細小可是危險的問題。class 加深了過去20 年中對於JavaScript 中「類」的誤解,在某些方面,它產生的問題比解決的多,並且讓原本優雅簡潔的[[Prototype]] 機制變得很是彆扭。結論:若是ES6 的class 讓[[Prototype]] 變得更加難用並且隱藏了JavaScript 對象最重要的機制——對象之間的實時委託關聯,咱們難道不該該認爲class 產生的問題比解決的多嗎?難道不該該抵制這種設計模式嗎?這些問題沒法獲得回答,可是但願這裏能從史無前例的深度分析這些問題,而且可以提供回答問題所需的全部信息。