感謝社區(qū)中各位的大力支持,譯者再次奉上一點點福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠,并抽取幸運大獎:點擊這里領取
在第五章中,我們詳細地討論了[[Prototype]]機制,和 為什么 對于描述“類”或“繼承”來說它是那么使人糊涂和不合適。我們一路跋涉,不僅涉及了相當繁冗的語法(使代碼凌亂的.prototype),還有各種陷阱(比如使人吃驚的.constructor解析和難看的假想多態(tài)語法)。我們探索了許多人試圖用抹平這些粗糙的區(qū)域而使用的各種“mixin”方法。
這時一個常見的反應是,想知道為什么這些看起來如此簡單的事情這么復雜?,F(xiàn)在我們已經(jīng)拉開帷幕看到了它是多么麻煩,這并不奇怪:大多數(shù)JS開發(fā)者從不探究得這么深,而將這一團糟交給一個“類”包去幫他們處理。
我希望到現(xiàn)在你不會甘心于敷衍了事并把這樣的細節(jié)丟給一個“黑盒”庫。現(xiàn)在我們來深入講解我們 如何與應當如何 以一種比類造成的困惑 簡單得多而且更直接的方式 來考慮JS中對象的[[Prototype]]機制。
簡單地復習一下第五章的結(jié)論,[[Prototype]]機制是一種存在于一個對象上的內(nèi)部鏈接,它指向一個其他對象。
當一個屬性/方法引用在第一個對象上發(fā)生,而這樣的屬性/方法又不存在時,這個鏈接就會被使用。在這種情況下,[[Prototype]]鏈接告訴引擎去那個被鏈接的對象上尋找該屬性/方法。接下來,如果那個對象也不能滿足查詢,就沿著它的[[Prototype]]查詢,如此繼續(xù)。這種對象間一系列的鏈接構成了所謂的“原形鏈”。
換句話說,對于我們能在JavaScript中利用的功能的實際機制來說,其重要的實質(zhì) 全部在于被連接到其他對象的對象。
這個觀點是理解本章其余部分的動機和方法的重要基礎!
邁向面相委托的設計
為了將我們的思想恰當?shù)丶性谌绾斡米钪苯亓水數(shù)姆椒ㄊ褂?code>[[Prototype]],我們必須認識到它代表一種根本上與類不同的設計模式(見第四章)。
注意* 某些 面相類的設計依然是很有效的,所以不要扔掉你知道的每一件事(扔掉大多數(shù)就行了?。1热纾?em>封裝 就十分強大,而且與委托兼容的(雖然不那么常見)。
我們需要試著將我們的思維從類/繼承的設計模式轉(zhuǎn)變?yōu)樾袨榇碓O計模式。如果你已經(jīng)用在教育/工作生涯中思考類的方式做了大多數(shù)或所有的編程工作,這可能感覺不舒服或不自然。你可能需要嘗試這種思維過程好幾次,才能適應這種非常不同的思考方式。
我將首先帶你進行一些理論練習,之后我們會一對一地看一些更實際的例子來為你自己的代碼提供實踐環(huán)境。
類理論
比方說我們有幾個相似的任務(“XYZ”,“ABC”,等)需要在我們的軟件中建模。
使用類,你設計這個場景的方式是:定義一個泛化的父類(基類)比如Task,為所有的“同類”任務定義共享的行為。然后,你定義子類XYZ和ABC,它們都繼承自Task,每個都分別添加了特化的行為來處理各自的任務。
重要的是, 類設計模式將鼓勵你發(fā)揮繼承的最大功效,當你在XYZ任務中覆蓋Task的某些泛化方法的定義時,你將會想利用方法覆蓋(和多態(tài)),也許會利用super來調(diào)用這個方法泛化版本,為它添加更多的行為。你很可能會找到幾個可以“抽象”到父類中,或在子類中特化(覆蓋)的地方。
這是一些關于這個場景的假想代碼:
class Task {
id;
// `Task()`構造器
Task(ID) { id = ID; }
outputTask() { output( id ); }
}
class XYZ inherits Task {
label;
// `XYZ()`構造器
XYZ(ID,Label) { super( ID ); label = Label; }
outputTask() { super(); output( label ); }
}
class ABC inherits Task {
// ...
}
現(xiàn)在,你可以初始化一個或多個XYZ子類的 拷貝,并且使用這些實例來執(zhí)行“XYZ”任務。這些實例已經(jīng) 同時拷貝 了泛化的Task定義的行為和具體的XYZ定義的行為。類似地,ABC類的實例將拷貝Task的行為和具體的ABC的行為。在構建完成之后,你一般會僅與這些實例互動(而不是類),因為每個實例都拷貝了完成計劃任務的所有行為。
委托理論
但是現(xiàn)在然我們試著用 行為委托 代替 類 來思考同樣的問題。
你將首先定義一個稱為Task的 對象(不是一個類,也不是一個大多數(shù)JS開發(fā)者想讓你相信的function),而且它將擁有具體的行為,這些行為包含各種任務可以使用的(讀作:委托至?。┕ぞ叻椒?。然后,對于每個任務(“XYZ”,“ABC”),你定義一個 對象 來持有這個特定任務的數(shù)據(jù)/行為。你 鏈接 你的特定任務對象到Task工具對象,允許它們在必要的時候可以委托到它。
基本上,你認為執(zhí)行任務“XYZ”就是從兩個兄弟/對等的對象(XYZ和Task)中請求行為來完成它。與其通過類的拷貝將它們組合在一起,我們可以將他們保持在分離的對象中,而且可以在需要的情況下允許XYZ對象來 委托到 Task。
這里是一些簡單的代碼,示意你如何實現(xiàn)它:
var Task = {
setID: function(ID) { this.id = ID; },
outputID: function() { console.log( this.id ); }
};
// 使`XYZ`委托到`Task`
var XYZ = Object.create( Task );
XYZ.prepareTask = function(ID,Label) {
this.setID( ID );
this.label = Label;
};
XYZ.outputTaskDetails = function() {
this.outputID();
console.log( this.label );
};
// ABC = Object.create( Task );
// ABC ... = ...
在這段代碼中,Task和XYZ不是類(也不是函數(shù)),它們 僅僅是對象。XYZ通過Object.create()創(chuàng)建,來[[Prototype]]委托到Task對象(見第五章)。
作為與面相類(也就是,OO——面相對象)的對比,我稱這種風格的代碼為 “OLOO”(objects-linked-to-other-objects(鏈接到其他對象的對象))。所有我們 真正 關心的是,對象XYZ委托到對象Task(對象ABC也一樣)。
在JavaScript中,[[Prototype]]機制將 對象 鏈接到其他 對象。無論你多么想說服自己這不是真的,JavaScript沒有像“類”那樣的抽象機制。這就像逆水行舟:你 可以 做到,但你 選擇 了逆流而上,所以很明顯地,你會更困難地達到目的地。
OLOO風格的代碼 中有一些需要注意的不同:
- 前一個類的例子中的
id和label數(shù)據(jù)成員都是XYZ上的直接數(shù)據(jù)屬性(它們都不在Task上)。一般來說,當[[Prototype]]委托引入時,你想使狀態(tài)保持在委托者上(XYZ,ABC),不是在委托上(Task)。 - 在類的設計模式中,我們故意在父類(
Task)和子類(XYZ)上采用相同的命名outputTask,以至于我們可以利用覆蓋(多態(tài))。在委托的行為中,我們反其道而行之:我們盡一切可能避免在[[Prototype]]鏈的不同層級上給出相同的命名(稱為“遮蔽”——見第五章),因為這些命名沖突會導致尷尬/脆弱的語法來消除引用的歧義(見第四章),而我們想避免它。
這種設計模式不那么要求那些傾向于被覆蓋的泛化的方法名,而是要求針對于每個對象的 具體 行為類型給出更具描述性的方法名。這實際上會產(chǎn)生更易于理解/維護的代碼,因為方法名(不僅在定義的位置,而是擴散到其他代碼中)變得更加明白(代碼即文檔)。 -
this.setID(ID);位于對象XYZ的一個方法內(nèi)部,它首先在XYZ上查找setID(..),但因為它不能在XYZ上找到叫這個名稱的方法,[[Prototype]]委托意味著它可以沿著鏈接到Task來尋找setID(),這樣當然就找到了。另外,由于調(diào)用點的隱含this綁定規(guī)則(見第二章),當setID()運行時,即便方法是在Task上找到的,這個函數(shù)調(diào)用的this綁定依然是我們期望和想要的XYZ。我們在代碼稍后的this.outputID()中也看到了同樣的事情。
換句話說,我們可以使用存在于Task上的泛化工具與XYZ互動,因為XYZ可以委托至Task。
行為委托 意味著:在某個對象(XYZ)的屬性或方法沒能在這個對象(XYZ)上找到時,讓這個對象(XYZ)為屬性或方法引用提供一個委托(Task)。
這是一個 極其強大 的設計模式,與父類和子類,繼承,多態(tài)等有很大的不同。與其在你的思維中縱向地,從上面父類到下面子類地組織對象,你應帶并列地,對等地考慮對象,而且對象間擁有方向性的委托鏈接。
注意: 委托更適于作為內(nèi)部實現(xiàn)的細節(jié),而不是直接暴露在API接口的設計中。在上面的例子中,我們的API設計沒必要有意地讓開發(fā)者調(diào)用XYZ.setID()(當然我們可以?。?。我們以某種隱藏的方式將委托作為我們API的內(nèi)部細節(jié),即XYZ.prepareTask(..)委托到Task.setID(..)。詳細的內(nèi)容,參照第五章的“鏈接作為候補?”中的討論。
相互委托(不允許)
你不能在兩個或多個對象間相互地委托(雙向地)對方來創(chuàng)建一個 循環(huán) 。如果你使B鏈接到A,然后試著讓A鏈接到B,那么你將得到一個錯誤。
這樣的事情不被允許有些可惜(不是非常令人驚訝,但稍稍有些惱人)。如果你制造一個在任意一方都不存在的屬性/方法引用,你就會在[[Prototype]]上得到一個無限遞歸的循環(huán)。但如果所有的引用都嚴格存在,那么B就可以委托至A,或相反,而且它可以工作。這意味著你可以為了多種任務用這兩個對象互相委托至對方。有一些情況這可能會有用。
但它不被允許是因為引擎的實現(xiàn)者發(fā)現(xiàn),在設置時檢查(并拒絕?。o限循環(huán)引用一次,要比每次你在一個對象上查詢屬性時都做相同檢查的性能要高。
調(diào)試
我們將簡單地討論一個可能困擾開發(fā)者的微妙的細節(jié)。一般來說,JS語言規(guī)范不會控制瀏覽器開發(fā)者工具如何向開發(fā)者表示指定的值/結(jié)構,所以每種瀏覽器/引擎都自由地按需要解釋這個事情。因此,瀏覽器/工具 不總是意見統(tǒng)一。特別地,我們現(xiàn)在要考察的行為就是當前僅在Chrome的開發(fā)者工具中觀察到的。
考慮這段傳統(tǒng)的“類構造器”風格的JS代碼,正如它將在Chrome開發(fā)者工具 控制臺 中出現(xiàn)的:
function Foo() {}
var a1 = new Foo();
a1; // Foo {}
讓我們看一下這個代碼段的最后一行:對表達式a1進行求值的輸出,打印Foo {}。如果你在FireFox中試用同樣的代碼,你很可能會看到Object {}。為什么會有不同?這些輸出意味著什么?
Chrome實質(zhì)上在說“{}是一個由名為‘Foo’的函數(shù)創(chuàng)建的空對象”。Firefox在說“{}是一個由Object普通構建的空對象”。這種微妙的區(qū)別是因為Chrome在像一個 內(nèi)部屬性 一樣,動態(tài)跟蹤執(zhí)行創(chuàng)建的實際方法的名稱,而其他瀏覽器不會跟蹤這樣的附加信息。
試圖用JavaScript機制來解釋它很吸引人:
function Foo() {}
var a1 = new Foo();
a1.constructor; // Foo(){}
a1.constructor.name; // "Foo"
那么,Chrome就是通過簡單地查看對象的.Constructor.name來輸出“Foo”的?令人費解的是,答案既是“是”也是“不”。
考慮下面的代碼:
function Foo() {}
var a1 = new Foo();
Foo.prototype.constructor = function Gotcha(){};
a1.constructor; // Gotcha(){}
a1.constructor.name; // "Gotcha"
a1; // Foo {}
即便我們將a1.constructor.name合法地改變?yōu)槠渌臇|西(“Gotcha”),Chrome控制臺依舊使用名稱“Foo”。
那么,說明前面問題(它使用.constructor.name嗎?)的答案是 不,他一定在內(nèi)部追蹤其他的什么東西。
但是,且慢!讓我們看看這種行為如何與OLOO風格的代碼一起工作:
var Foo = {};
var a1 = Object.create( Foo );
a1; // Object {}
Object.defineProperty( Foo, "constructor", {
enumerable: false,
value: function Gotcha(){}
});
a1; // Gotcha {}
啊哈!Gotcha,Chrome的控制臺 確實 尋找并且使用了.constructor.name。實際上,就在寫這本書的時候,正是這個行為被認定為是Chrome的一個Bug,而且就在你讀到這里的時候,它可能已經(jīng)被修復了。所以你可能已經(jīng)看到了被修改過的 a1; // Object{}。
這個bug暫且不論,Chrome執(zhí)行的(剛剛在代碼段中展示的)“構造器名稱”內(nèi)部追蹤(目前僅用于調(diào)試輸出的目的),是一個僅在Chrome內(nèi)部存在的擴張行為,它已經(jīng)超出了JS語言規(guī)范要求的范圍。
如果你不使用“構造器”來制造你的對象,就像我們在本章的OLOO風格代碼中不鼓勵的那樣,那么你將會得到一個Chrome不會為其追蹤內(nèi)部“構造器名稱”的對象,所以這樣的對象將正確地僅僅被輸出“Object {}”,意味著“從Object()構建生成的對象”。
不要認為 這代表一個OLOO風格代碼的缺點。當你用OLOO編碼而且用行為代理作為你的設計模式時,誰 “創(chuàng)建了”(也就是,哪個函數(shù) 被和new一起調(diào)用了?)一些對象是一個無關的細節(jié)。Chrome特殊的內(nèi)部“構造器名稱”追蹤僅僅在你完全接受“類風格”編碼時才有用,而在你接受OLOO委托時是沒有意義的。
思維模型比較
現(xiàn)在你至少在理論上可以看到“類”和“委托”設計模式的不同了,讓我們看看這些設計模式在我們用來推導我們代碼的思維模型上的含義。
我們將查看一些更加理論上的(“Foo”,“Bar”)代碼,然后比較兩種方法(OO vs. OLOO)的代碼實現(xiàn)。第一段代碼使用經(jīng)典的(“原型的”)OO風格:
function Foo(who) {
this.me = who;
}
Foo.prototype.identify = function() {
return "I am " + this.me;
};
function Bar(who) {
Foo.call( this, who );
}
Bar.prototype = Object.create( Foo.prototype );
Bar.prototype.speak = function() {
alert( "Hello, " + this.identify() + "." );
};
var b1 = new Bar( "b1" );
var b2 = new Bar( "b2" );
b1.speak();
b2.speak();
父類Foo,被子類Bar繼承,之后Bar被初始化兩次:b1和b2。我們得到的是b1委托至Bar.prototype,Bar.prototype委托至Foo.prototype。這對你來說應當看起來十分熟悉。沒有太具開拓性的東西發(fā)生。
現(xiàn)在,讓我們使用 OLOO 風格的代碼 實現(xiàn)完全相同的功能:
var Foo = {
init: function(who) {
this.me = who;
},
identify: function() {
return "I am " + this.me;
}
};
var Bar = Object.create( Foo );
Bar.speak = function() {
alert( "Hello, " + this.identify() + "." );
};
var b1 = Object.create( Bar );
b1.init( "b1" );
var b2 = Object.create( Bar );
b2.init( "b2" );
b1.speak();
b2.speak();
我們利用了完全相同的從Bar到Foo的[[Prototype]]委托,正如我們在前一個代碼段中b1,Bar.prototype,和Foo.prototype之間那樣。我們?nèi)匀挥?個對象鏈接在一起。
但重要的是,我們極大地簡化了發(fā)生的 所有其他事項,因為我們現(xiàn)在僅僅建立了相互鏈接的 對象,而不需要所有其他討厭且困惑的看起來像類(但動起來不像)的東西,還有構造器,原型和new調(diào)用。
問問你自己:如果我能用OLOO風格代碼得到我用“類”風格代碼得到的一樣的東西,但OLOO更簡單而且需要考慮的事情更少,OLOO不是更好嗎?
讓我們講解一下這兩個代碼段間涉及的思維模型。
首先,類風給的代碼段意味著這樣的實體與它們的關系的思維模型:
[圖片上傳失敗...(image-75b1c6-1515410940434)]
實際上,這有點兒不公平/誤導,因為它展示了許多額外的,你在 技術上 一直不需要知道(雖然你 需要 理解它)的細節(jié)。一個關鍵是,它是一系列十分復雜的關系。但另一個關鍵是:如果你花時間來沿著這些關系的箭頭走,在JS的機制中 有數(shù)量驚人的內(nèi)部統(tǒng)一性。
例如,JS函數(shù)可以訪問call(..),apply(..)和bind(..)(見第二章)的能力是因為函數(shù)本身是對象,而函數(shù)對象還擁有一個[[Prototype]]鏈接,鏈到Function.prototype對象,它定義了那些任何函數(shù)對象都可以委托到的默認方法。JS可以做這些事情,你也能!
好了,現(xiàn)在讓我們看一個這張圖的 稍稍 簡化的版本,用它來進行比較稍微“公平”一點——它僅展示了 相關 的實體與關系。
[圖片上傳失敗...(image-f28224-1515410940434)]
任然非常復雜,對吧?虛線描繪了當你在Foo.prototype和Bar.prototype間建立“繼承”時的隱含關系,而且還沒有 修復 丟失的 .constructor屬性引用(見第五章“終極構造器”)。即便將虛線去掉,每次你與對象鏈接打交道時,這個思維模型依然要變很多可怕的戲法。
現(xiàn)在,然我們看看OLOO風格代碼的思維模型:
[圖片上傳失敗...(image-30e581-1515410940434)]
正如你所比較它們得到的,十分明顯,OLOO風格的代碼 需要關心的東西少太多了,因為OLOO風格代碼接受了 事實:我們唯一需要真正關心的事情是 鏈接到其他對象的對象。
所有其他“類”的爛設計用一種令人費解而且復雜的方式得到相同的結(jié)果。去掉那些東西,事情就變得簡單得多(還不會失去任何功能)。
Classes vs. Objects
我們已經(jīng)看到了各種理論的探索和“類”與“行為委托”的思維模型的比較?,F(xiàn)在讓我們來看看更具體的代碼場景,來展示你如何實際應用這些想法。
我們將首先講解一種在前端網(wǎng)頁開發(fā)中的典型場景:建造UI部件(按鈕,下拉列表等等)。
Widget“類”
因為你可能還是如此地習慣于OO設計模式,你很可能會立即這樣考慮這個問題:一個父類(也許稱為Wedget)擁有所有共通的基本部件行為,然后衍生的子類擁有具體的部件類型(比如Button)。
注意: 為了DOM和CSS的操作,我們將在這里使用JQuery,這僅僅是因為對于我們現(xiàn)在的討論,它不是一個我們真正關心的細節(jié)。這些代碼中不關心你用哪個JS框架(JQuery,Dojo,YUI等等)來解決如此無趣的問題。
讓我們來看看,在沒有任何“類”幫助庫或語法的情況下,我們?nèi)绾斡媒?jīng)典風格的純JS來實現(xiàn)“類”設計:
// 父類
function Widget(width,height) {
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
}
Widget.prototype.render = function($where){
if (this.$elem) {
this.$elem.css( {
width: this.width + "px",
height: this.height + "px"
} ).appendTo( $where );
}
};
// 子類
function Button(width,height,label) {
// "super"構造器調(diào)用
Widget.call( this, width, height );
this.label = label || "Default";
this.$elem = $( "<button>" ).text( this.label );
}
// 使`Button` “繼承” `Widget`
Button.prototype = Object.create( Widget.prototype );
// 覆蓋“繼承來的” `render(..)`
Button.prototype.render = function($where) {
// "super"調(diào)用
Widget.prototype.render.call( this, $where );
this.$elem.click( this.onClick.bind( this ) );
};
Button.prototype.onClick = function(evt) {
console.log( "Button '" + this.label + "' clicked!" );
};
$( document ).ready( function(){
var $body = $( document.body );
var btn1 = new Button( 125, 30, "Hello" );
var btn2 = new Button( 150, 40, "World" );
btn1.render( $body );
btn2.render( $body );
} );
OO設計模式告訴我們要在父類中聲明一個基礎render(..),之后在我們的子類中覆蓋它,但不是完全替代它,而是用按鈕特定的行為增強這個基礎功能。
注意 顯示假想多態(tài) 的丑態(tài),Widget.call和Widget.prototype.render.call引用是為了偽裝從子“類”方法得到“父類”基礎方法支持的“super”調(diào)用。呃。
ES6 class 語法糖
我們會在附錄A中講解ES6的class語法糖,但是讓我們演示一下我們?nèi)绾斡?code>class來實現(xiàn)相同的代碼。
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.render( $where );
this.$elem.click( this.onClick.bind( this ) );
}
onClick(evt) {
console.log( "Button '" + this.label + "' clicked!" );
}
}
$( document ).ready( function(){
var $body = $( document.body );
var btn1 = new Button( 125, 30, "Hello" );
var btn2 = new Button( 150, 40, "World" );
btn1.render( $body );
btn2.render( $body );
} );
毋庸置疑,通過使用ES6的class,許多前面經(jīng)典方法中語法的丑態(tài)被改善了。super(..)的存在看起來非常適宜(但當你深入挖掘它時,不全是好事!)。
除了語法上的改進,這些都不是 真正的 類,因為他們?nèi)匀还ぷ髟?code>[[Prototype]]機制之上。它們依然會受到思維模型不匹配的拖累,就像我們在第四,五章中,和直到現(xiàn)在探索的那樣。附錄A將會詳細講解ES6class語法和他的含義。我們將會看到為什么解決語法上的小問題不會實質(zhì)上解決我們在JS中的類的困惑,雖然它做出了勇敢的努力假裝解決了問題!
無論你是使用經(jīng)典的原型語法還是新的ES6語法糖,你依然選擇了使用“類”來對問題(UI部件)進行建模。正如我們前面幾章試著展示的,在JavaScript中做這個選擇會帶給你額外的頭疼和思維上的彎路。
委托部件對象
這是我們更簡單的Widget/Button例子,使用了 OLOO風格委托:
var Widget = {
init: function(width,height){
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
},
insert: function($where){
if (this.$elem) {
this.$elem.css( {
width: this.width + "px",
height: this.height + "px"
} ).appendTo( $where );
}
}
};
var Button = Object.create( Widget );
Button.setup = function(width,height,label){
// delegated call
this.init( width, height );
this.label = label || "Default";
this.$elem = $( "<button>" ).text( this.label );
};
Button.build = function($where) {
// delegated call
this.insert( $where );
this.$elem.click( this.onClick.bind( this ) );
};
Button.onClick = function(evt) {
console.log( "Button '" + this.label + "' clicked!" );
};
$( document ).ready( function(){
var $body = $( document.body );
var btn1 = Object.create( Button );
btn1.setup( 125, 30, "Hello" );
var btn2 = Object.create( Button );
btn2.setup( 150, 40, "World" );
btn1.build( $body );
btn2.build( $body );
} );
使用這種OLOO風格的方法,我們不認為Widget是一個父類而Button是一個子類,Wedget只是一個對象 和某種具體類型的部件也許想要代理到的工具的集合,而且Button也只是一個獨立的對象(當然,帶有委托至Wedget的鏈接?。?。
從設計模式的角度來看,我們 沒有 像類的方法建議的那樣,在兩個對象中共享相同的render(..)方法名稱,而是選擇了更能描述每個特定任務的不同的名稱。同樣的原因,初始化 方法被分別稱為init(..)和setup(..)。
不僅委托設計模式建議使用不同而且更具描述性的名稱,而且在OLOO中這樣做會避免難看的顯式假想多態(tài)調(diào)用,正如你可以通過簡單,相對的this.init(..)和this.insert(..)委托調(diào)用看到的。
語法上,我們也沒有任何構造器,.prototype或者new出現(xiàn),它們事實上是不必要的設計。
現(xiàn)在,如果你再細心考察一下,你可能會注意到之前僅有一個調(diào)用(var btn1 = new Button(..)),而現(xiàn)在有了兩個(var btn1 = Object.create(Button)和btn1.setup(..))。這猛地看起來像是一個缺點(代碼變多了)。
然而,即便是這樣的事情,和經(jīng)典原型風格比起來也是 OLOO風格代碼的優(yōu)點。為什么?
用類的構造器,你“強制”(不完全是這樣,但是被強烈建議)構建和初始化在同一個步驟中進行。然而,有許多種情況,能夠?qū)⑦@兩步分開做(就像你在OLOO中做的)更靈活。
舉個例子,我們假定你在程序的最開始,在一個池中創(chuàng)建所有的實例,但你等到在它們被從池中找出并使用之前再用指定的設置初始化它們。我們的例子中,這兩個調(diào)用緊挨在一起,當然它們也可以按需要發(fā)生在非常不同的時間和代碼中非常不同的部分。
OLOO 對關注點分離原則有 更好 的支持,也就是創(chuàng)建和初始化沒有必要合并在同一個操作中。
更簡單的設計
OLOO除了提供表面上更簡單(而且更靈活?。┑拇a之外,行為委托作為一個模式實際上會帶來更簡單的代碼架構。讓我們講解最后一個例子來說明OLOO是如何簡化你的整體設計的。
這個場景中我們將講解兩個控制器對象,一個用來處理網(wǎng)頁的登錄form(表單),另一個實際處理服務器的認證(通信)。
我們需要幫助工具來進行與服務器的Ajax通信。我們將使用JQuery(雖然其他的框架都可以),因為它不僅為我們處理Ajax,而且還返回一個類似Promise的應答,這樣我們就可以在代碼中使用.then(..)來監(jiān)聽這個應答。
注意: 我們不會再這里講到Promise,但我們會在以后的 你不懂JS 系列中講到。
根據(jù)典型的類的設計模式,我們在一個叫做Controller的類中將任務分解為基本功能,之后我們會衍生出兩個子類,LoginController和AuthController,它們都繼承自Controller而且特化某些基本行為。
// 父類
function Controller() {
this.errors = [];
}
Controller.prototype.showDialog = function(title,msg) {
// 在對話框中給用戶顯示標題和消息
};
Controller.prototype.success = function(msg) {
this.showDialog( "Success", msg );
};
Controller.prototype.failure = function(err) {
this.errors.push( err );
this.showDialog( "Error", err );
};
// 子類
function LoginController() {
Controller.call( this );
}
// 將子類鏈接到父類
LoginController.prototype = Object.create( Controller.prototype );
LoginController.prototype.getUser = function() {
return document.getElementById( "login_username" ).value;
};
LoginController.prototype.getPassword = function() {
return document.getElementById( "login_password" ).value;
};
LoginController.prototype.validateEntry = function(user,pw) {
user = user || this.getUser();
pw = pw || this.getPassword();
if (!(user && pw)) {
return this.failure( "Please enter a username & password!" );
}
else if (pw.length < 5) {
return this.failure( "Password must be 5+ characters!" );
}
// 到這里了?輸入合法!
return true;
};
// 覆蓋來擴展基本的`failure()`
LoginController.prototype.failure = function(err) {
// "super"調(diào)用
Controller.prototype.failure.call( this, "Login invalid: " + err );
};
// 子類
function AuthController(login) {
Controller.call( this );
// 除了繼承外,我們還需要合成
this.login = login;
}
// 將子類鏈接到父類
AuthController.prototype = Object.create( Controller.prototype );
AuthController.prototype.server = function(url,data) {
return $.ajax( {
url: url,
data: data
} );
};
AuthController.prototype.checkAuth = function() {
var user = this.login.getUser();
var pw = this.login.getPassword();
if (this.login.validateEntry( user, pw )) {
this.server( "/check-auth",{
user: user,
pw: pw
} )
.then( this.success.bind( this ) )
.fail( this.failure.bind( this ) );
}
};
// 覆蓋以擴展基本的`success()`
AuthController.prototype.success = function() {
// "super"調(diào)用
Controller.prototype.success.call( this, "Authenticated!" );
};
// 覆蓋以擴展基本的`failure()`
AuthController.prototype.failure = function(err) {
// "super"調(diào)用
Controller.prototype.failure.call( this, "Auth Failed: " + err );
};
var auth = new AuthController(
// 除了繼承,我們還需要合成
new LoginController()
);
auth.checkAuth();
我們有所有控制器分享的基本行為,它們是success(..),failure(..)和showDialog(..)。我們的子類LoginController和AuthController覆蓋了failure(..)和success(..)來增強基本類的行為。還要注意的是,AuthController需要一個LoginController實例來與登錄form互動,所以它變成了一個數(shù)據(jù)屬性成員。
另外一件要提的事情是,我們選擇一些 合成 散布在繼承的頂端。AuthController需要知道LoginController,所以我們初始化它(new LoginController()),使它一個成為this.login的類屬性成員來引用它,這樣AuthController才可以調(diào)用LoginController上的行為。
注意: 這里可能會存在一絲沖動,就是使AuthController繼承LoginController,或者反過來,這樣的話我們就會通過繼承鏈得到 虛擬合成。但是這是一個非常清晰地例子,表明對這個問題來講,將類繼承作為模型有什么問題,因為AuthController和LoginController都不特化對方的行為,所以它們之間的繼承沒有太大的意義,除非類是你唯一的設計模式。與此相反的是,我們在一些簡單的合成中分層,然后它們就可以合作了,同時他倆都享有繼承自父類Controller的好處。
如果你熟悉面向類(OO)的設計,這都聽該看起來十分熟悉和自然。
去類化
但是,我們真的需要用一個父類,兩個子類,和一些合成來對這個問題建立模型嗎?有辦法利用OLOO風格的行為委托得到 簡單得多 的設計嗎?有的!
var LoginController = {
errors: [],
getUser: function() {
return document.getElementById( "login_username" ).value;
},
getPassword: function() {
return document.getElementById( "login_password" ).value;
},
validateEntry: function(user,pw) {
user = user || this.getUser();
pw = pw || this.getPassword();
if (!(user && pw)) {
return this.failure( "Please enter a username & password!" );
}
else if (pw.length < 5) {
return this.failure( "Password must be 5+ characters!" );
}
// 到這里了?輸入合法!
return true;
},
showDialog: function(title,msg) {
// 在對話框中向用于展示成功消息
},
failure: function(err) {
this.errors.push( err );
this.showDialog( "Error", "Login invalid: " + err );
}
};
// 鏈接`AuthController`委托到`LoginController`
var AuthController = Object.create( LoginController );
AuthController.errors = [];
AuthController.checkAuth = function() {
var user = this.getUser();
var pw = this.getPassword();
if (this.validateEntry( user, pw )) {
this.server( "/check-auth",{
user: user,
pw: pw
} )
.then( this.accepted.bind( this ) )
.fail( this.rejected.bind( this ) );
}
};
AuthController.server = function(url,data) {
return $.ajax( {
url: url,
data: data
} );
};
AuthController.accepted = function() {
this.showDialog( "Success", "Authenticated!" )
};
AuthController.rejected = function(err) {
this.failure( "Auth Failed: " + err );
};
因為AuthController只是一個對象(LoginController也是),我們不需要初始化(比如new AuthController())就能執(zhí)行我們的任務。所有我們要做的是:
AuthController.checkAuth();
當然,通過OLOO,如果你確實需要在委托鏈上創(chuàng)建一個或多個附加的對象時也很容易,而且仍然不需要任何像類實例化那樣的東西:
var controller1 = Object.create( AuthController );
var controller2 = Object.create( AuthController );
使用行為委托,AuthController和LoginController僅僅是對象,互相是 水平 對等的,而且沒有被安排或關聯(lián)成面向類中的父與子。我們有些隨意地選擇讓AuthController委托至LoginController —— 相反方向的委托也同樣是有效的。
第二個代碼段的主要要點是,我們只擁有兩個實體(LoginController and AuthController),而 不是之前的三個。
我們不需要一個基本的Controller類來在兩個子類間“分享”行為,因為委托是一種可以給我們所需功能的,足夠強大的機制。同時,就像之前注意的,我們也不需要實例化我們的對象來使它們工作,因為這里沒有類,只有對象自身。 另外,這里不需要 合成 作為委托來給兩個對象 差異化 地合作的能力。
最后,由于沒有讓名稱success(..)和failure(..)在兩個對象上相同,我們避開了面向類的設計的多態(tài)陷阱:它將會需要難看的顯式假想多態(tài)。相反,我們在AuthController上稱它們?yōu)?code>accepted()和rejected(..) —— 對于他們的具體任務來說,稍稍更具描述性的名稱。
底線: 我們最終得到了相同的結(jié)果,但是用了(顯著的)更簡單的設計。這就是OLOO風格代碼和 行為委托 設計模式的力量。
更好的語法
一個使ES6class看似如此誘人的更好的東西是(見附錄A來了解為什么要避免它!),聲明類方法的速記語法:
class Foo {
methodName() { /* .. */ }
}
我們從聲明中扔掉了單詞function,這使所有的JS開發(fā)者歡呼!
你可能已經(jīng)注意到,而且為此感到沮喪:上面推薦的OLOO語法出現(xiàn)了許多function,這看起來像對OLOO簡化目標的詆毀。但它不必是!
在ES6中,我們可以在任何字面對象中使用 簡約方法聲明,所以一個OLOO風格的對象可以用這種方式聲明(與class語法中相同的語法糖):
var LoginController = {
errors: [],
getUser() { // 看,沒有`function`!
// ...
},
getPassword() {
// ...
}
// ...
};
唯一的區(qū)別是字面對象的元素間依然需要,逗號分隔符,而class語法不必如此。這是在整件事情上很小的讓步。
還有,在ES6中,一個你使用的更笨重的語法(比如AuthController的定義中):你一個一個地給屬性賦值而不使用字面對象,可以改寫為使用字面對象(于是你可以使用簡約方法),而且你可以使用Object.setPrototypeOf(..)來修改對象的[[Prototype]],像這樣:
// 使用更好的字面對象語法 w/ 簡約方法!
var AuthController = {
errors: [],
checkAuth() {
// ...
},
server(url,data) {
// ...
}
// ...
};
// 現(xiàn)在, 鏈接`AuthController`委托至`LoginController`
Object.setPrototypeOf( AuthController, LoginController );
ES6中的OLOO風格,與簡明方法一起,變得比它以前 友好得多(即使在以前,它也比經(jīng)典的原型風格代碼簡單好看的多)。 你不必非得選用類(復雜性)來得到干凈漂亮的對象語法!
沒有詞法
簡約方法確實有一個缺點,一個重要的細節(jié)??紤]這段代碼:
var Foo = {
bar() { /*..*/ },
baz: function baz() { /*..*/ }
};
這是去掉語法糖后,這段代碼將如何工作:
var Foo = {
bar: function() { /*..*/ },
baz: function baz() { /*..*/ }
};
看到區(qū)別了?bar()的速記法變成了一個附著在bar屬性上的 匿名函數(shù)表達式(function()..),因為函數(shù)對象本身沒有名稱標識符。和擁有詞法名稱標識符baz,附著在.baz屬性上的手動指定的 命名函數(shù)表達式(function baz()..)做個比較。
那又怎么樣?在 “你不懂JS” 系列的 “作用域與閉包” 這本書中,我們詳細講解了 匿名函數(shù)表達式 的三個主要缺點。我們簡單地重復一下它們,以便于我們和簡明方法相比較。
一個匿名函數(shù)缺少name標識符:
- 使調(diào)試時的棧追蹤變得困難
- 使自引用(遞歸,事件綁定等)變得困難
- 使代碼(稍稍)變得難于理解
第一和第三條不適用于簡明方法。
雖然去掉語法糖使用 匿名函數(shù)表達式 一般會使棧追蹤中沒有name。簡明方法在語言規(guī)范中被要求去設置相應的函數(shù)對象內(nèi)部的name屬性,所以棧追蹤應當可以使用它(這是依賴于具體實現(xiàn)的,所以不能保證)。
不幸的是,第二條 仍然是簡明方法的一個缺陷。 它們不會有詞法標識符用來自引用。考慮:
var Foo = {
bar: function(x) {
if (x < 10) {
return Foo.bar( x * 2 );
}
return x;
},
baz: function baz(x) {
if (x < 10) {
return baz( x * 2 );
}
return x;
}
};
在這個例子中上面的手動Foo.bar(x*2)引用就足夠了,但是在許多情況下,一個函數(shù)沒必要能夠這樣做,比如使用this綁定,函數(shù)在委托中被分享到不同的對象,等等。你將會想要使用一個真正的自引用,而函數(shù)對象的name標識符是實現(xiàn)的最佳方式。
只要小心簡明方法的這個注意點,而且如果當你陷入缺少自引用的問題時,僅僅為這個聲明 放棄簡明方法語法,取代以手動的 命名函數(shù)表達式 聲明形式:baz: function baz(){..}。
自省
如果你花了很長時間在面向類的編程方式(不管是JS還是其他的語言),你可能會對 類型自省 很熟悉:自省一個實例來找出它是什么 種類 的對象。在類的實例上進行 類型自省 的主要目的是根據(jù) 對象是如何創(chuàng)建的 來推斷它的結(jié)構/能力。
考慮這段代碼,它使用instanceof(見第五章)來自省一個對象a1來推斷它的能力:
function Foo() {
// ...
}
Foo.prototype.something = function(){
// ...
}
var a1 = new Foo();
// 稍后
if (a1 instanceof Foo) {
a1.something();
}
因為Foo.prototype(不是Foo!)在a1的[[Prototype]]鏈上(見第五章),instanceof操作符(使人困惑地)假裝告訴我們a1是一個Foo“類”的實例。有了這個知識,我們假定a1有Foo“類”中描述的能力。
當然,這里沒有Foo類,只有一個普通的函數(shù)Foo,它恰好擁有一個引用指向一個隨意的對象(Foo.prototype),而a1恰好委托鏈接至這個對象。通過它的語法,instanceof假裝檢查了a1和Foo之間的關系,但它實際上告訴我們的是a1和Foo.prototype(這個隨意被引用的對象)是否有關聯(lián)。
instanceof在語義上的混亂(和間接)意味著,要使用以instanceof為基礎的自省來查詢對象a1是否與討論中的對象有關聯(lián),你 不得不 擁有一個持有對這個對象引用的函數(shù) —— 你不能直接查詢這兩個對象是否有關聯(lián)。
回想本章前面的抽象Foo / Bar / b1例子,我們在這里縮寫一下:
function Foo() { /* .. */ }
Foo.prototype...
function Bar() { /* .. */ }
Bar.prototype = Object.create( Foo.prototype );
var b1 = new Bar( "b1" );
為了在這個例子中的實體上進行 類型自省, 使用instanceof和.prototype語義,這里有各種你可能需要實施的檢查:
// 的`Foo`和`Bar`互相聯(lián)系
Bar.prototype instanceof Foo; // true
Object.getPrototypeOf( Bar.prototype ) === Foo.prototype; // true
Foo.prototype.isPrototypeOf( Bar.prototype ); // true
// `b1`與`Foo`和`Bar`的聯(lián)系
b1 instanceof Foo; // true
b1 instanceof Bar; // true
Object.getPrototypeOf( b1 ) === Bar.prototype; // true
Foo.prototype.isPrototypeOf( b1 ); // true
Bar.prototype.isPrototypeOf( b1 ); // true
可以說,其中有些爛透了。舉個例子,直覺上(用類)你可能想說這樣的東西Bar instanceof Foo(因為很容易混淆“實例”的意義認為它包含“繼承”),但在JS中這不是一個合理的比較。你不得不說Bar.prototype instanceof Foo。
另一個常見,但也許健壯性更差的 類型自省 模式叫“duck typing(鴨子類型)”,比起instanceof來許多開發(fā)者都傾向于它。這個術語源自一則諺語,“如果它看起來像鴨子,叫起來像鴨子,那么它一定是一只鴨子”。
例如:
if (a1.something) {
a1.something();
}
與其檢查a1和一個持有可委托的something()函數(shù)的對象的關系,我們假設a1.something測試通過意味著a1有能力調(diào)用.something()(不管是直接在a1上直接找到方法,還是委托至其他對象)。就其本身而言,這種假設沒什么風險。
但是“鴨子類型”常常被擴展用于 除了被測試關于對象能力以外的其他假設,這當然會在測試中引入更多風險(比如脆弱的設計)。
“鴨子類型”的一個值得注意的例子來自于ES6的Promises(就是我們前面解釋過,將不再本書內(nèi)涵蓋的內(nèi)容)。
由于種種原因,需要判定任意一個對象引用是否 是一個Promise,但測試是通過檢查對象是否恰好有then()函數(shù)出現(xiàn)在它上面來完成的。換句話說,如果任何對象 恰好有一個then()方法,ES6的Promises將會無條件地假設這個對象 是“thenable” 的,而且因此會期望它按照所有的Promises標準行為那樣一致地動作。
如果你有任何非Promise對象,而卻不管因為什么它恰好擁有then()方法,你會被強烈建議使它遠離ES6的Promise機制,來避免破壞這種假設。
這個例子清楚地展現(xiàn)了“鴨子類型”的風險。你應當僅在可控的條件下,保守地使用這種方式。
再次將我們的注意力轉(zhuǎn)向本章中出現(xiàn)的OLOO風格的代碼,類型自省 變得清晰多了。讓我們回想(并縮寫)本章的Foo / Bar / b1的OLOO示例:
var Foo = { /* .. */ };
var Bar = Object.create( Foo );
Bar...
var b1 = Object.create( Bar );
使用這種OLOO方式,我們所擁有的一切都是通過[[Prototype]]委托關聯(lián)起來的普通對象,這是我們可能會用到的大幅簡化后的 類型自省:
// `Foo`和`Bar`互相的聯(lián)系
Foo.isPrototypeOf( Bar ); // true
Object.getPrototypeOf( Bar ) === Foo; // true
// `b1`與`Foo`和`Bar`的聯(lián)系
Foo.isPrototypeOf( b1 ); // true
Bar.isPrototypeOf( b1 ); // true
Object.getPrototypeOf( b1 ) === Bar; // true
我們不再使用instanceof,因為它令人迷惑地假裝與類有關系。現(xiàn)在,我們只需要(非正式地)問這個問題,“你是我的 一個 原型嗎?”。不再需要用Foo.prototype或者痛苦冗長的Foo.prototype.isPrototypeOf(..)來間接地查詢了。
我想可以說這些檢查比起前面一組自省檢查,極大地減少了復雜性/混亂。又一次,我們看到了在JavaScript中OLOO要比類風格的編碼簡單(但有著相同的力量)。
復習
在你的軟件體系結(jié)構中,類和繼承是你可以 選用 或 不選用 的設計模式。多數(shù)開發(fā)者理所當然地認為類是組織代碼的唯一(正確的)方法,但我們在這里看到了另一種不太常被提到的,但實際上十分強大的設計模式:行為委托。
行為委托意味著對象彼此是對等的,在它們自己當中相互委托,而不是父類與子類的關系。JavaScript的[[Prototype]]機制的設計本質(zhì),就是行為委托機制。這意味著我們可以選擇掙扎著在JS上實現(xiàn)類機制,也可以欣然接受[[Prototype]]作為委托機制的本性。
當你僅用對象設計代碼時,它不僅能簡化你使用的語法,而且它還能實際上引領更簡單的代碼結(jié)構設計。
OLOO(鏈接到其他對象的對像)是一種沒有類的抽象,而直接創(chuàng)建和關聯(lián)對象的代碼風格。OLOO十分自然地實現(xiàn)了基于[[Prototype]]的行為委托。