特別說明,為便于查閱,文章轉自https://github.com/getify/You-Dont-Know-JS
在第五章中,我們詳細地討論了 [[Prototype]]
機制,和 為什么 對于描述“類”或“繼承”來說它是那么使人糊涂和不合適。我們一路跋涉,不僅涉及了相當繁冗的語法(使代碼凌亂的 .prototype
),還有各種陷阱(比如使人吃驚的 .constructor
解析和難看的假想多態語法)。我們探索了許多人試圖用抹平這些粗糙的區域而使用的各種“mixin”方法。
這時一個常見的反應是,想知道為什么這些看起來如此簡單的事情這么復雜?,F在我們已經拉開帷幕看到了它是多么麻煩,這并不奇怪:大多數 JS 開發者從不探究得這么深,而將這一團糟交給一個“類”包去幫他們處理。
我希望到現在你不會甘心于敷衍了事并把這樣的細節丟給一個“黑盒”庫。現在我們來深入講解我們 如何與應當如何 以一種比類造成的困惑 簡單得多而且更直接的方式 來考慮 JS 中對象的 [[Prototype]]
機制。
簡單地復習一下第五章的結論,[[Prototype]]
機制是一種存在于一個對象上的內部鏈接,它指向一個其他對象。
當一個屬性/方法引用在一個對象上發生,而這樣的屬性/方法又不存在時,這個鏈接就會被使用。在這種情況下,[[Prototype]]
鏈接告訴引擎去那個被鏈接的對象上尋找該屬性/方法。接下來,如果那個對象也不能滿足查詢,就沿著它的 [[Prototype]]
查詢,如此繼續。這種對象間的一系列鏈接構成了所謂的“原形鏈”。
換句話說,對于我們能在 JavaScript 中利用的功能的實際機制來說,其重要的實質 全部在于被連接到其他對象的對象。
這個觀點是理解本章其余部分的動機和方法的重要基礎!
邁向面向委托的設計
為了將我們的思想恰當地集中在如何用最直截了當的方法使用 [[Prototype]]
,我們必須認識到它代表一種根本上與類不同的設計模式(見第四章)。
注意 某些 面向類的設計依然是很有效的,所以不要扔掉你知道的每一件事(扔掉大多數就行了?。?。比如,封裝 就十分強大,而且與委托是兼容的(雖然不那么常見)。
我們需要試著將我們的思維從類/繼承的設計模式轉變為行為代理設計模式。如果你已經使用你在教育/工作生涯中思考類的方式做了大多數或所有的編程工作,這可能感覺不舒服或不自然。你可能需要嘗試這種思維過程好幾次,才能適應這種非常不同的思考方式。
我將首先帶你進行一些理論練習,之后我們會一對一地看一些更實際的例子來為你自己的代碼提供實踐環境。
類理論
比方說我們有幾個相似的任務(“XYZ”,“ABC”,等)需要在我們的軟件中建模。
使用類,你設計這個場景的方式是:定義一個泛化的父類(基類)比如 Task
,為所有的“同類”任務定義共享的行為。然后,你定義子類 XYZ
和 ABC
,它們都繼承自 Task
,每個都分別添加了特化的行為來處理各自的任務。
重要的是, 類設計模式將鼓勵你發揮繼承的最大功效,當你在 XYZ
任務中覆蓋 Task
的某些泛化方法的定義時,你將會想利用方法覆蓋(和多態),也許會利用 super
來調用這個方法的泛化版本,為它添加更多的行為。你很可能會找到幾個這樣的地方:可以“抽象”到父類中,并在子類中特化(覆蓋)的一般化行為。。
這是一些關于這個場景的假想代碼:
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 {
// ...
}
現在,你可以初始化一個或多個 XYZ
子類的 拷貝,并且使用這些實例來執行“XYZ”任務。這些實例已經 同時拷貝 了泛化的 Task
定義的行為和具體的 XYZ
定義的行為。類似地,ABC
類的實例將拷貝 Task
的行為和具體的 ABC
的行為。在構建完成之后,你通常僅會與這些實例交互(而不是類),因為每個實例都拷貝了完成計劃任務的所有行為。
委托理論
但是現在讓我們試著用 行為委托 代替 類 來思考同樣的問題。
你將首先定義一個稱為 Task
的 對象(不是一個類,也不是一個大多數 JS 開發者想讓你相信的 function
),而且它將擁有具體的行為,這些行為包含各種任務可以使用的(讀作:委托至!)工具方法。然后,對于每個任務(“XYZ”,“ABC”),你定義一個 對象 來持有這個特定任務的數據/行為。你 鏈接 你的特定任務對象到 Task
工具對象,允許它們在必要的時候可以委托到它。
基本上,你認為執行任務“XYZ”就是從兩個兄弟/對等的對象(XYZ
和 Task
)中請求行為來完成它。與其通過類的拷貝將它們組合在一起,我們可以將它們保持在分離的對象中,而且可以在需要的情況下允許 XYZ
對象 委托到 Task
。
這里是一些簡單的代碼,示意你如何實現它:
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
不是類(也不是函數),它們 僅僅是對象。XYZ
通過 Object.create()
創建,來 [[Prototype]]
委托到 Task
對象(見第五章)。
作為與面向類(也就是,OO —— 面向對象)的對比,我稱這種風格的代碼為 “OLOO”(objects-linked-to-other-objects(鏈接到其他對象的對象))。所有我們 真正 關心的是,對象 XYZ
委托到對象 Task
(對象 ABC
也一樣)。
在 JavaScript 中,[[Prototype]]
機制將 對象 鏈接到其他 對象。無論你多么想說服自己這不是真的,JavaScript 沒有像“類”那樣的抽象機制。這就像逆水行舟:你 可以 做到,但你 選擇 了逆流而上,所以很明顯地,你會更困難地達到目的地。
OLOO 風格的代碼 中有一些需要注意的不同:
- 前一個類的例子中的
id
和label
數據成員都是XYZ
上的直接數據屬性(它們都不在Task
上)。一般來說,當[[Prototype]]
委托引入時,你想使狀態保持在委托者上(XYZ
,ABC
),不是在委托上(Task
)。 - 在類的設計模式中,我們故意在父類(
Task
)和子類(XYZ
)上采用相同的命名outputTask
,以至于我們可以利用覆蓋(多態)。在委托的行為中,我們反其道而行之:我們盡一切可能避免在[[Prototype]]
鏈的不同層級上給出相同的命名(稱為“遮蔽” —— 見第五章),因為這些命名沖突會導致尷尬/脆弱的語法來消除引用的歧義(見第四章),而我們想避免它。
這種設計模式不那么要求那些傾向于被覆蓋的泛化的方法名,而是要求針對于每個對象的 具體 行為類型給出更具描述性的方法名。這實際上會產生更易于理解/維護的代碼,因為方法名(不僅在定義的位置,而是擴散到其他代碼中)變得更加明白(代碼即文檔)。 -
this.setID(ID);
位于對象XYZ
的一個方法內部,它首先在XYZ
上查找setID(..)
,但因為它不能在XYZ
上找到叫這個名稱的方法,[[Prototype]]
委托意味著它可以沿著鏈接到Task
來尋找setID()
,這樣當然就找到了。另外,由于調用點的隱含this
綁定規則(見第二章),當setID()
運行時,即便方法是在Task
上找到的,這個函數調用的this
綁定依然是我們期望和想要的XYZ
。我們在代碼稍后的this.outputID()
中也看到了同樣的事情。
換句話說,我們可以使用存在于Task
上的泛化工具與XYZ
互動,因為XYZ
可以委托至Task
。
行為委托 意味著:在某個對象(XYZ
)的屬性或方法沒能在這個對象(XYZ
)上找到時,讓這個對象(XYZ
)為屬性或方法引用提供一個委托(Task
)。
這是一個 極其強大 的設計模式,與父類和子類,繼承,多態等有很大的不同。與其在你的思維中縱向地,從上面父類到下面子類地組織對象,你應當并列地,對等地考慮對象,而且對象間擁有方向性的委托鏈接。
注意: 委托更適于作為內部實現的細節,而不是直接暴露在 API 接口的設計中。在上面的例子中,我們的 API 設計沒必要有意地讓開發者調用 XYZ.setID()
(當然我們可以?。?。我們以某種隱藏的方式將委托作為我們 API 的內部細節,即 XYZ.prepareTask(..)
委托到 Task.setID(..)
。詳細的內容,參照第五章的“鏈接作為候補?”中的討論。
相互委托(不允許)
你不能在兩個或多個對象間相互地委托(雙向地)對方來創建一個 循環 。如果你使 B
鏈接到 A
,然后試著讓 A
鏈接到 B
,那么你將得到一個錯誤。
這樣的事情不被允許有些可惜(不是非常令人驚訝,但稍稍有些惱人)。如果你制造一個在任意一方都不存在的屬性/方法引用,你就會在 [[Prototype]]
上得到一個無限遞歸的循環。但如果所有的引用都嚴格存在,那么 B
就可以委托至 A
,或相反,而且它可以工作。這意味著你可以為了多種任務用這兩個對象互相委托至對方。有一些情況這可能會有用。
但它不被允許是因為引擎的實現者發現,在設置時檢查(并拒絕?。o限循環引用一次,要比每次你在一個對象上查詢屬性時都做相同檢查的性能要高。
調試
我們將簡單地討論一個可能困擾開發者的微妙的細節。一般來說,JS 語言規范不會控制瀏覽器開發者工具如何向開發者表示指定的值/結構,所以每種瀏覽器/引擎都自由地按需要解釋這個事情。因此,瀏覽器/工具 不總是意見統一。特別地,我們現在要考察的行為就是當前僅在 Chrome 的開發者工具中觀察到的。
考慮這段傳統的“類構造器”風格的 JS 代碼,正如它將在 Chrome 開發者工具 控制臺 中出現的:
function Foo() {}
var a1 = new Foo();
a1; // Foo {}
讓我們看一下這個代碼段的最后一行:對表達式 a1
進行求值的輸出,打印 Foo {}
。如果你在 FireFox 中試用同樣的代碼,你很可能會看到 Object {}
。為什么會有不同?這些輸出意味著什么?
Chrome 實質上在說“{} 是一個由名為‘Foo’的函數創建的空對象”。Firefox 在說“{} 是一個由 Object 普通構建的空對象”。這種微妙的區別是因為 Chrome 在像一個 內部屬性 一樣,動態跟蹤執行創建的實際方法的名稱,而其他瀏覽器不會跟蹤這樣的附加信息。
試圖用 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
合法地改變為其他的東西(“Gotcha”),Chrome 控制臺依舊使用名稱“Foo”。
那么,說明前面問題(它使用 .constructor.name
嗎?)的答案是 不,它一定在內部追蹤其他的什么東西。
但是,且慢!讓我們看看這種行為如何與 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,而且就在你讀到這里的時候,它可能已經被修復了。所以你可能已經看到了被修改過的 a1; // Object{}
。
這個 bug 暫且不論,Chrome 執行的(剛剛在代碼段中展示的)“構造器名稱”內部追蹤(目前僅用于調試輸出的目的),是一個僅在 Chrome 內部存在的擴張行為,它已經超出了 JS 語言規范要求的范圍。
如果你不使用“構造器”來制造你的對象,就像我們在本章的 OLOO 風格代碼中不鼓勵的那樣,那么你將會得到一個 Chrome 不會為其追蹤內部“構造器名稱”的對象,所以這樣的對象將正確地僅僅被輸出“Object {}”,意味著“從 Object() 構建生成的對象”。
不要認為 這代表一個 OLOO 風格代碼的缺點。當你用 OLOO 編碼而且用行為委托作為你的設計模式時,誰 “創建了”(也就是,哪個函數 被和 new
一起調用了?)一些對象是一個無關的細節。Chrome 特殊的內部“構造器名稱”追蹤僅僅在你完全接受“類風格”編碼時才有用,而在你接受 OLOO 委托時是沒有意義的。
思維模型比較
現在你至少在理論上可以看到“類”和“委托”設計模式的不同了,讓我們看看這些設計模式在我們用來推導我們代碼的思維模型上的含義。
我們將查看一些更加理論上的(“Foo”,“Bar”)代碼,然后比較兩種方法(OO vs. OLOO)的代碼實現。第一段代碼使用經典的(“原型的”)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
。這對你來說應當看起來十分熟悉。沒有太具開拓性的東西發生。
現在,讓我們使用 OLOO 風格的代碼 實現完全相同的功能:
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
之間那樣。我們仍然有三個對象鏈接在一起。
但重要的是,我們極大地簡化了發生的 所有其他事項,因為我們現在僅僅建立了相互鏈接的 對象,而不需要所有其他討厭且困惑的看起來像類(但動起來不像)的東西,還有構造器,原型和 new
調用。
問問你自己:如果我能用 OLOO 風格代碼得到我用“類”風格代碼得到的一樣的東西,但 OLOO 更簡單而且需要考慮的事情更少,OLOO 不是更好嗎?
讓我們講解一下這兩個代碼段間涉及的思維模型。
首先,類風格的代碼段意味著這樣的實體與它們的關系的思維模型:

實際上,這有點兒不公平/誤導,因為它展示了許多額外的,你在 技術上 一直不需要知道(雖然你 需要 理解它)的細節。一個關鍵是,它是一系列十分復雜的關系。但另一個關鍵是:如果你花時間來沿著這些關系的箭頭走,在 JS 的機制中 有數量驚人的內部統一性。
例如,JS 函數可以訪問 call(..)
,apply(..)
和 bind(..)
(見第二章)的能力是因為函數本身是對象,而函數對象還擁有一個 [[Prototype]]
鏈接,鏈到 Function.prototype
對象,它定義了那些任何函數對象都可以委托到的默認方法。JS 可以做這些事情,你也能!
好了,現在讓我們看一個這張圖的 稍稍 簡化的版本,用它來進行比較稍微“公平”一點 —— 它僅展示了 相關 的實體與關系。

仍然非常復雜,對吧?虛線描繪了當你在 Foo.prototype
和 Bar.prototype
間建立“繼承”時的隱含關系,而且還沒有 修復 丟失的 .constructor
屬性引用(見第五章“復活構造器”)。即便將虛線去掉,每次你與對象鏈接打交道時,這個思維模型依然要變很多可怕的戲法。
現在,讓我們看看 OLOO 風格代碼的思維模型:

正如你比較它們所得到的,十分明顯,OLOO 風格的代碼 需要關心的東西少太多了,因為 OLOO 風格代碼接受了 事實:我們唯一需要真正關心的事情是 鏈接到其他對象的對象。
所有其他“類”的爛設計用一種令人費解而且復雜的方式得到相同的結果。去掉那些東西,事情就變得簡單得多(還不會失去任何功能)。
Classes vs. Objects
我們已經看到了各種理論的探索和“類”與“行為委托”的思維模型的比較?,F在讓我們來看看更具體的代碼場景,來展示你如何實際應用這些想法。
我們將首先講解一種在前端網頁開發中的典型場景:建造 UI 部件(按鈕,下拉列表等等)。
Widget “類”
因為你可能還是如此地習慣于 OO 設計模式,你很可能會立即這樣考慮這個問題:一個父類(也許稱為 Widget
)擁有所有共通的基本部件行為,然后衍生的子類擁有具體的部件類型(比如 Button
)。
注意: 為了 DOM 和 CSS 的操作,我們將在這里使用 JQuery,這僅僅是因為對于我們現在的討論,它不是一個我們真正關心的細節。這些代碼中不關心你用哪個 JS 框架(JQuery,Dojo,YUI 等等)來解決如此無趣的問題。
讓我們來看看,在沒有任何“類”幫助庫或語法的情況下,我們如何用經典風格的純 JS 來實現“類”設計:
// 父類
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"構造器調用
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"調用
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(..)
,之后在我們的子類中覆蓋它,但不是完全替代它,而是用按鈕特定的行為增強這個基礎功能。
注意 顯式假想多態 的丑態,Widget.call
和 Widget.prototype.render.call
引用是為了偽裝從子“類”方法得到“父類”基礎方法支持的“super”調用。呃。
ES6 class
語法糖
我們會在附錄A中講解 ES6 的 class
語法糖,但是讓我們演示一下我們如何用 class
來實現相同的代碼。
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
,許多前面經典方法中難看的語法被改善了。super(..)
的存在看起來非常適宜(但當你深入挖掘它時,不全是好事?。?。
除了語法上的改進,這些都不是 真正的 類,因為它們仍然工作在 [[Prototype]]
機制之上。它們依然會受到思維模型不匹配的拖累,就像我們在第四,五章中,和直到現在探索的那樣。附錄A將會詳細講解 ES6 class
語法和它的含義。我們將會看到為什么解決語法上的小問題不會實質上解決我們在 JS 中的類的困惑,雖然它做出了勇敢的努力假裝解決了問題!
無論你是使用經典的原型語法還是新的 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
是一個子類,Widget
只是一個對象 和某種具體類型的部件也許想要代理到的工具的集合,而且 Button
也只是一個獨立的對象(當然,帶有委托至 Widget
的鏈接!)。
從設計模式的角度來看,我們 沒有 像類的方法建議的那樣,在兩個對象中共享相同的 render(..)
方法名稱,而是選擇了更能描述每個特定任務的不同的名稱。同樣的原因,初始化 方法被分別稱為 init(..)
和 setup(..)
。
不僅委托設計模式建議使用不同而且更具描述性的名稱,而且在 OLOO 中這樣做會避免難看的顯式假想多態調用,正如你可以通過簡單,相對的 this.init(..)
和 this.insert(..)
委托調用看到的。
語法上,我們也沒有任何構造器,.prototype
或者 new
出現,它們事實上是不必要的設計。
現在,如果你再細心考察一下,你可能會注意到之前僅有一個調用(var btn1 = new Button(..)
),而現在有了兩個(var btn1 = Object.create(Button)
和 btn1.setup(..)
)。這猛地看起來像是一個缺點(代碼變多了)。
然而,即便是這樣的事情,和經典原型風格比起來也是 OLOO 風格代碼的優點。為什么?
用類的構造器,你“強制”(不完全是這樣,但是被強烈建議)構建和初始化在同一個步驟中進行。然而,有許多種情況,能夠將這兩步分開做(就像你在 OLOO 中做的)更靈活。
舉個例子,我們假定你在程序的最開始,在一個池中創建所有的實例,但你等到在它們被從池中找出并使用之前再用指定的設置初始化它們。我們的例子中,這兩個調用緊挨在一起,當然它們也可以按需要發生在非常不同的時間和代碼中非常不同的部分。
OLOO 對關注點分離原則有 更好 的支持,也就是創建和初始化沒有必要合并在同一個操作中。
更簡單的設計
OLOO 除了提供表面上更簡單(而且更靈活?。┑拇a之外,行為委托作為一個模式實際上會帶來更簡單的代碼架構。讓我們講解最后一個例子來說明 OLOO 是如何簡化你的整體設計的。
這個場景中我們將講解兩個控制器對象,一個用來處理網頁的登錄 form(表單),另一個實際處理服務器的認證(通信)。
我們需要幫助工具來進行與服務器的 Ajax 通信。我們將使用 JQuery(雖然其他的框架都可以),因為它不僅為我們處理 Ajax,而且還返回一個類似 Promise 的應答,這樣我們就可以在代碼中使用 .then(..)
來監聽這個應答。
注意: 我們不會再這里講到 Promise,但我們會在以后的 你不懂 JS 系列中講到。
根據典型的類的設計模式,我們在一個叫做 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"調用
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"調用
Controller.prototype.success.call( this, "Authenticated!" );
};
// 覆蓋以擴展基本的 `failure()`
AuthController.prototype.failure = function(err) {
// "super"調用
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 互動,所以它變成了一個數據屬性成員。
另外一件要提的事情是,我們選擇一些 合成 散布在繼承的頂端。AuthController
需要知道 LoginController
,所以我們初始化它(new LoginController()
),使它一個成為 this.login
的類屬性成員來引用它,這樣 AuthController
才可以調用 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()
)就能執行我們的任務。所有我們要做的是:
AuthController.checkAuth();
當然,通過 OLOO,如果你確實需要在委托鏈上創建一個或多個附加的對象時也很容易,而且仍然不需要任何像類實例化那樣的東西:
var controller1 = Object.create( AuthController );
var controller2 = Object.create( AuthController );
使用行為委托,AuthController
和 LoginController
僅僅是對象,互相是 水平 對等的,而且沒有被安排或關聯成面向類中的父與子。我們有些隨意地選擇讓 AuthController
委托至 LoginController
—— 相反方向的委托也同樣是有效的。
第二個代碼段的主要要點是,我們只擁有兩個實體(LoginController
and AuthController
),而 不是之前的三個。
我們不需要一個基本的 Controller
類來在兩個子類間“分享”行為,因為委托是一種可以給我們所需功能的,足夠強大的機制。同時,就像之前注意的,我們也不需要實例化我們的對象來使它們工作,因為這里沒有類,只有對象自身。 另外,這里不需要 合成 作為委托來給兩個對象 差異化 地合作的能力。
最后,由于沒有讓名稱 success(..)
和 failure(..)
在兩個對象上相同,我們避開了面向類的設計的多態陷阱:它將會需要難看的顯式假想多態。相反,我們在 AuthController
上稱它們為 accepted()
和 rejected(..)
—— 對于它們的具體任務來說,稍稍更具描述性的名稱。
底線: 我們最終得到了相同的結果,但是用了(顯著的)更簡單的設計。這就是 OLOO 風格代碼和 行為委托 設計模式的力量。
更好的語法
一個使 ES6 class
看似如此誘人的更好的東西是(見附錄A來了解為什么要避免它?。?,聲明類方法的速記語法:
class Foo {
methodName() { /* .. */ }
}
我們從聲明中扔掉了單詞 function
,這使所有的 JS 開發者歡呼!
你可能已經注意到,而且為此感到沮喪:上面推薦的 OLOO 語法出現了許多 function
,這看起來像是對 OLOO 簡化目標的詆毀。但它不必是!
在 ES6 中,我們可以在任何字面對象中使用 簡約方法聲明,所以一個 OLOO 風格的對象可以用這種方式聲明(與 class
語法中相同的語法糖):
var LoginController = {
errors: [],
getUser() { // 看,沒有 `function`!
// ...
},
getPassword() {
// ...
}
// ...
};
唯一的區別是字面對象的元素間依然需要 ,
逗號分隔符,而 class
語法不必如此。這是在整件事情上很小的讓步。
還有,在 ES6 中,一個你使用的更笨重的語法(比如 AuthController
的定義中):你一個一個地給屬性賦值而不使用字面對象,可以改寫為使用字面對象(于是你可以使用簡約方法),而且你可以使用 Object.setPrototypeOf(..)
來修改對象的 [[Prototype]]
,像這樣:
// 使用更好的字面對象語法 w/ 簡約方法!
var AuthController = {
errors: [],
checkAuth() {
// ...
},
server(url,data) {
// ...
}
// ...
};
// 現在, 鏈接 `AuthController` 委托至 `LoginController`
Object.setPrototypeOf( AuthController, LoginController );
ES6 中的 OLOO 風格,與簡明方法一起,變得比它以前 友好得多(即使在以前,它也比經典的原型風格代碼簡單好看的多)。 你不必非得選用類(復雜性)來得到干凈漂亮的對象語法!
沒有詞法
簡約方法確實有一個缺點,一個重要的細節??紤]這段代碼:
var Foo = {
bar() { /*..*/ },
baz: function baz() { /*..*/ }
};
這是去掉語法糖后,這段代碼將如何工作:
var Foo = {
bar: function() { /*..*/ },
baz: function baz() { /*..*/ }
};
看到區別了?bar()
的速記法變成了一個附著在 bar
屬性上的 匿名函數表達式(function()..
),因為函數對象本身沒有名稱標識符。和擁有詞法名稱標識符 baz
,附著在 .baz
屬性上的手動指定的 命名函數表達式(function baz()..
)做個比較。
那又怎么樣?在 “你不懂 JS” 系列的 “作用域與閉包” 這本書中,我們詳細講解了 匿名函數表達式 的三個主要缺點。我們簡單地重復一下它們,以便于我們和簡明方法相比較。
一個匿名函數缺少 name
標識符:
- 使調試時的棧追蹤變得困難
- 使自引用(遞歸,事件綁定等)變得困難
- 使代碼(稍稍)變得難于理解
第一和第三條不適用于簡明方法。
雖然去掉語法糖使用 匿名函數表達式 一般會使棧追蹤中沒有 name
。簡明方法在語言規范中被要求去設置相應的函數對象內部的 name
屬性,所以棧追蹤應當可以使用它(這是依賴于具體實現的,所以不能保證)。
不幸的是,第二條 仍然是簡明方法的一個缺陷。 它們不會有詞法標識符用來自引用。考慮:
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)
引用就足夠了,但是在許多情況下,一個函數不一定能夠這樣做,比如使用 this
綁定,函數在委托中被分享到不同的對象,等等。你將會想要使用一個真正的自引用,而函數對象的 name
標識符是實現的最佳方式。
只要小心簡明方法的這個注意點,而且如果當你陷入缺少自引用的問題時,僅僅為這個聲明 放棄簡明方法語法,取代以手動的 命名函數表達式 聲明形式:baz: function baz(){..}
。
自省
如果你花了很長時間在面向類的編程方式(不管是 JS 還是其他的語言)上,你可能會對 類型自省 很熟悉:自省一個實例來找出它是什么 種類 的對象。在類的實例上進行 類型自省 的主要目的是根據 對象是如何創建的 來推斷它的結構/能力。
考慮這段代碼,它使用 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
類,只有一個普通的函數 Foo
,它恰好擁有一個引用指向一個隨意的對象(Foo.prototype
),而 a1
恰好委托鏈接至這個對象。通過它的語法,instanceof
假裝檢查了 a1
和 Foo
之間的關系,但它實際上告訴我們的是 a1
和 Foo.prototype
(這個隨意被引用的對象)是否有關聯。
instanceof
在語義上的混亂(和間接)意味著,要使用以 instanceof
為基礎的自省來查詢對象 a1
是否與討論中的對象有關聯,你 不得不 擁有一個持有對這個對象引用的函數 —— 你不能直接查詢這兩個對象是否有關聯。
回想本章前面的抽象 Foo
/ Bar
/ b1
例子,我們在這里縮寫一下:
function Foo() { /* .. */ }
Foo.prototype...
function Bar() { /* .. */ }
Bar.prototype = Object.create( Foo.prototype );
var b1 = new Bar( "b1" );
為了在這個例子中的實體上進行 類型自省, 使用 instanceof
和 .prototype
語義,這里有各種你可能需要實施的檢查:
// `Foo` 和 `Bar` 互相的聯系
Bar.prototype instanceof Foo; // true
Object.getPrototypeOf( Bar.prototype ) === Foo.prototype; // true
Foo.prototype.isPrototypeOf( Bar.prototype ); // true
// `b1` 與 `Foo` 和 `Bar` 的聯系
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
來許多開發者都傾向于它。這個術語源自一則諺語,“如果它看起來像鴨子,叫起來像鴨子,那么它一定是一只鴨子”。
例如:
if (a1.something) {
a1.something();
}
與其檢查 a1
和一個持有可委托的 something()
函數的對象的關系,我們假設 a1.something
測試通過意味著 a1
有能力調用 .something()
(不管是直接在 a1
上直接找到方法,還是委托至其他對象)。就其本身而言,這種假設沒什么風險。
但是“鴨子類型”常常被擴展用于 除了被測試關于對象能力以外的其他假設,這當然會在測試中引入更多風險(比如脆弱的設計)。
“鴨子類型”的一個值得注意的例子來自于 ES6 的 Promises(就是我們前面解釋過,將不再本書內涵蓋的內容)。
由于種種原因,需要判定任意一個對象引用是否 是一個 Promise,但測試是通過檢查對象是否恰好有 then()
函數出現在它上面來完成的。換句話說,如果任何對象 恰好有一個 then()
方法,ES6 的 Promises 將會無條件地假設這個對象 是“thenable” 的,而且因此會期望它按照所有的 Promises 標準行為那樣一致地動作。
如果你有任何非 Promise 對象,而卻不管因為什么它恰好擁有 then()
方法,你會被強烈建議使它遠離 ES6 的 Promise 機制,來避免破壞這種假設。
這個例子清楚地展現了“鴨子類型”的風險。你應當僅在可控的條件下,保守地使用這種方式。
再次將我們的注意力轉向本章中出現的 OLOO 風格的代碼,類型自省 變得清晰多了。讓我們回想(并縮寫)本章的 Foo
/ Bar
/ b1
的 OLOO 示例:
var Foo = { /* .. */ };
var Bar = Object.create( Foo );
Bar...
var b1 = Object.create( Bar );
使用這種 OLOO 方式,我們所擁有的一切都是通過 [[Prototype]]
委托關聯起來的普通對象,這是我們可能會用到的大幅簡化后的 類型自省:
// `Foo` 和 `Bar` 互相的聯系
Foo.isPrototypeOf( Bar ); // true
Object.getPrototypeOf( Bar ) === Foo; // true
// `b1` 與 `Foo` 和 `Bar` 的聯系
Foo.isPrototypeOf( b1 ); // true
Bar.isPrototypeOf( b1 ); // true
Object.getPrototypeOf( b1 ) === Bar; // true
我們不再使用 instanceof
,因為它令人迷惑地假裝與類有關系?,F在,我們只需要(非正式地)問這個問題,“你是我的 一個 原型嗎?”。不再需要用 Foo.prototype
或者痛苦冗長的 Foo.prototype.isPrototypeOf(..)
來間接地查詢了。
我想可以說這些檢查比起前面一組自省檢查,極大地減少了復雜性/混亂。又一次,我們看到了在 JavaScript 中 OLOO 要比類風格的編碼簡單(但有著相同的力量)。
復習
在你的軟件體系結構中,類和繼承是你可以 選用 或 不選用 的設計模式。多數開發者理所當然地認為類是組織代碼的唯一(正確的)方法,但我們在這里看到了另一種不太常被提到的,但實際上十分強大的設計模式:行為委托。
行為委托意味著對象彼此是對等的,在它們自己當中相互委托,而不是父類與子類的關系。JavaScript 的 [[Prototype]]
機制的設計本質,就是行為委托機制。這意味著我們可以選擇掙扎著在 JS 上實現類機制,也可以欣然接受 [[Prototype]]
作為委托機制的本性。
當你僅用對象設計代碼時,它不僅能簡化你使用的語法,而且它還能實際上引領更簡單的代碼結構設計。
OLOO(鏈接到其他對象的對像)是一種沒有類的抽象,而直接創建和關聯對象的代碼風格。OLOO 十分自然地實現了基于 [[Prototype]]
的行為委托。