ES6中的Class

對於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 產生的問題比解決的多嗎?難道不該該抵制這種設計模式嗎?這些問題沒法獲得回答,可是但願這裏能從史無前例的深度分析這些問題,而且可以提供回答問題所需的全部信息。

相關文章
相關標籤/搜索