javascript 遊戲設計模式總結
原文連結:https://github.com/TooBug/javascript.patterns/blob/master/chapter7.markdown?utm_source=caibaojian.com
設計模式
在GoF(Gang of Four)的書中提出的設計模式為面向物件的軟體設計中遇到的一些普遍問題提供瞭解決方案。它們已經誕生很久了,而且被證實在很多情況下是很有效的。這正是你需要熟悉它的原因,也是我們要討論它的原因。
儘管這些設計模式跟語言和具體的實現方式無關,但它們多年來被關注到的方面仍然主要是在強型別靜態語言比如C++和Java中的應用。
JavaScript作為一種基於原型的弱型別動態語言,有些時候實現某些模式時相當簡單,甚至不費吹灰之力。
讓我們從第一個例子——單例模式——來看一下在JavaScript中和靜態的基於類的語言有什麼不同。
單例
單例模式的核心思想是讓指定的類只存在唯一一個例項。這意味著當你第二次使用相同的類去建立物件的時候,你得到的應該和第一次建立的是同一個物件。
這如何應用到JavaScript中呢?在JavaScript中沒有類,只有物件。當你建立一個物件時,事實上根本沒有另一個物件和它一樣,這個物件其實已經是一個單例。使用物件字面量建立一個簡單的物件也是一種單例的例子:
var obj = {
myprop: 'my value'
};
在JavaScript中,物件永遠不會相等,除非它們是同一個物件,所以即使你建立一個看起來完全一樣的物件,它也不會和前面的物件相等:
var obj2 = {
myprop: 'my value'
};
obj === obj2; // false
obj == obj2; // false
所以你可以說當你每次使用物件字面量建立一個物件的時候就是在建立一個單例,並沒有什麼特別的語法牽涉進來。
需要注意的是,有的時候當人們在JavaScript中提出“單例”的時候,它們可能是在指第五章討論過的“模組模式”。
使用new
JavaScript沒有類,所以一字一句地說單例的定義並沒有什麼意義。但是JavaScript有使用new
、通過建構函式來建立物件的語法,有時候你可能需要這種語法下的一個單例實現。這也就是說當你使用new
溫馨提示:從一個實用模式的角度來說,下面的討論並不是那麼有用,只是更多地在模擬一些語言中關於這個模式的一些問題的解決方案。這些語言主要是(靜態強型別的)基於類的語言,在這些語言中,函式並不是“一等公民”。
下面的程式碼片段展示了期望的結果(假設你忽略了多元宇宙的設想,接受了只有一個宇宙的觀點):
var uni = new Universe();
var uni2 = new Universe();
uni === uni2; // true
在這個例子中,uni
只在建構函式第一次被呼叫時建立。第二次(以及後續更多次)呼叫時,同一個uni
物件被返回。這就是為什麼uni === uni2
的原因——因為它們實際上是同一個物件的兩個引用。那麼怎麼在JavaScript達到這個效果呢?
當物件例項this
被建立時,你需要在Universe()
建構函式中快取它,以便在第二次呼叫的時候返回。有幾種選擇可以達到這種效果:
- 你可以使用一個全域性變數來儲存例項。不推薦使用這種方法,因為通常我們認為使用全域性變數是不好的。而且,任何人都可以改寫全域性變數的值,甚至可能是無意中改寫。所以我們不再討論這種方案。
- 你也可以將物件例項快取在建構函式的屬性中。在JavaScript中,函式也是物件,所以它們也可以有屬性。你可以寫一些類似
Universe.instance
的屬性來快取物件。這是一種漂亮乾淨的解決方案,不足之處是instance
屬性仍然是可以被公開訪問的,別人寫的程式碼可能修改它,這樣就會失去這個例項。 - 你可以將例項包裹在閉包中。這可以保持例項是私有的,不會在建構函式之外被修改,代價是一個額外的閉包。
讓我們來看一下第二種和第三種方案的實現示例。
將例項放到靜態屬性中
下面是一個將唯一的例項放入Universe()
建構函式的一個靜態屬性中的例子:
function Universe() {
// 例項是否已經存在?
if (typeof Universe.instance === "object") {
return Universe.instance;
}
// 處理普通邏輯
this.start_time = 0;
this.bang = "Big";
// 快取例項
Universe.instance = this;
// 隱式return:
// return this;
}
// 測試
var uni = new Universe();
var uni2 = new Universe();
uni === uni2; // true
如你所見,這是一種直接有效的解決方案,唯一的缺陷是instance
是可被公開訪問的。一般來說它被其它程式碼誤刪改的可能是很小的(起碼比全域性變數instance
要小得多),但是仍然是有可能的。
將例項放到閉包中
另一種實現基於類的單例模式的方法是使用一個閉包來保護這個唯一的例項。你可以通過第五章討論過的“私有靜態成員模式”來實現。唯一的祕密就是重寫建構函式:
function Universe() {
// 快取例項
var instance = this;
// 處理普通邏輯
this.start_time = 0;
this.bang = "Big";
// 重寫建構函式
Universe = function () {
return instance;
};
}
// 測試
var uni = new Universe();
var uni2 = new Universe();
uni === uni2; // true
第一次呼叫時,原來的建構函式被呼叫並且正常返回this
。在後續的呼叫中,被重寫的建構函式被呼叫。被重寫的這個建構函式可以通過閉包訪問私有的instance
變數並且將它返回。
這個實現實際上也是第四章討論的重定義函式的又一個例子。如我們討論過的一樣,這種模式的缺點是被重寫的函式(在這個例子中就是建構函式Universe()
)將丟失那些在初始定義和重新定義之間新增的屬性。在這個例子中,任何新增到Universe()
的原型上的屬性將不會被連結到使用原來的實現建立的例項上。(注:這裡的“原來的實現”是指例項是由未被重寫的建構函式建立的,而Universe()
則是被重寫的建構函式。)
下面我們通過一些測試來展示這個問題:
// 新增成員到原型
Universe.prototype.nothing = true;
var uni = new Universe();
// 在建立一個物件後再新增成員到原型
Universe.prototype.everything = true;
var uni2 = new Universe();
// 測試:
// 只有原始的原型被連結到物件上
uni.nothing; // true
uni2.nothing; // true
uni.everything; // undefined
uni2.everything; // undefined
// constructor看起來是對的
uni.constructor.name; // "Universe"
// 但其實不然
uni.constructor === Universe; // false
uni.constructor
不再和Universe()
相同的原因是uni.constructor
仍然是指向原來的建構函式,而不是被重新定義的那個。
如果一定要讓prototype
和constructor
的指向像我們期望的那樣,可以通過一些調整來做到:
function Universe() {
// 快取例項
var instance;
// 重寫建構函式
Universe = function Universe() {
return instance;
};
// 重寫prototype屬性
Universe.prototype = this;
// 建立例項
instance = new Universe();
// 重寫constructor屬性
instance.constructor = Universe;
// 其它的功能程式碼
instance.start_time = 0;
instance.bang = "Big";
return instance;
}
現在所有的測試結果都可以像我們期望的那樣了:
// 修改原型,建立物件
Universe.prototype.nothing = true; // true
var uni = new Universe();
Universe.prototype.everything = true; // true
var uni2 = new Universe();
// 它們是同一個例項
uni === uni2; // true
// 所有的原型上的屬性都正常工作,不管是什麼時候在哪新增的
uni.nothing && uni.everything && uni2.nothing && uni2.everything; // true
// 普通成員也可以正常工作
uni.bang; // "Big"
// constructor指向正確
uni.constructor === Universe; // true
另一種可選的解決方案是將建構函式和例項包在一個即時函式中。當建構函式第一次被呼叫的時候,它返回一個物件並且將私有的instance
指向它。在後續呼叫時,建構函式只是簡單地返回這個私有變數。在這種新的實現下,前面所有的測試程式碼也會和期望的一樣:
var Universe;
(function () {
var instance;
Universe = function Universe() {
if (instance) {
return instance;
}
instance = this;
// 功能程式碼
this.start_time = 0;
this.bang = "Big";
};
}());
工廠模式
使用工廠模式的目的就是建立物件。它通常被在類或者類的靜態方法中實現,目的是:
- 執行在建立相似的物件時進行的一些重複操作
- 讓工廠的使用者在編譯階段建立物件時不必知道它的特定型別(類)
第二點在靜態的基於類的語言中更重要,因為在(編譯階段)提前不知道類的情況下,建立類的例項是一件多少有些特殊的行為。但在JavaScript中,這部分的實現卻是相當容易的事情。
使用工廠方法(或類)建立的物件被設計為從同一個父物件繼承;它們是實現一些特定的功能的子類。有些時候這個共同的父物件就是包含工廠方法的同一個類。
我們來看一個示例實現,我們有:
- 一個共同的父建構函式
CarMaker()
。 CarMaker()
的一個靜態方法叫factory()
,用來建立car
物件。- 特定的從
CarMaker()
繼承而來的建構函式CarMaker.Compact()
,CarMaker.SUV()
,CarMaker.Convertible()
。它們都被定義為父建構函式的靜態屬性以便保持全域性空間乾淨,同時在需要的時候我們也知道在哪裡找到它們。
我們來看一下已經完成的實現會怎麼被使用:
var corolla = CarMaker.factory('Compact');
var solstice = CarMaker.factory('Convertible');
var cherokee = CarMaker.factory('SUV');
corolla.drive(); // "Vroom, I have 4 doors"
solstice.drive(); // "Vroom, I have 2 doors"
cherokee.drive(); // "Vroom, I have 17 doors"
這一段:
var corolla = CarMaker.factory('Compact');
可能是工廠模式中最為人熟知的。你有一個方法可以在執行時接受一個表示型別的字串,然後它建立並返回了一個和請求的型別一樣的物件。這裡沒有使用new
的建構函式,也沒有看到任何物件字面量,僅僅只有一個函式根據一個字串指定的型別建立了物件。
這裡是一個工廠模式的示例實現,它能讓上面的程式碼片段工作:
// 父建構函式
function CarMaker() {}
// 父建構函式的方法
CarMaker.prototype.drive = function () {
return "Vroom, I have " + this.doors + " doors";
};
// 靜態工廠方法factory
CarMaker.factory = function (type) {
var constr = type,
newcar;
// 如果指定型別的建構函式不存在則報錯
if (typeof CarMaker[constr] !== "function") {
throw {
name: "Error",
message: constr + " doesn't exist"
};
}
// 現在我們確認要用到的建構函式是存在的了
// 讓它繼承自父建構函式,但只繼承一次
if (typeof CarMaker[constr].prototype.drive !== "function") {
CarMaker[constr].prototype = new CarMaker();
}
// 建立一個新例項
newcar = new CarMaker[constr]();
// 這裡可以選擇性地呼叫一些方法,然後返回例項
return newcar;
};
// 建立特定型別的建構函式
CarMaker.Compact = function () {
this.doors = 4;
};
CarMaker.Convertible = function () {
this.doors = 2;
};
CarMaker.SUV = function () {
this.doors = 24;
};
工廠模式的實現中沒有什麼是特別困難的,你需要做的僅僅是尋找請求型別的物件建構函式。在這個例子中,使用了一個簡單的名字轉換以便對映物件型別和建立物件的建構函式。繼承的部分只是一個公共的重複程式碼片段的示例,它可以被放到工廠方法中而不是被每個建構函式的型別所重複。(譯註:指原型繼承的程式碼可以在factory()
方法以外執行,而不是放到factory()
中每呼叫一次都要執行一次。)
內建物件工廠
為了說明工廠模式應用之廣泛,我們來看一下內建的全域性建構函式Object()
。它的行為很像工廠,因為它根據不同的輸入建立不同的物件。如果傳入一個數字,它會使用Number()
建構函式建立一個物件。在傳入字串和布林值的時候也會發生類似的事情。任何其它的值(包括空值)將會建立一個正常的物件。
下面是這種行為的例子和測試,注意Object()
呼叫時可以不用加new
:
var o = new Object(),
n = new Object(1),
s = Object('1'),
b = Object(true);
// 測試
o.constructor === Object; // true
n.constructor === Number; // true
s.constructor === String; // true
b.constructor === Boolean; // true
Object()
也是一個工廠這一事實可能沒有太多實際用處,僅僅是覺得值得作為一個例子提一下,告訴我們工廠模式是隨處可見的。
遍歷模式
在遍歷模式中,你有一些含有有序聚合資料的物件。這些資料可能在內部用一種複雜的結構儲存著,但是你希望提供一種簡單的方法來訪問這種結構中的每個元素。資料的使用者不需要知道你是怎樣組織你的資料的,他們只需要操作一個個獨立的元素。
在遍歷模式中,你的物件需要提供一個next()
方法。按順序呼叫next()
方法必須返回序列中的下一個元素,但是“下一個”在你的特定的資料結構中指什麼是由你自己來決定的。
假設你的物件叫agg
,你可以通過簡單地在迴圈中呼叫next()
來訪問每個資料元素,像這樣:
var element;
while (element = agg.next()) {
// 訪問element……
console.log(element);
}
在遍歷模式中,聚合物件通常也會提供一個方便的方法hasNext()
,這樣物件的使用者就可以知道他們已經獲取到你資料的最後一個元素。當使用hasNext()
來按順序訪問所有元素時,是像這樣的:
while (agg.hasNext()) {
// 訪問element……
console.log(agg.next());
}
裝飾模式
在裝飾模式中,一些額外的功能可以在執行時被動態地新增到一個物件中。在靜態的基於類的語言中,處理這個問題可能是個挑戰,但是在JavaScript中,物件本來就是可變的,所以給一個物件新增額外的功能本身並不是什麼問題。
裝飾模式的一個很方便的特性是可以對我們需要的特性進行定製和配置。剛開始時,我們有一個擁有基本功能的物件,然後可以從可用的裝飾中去挑選一些需要用到的去增強這個物件,如果有必要的話,還可以指定增強的順序。
用法
我們來看一下這個模式的用法示例。假設你正在做一個賣東西的web應用,每個新交易是一個新的sale
物件。這個物件“知道”交易的價格並且可以通過呼叫sale.getPrice()
方法返回。根據環境的不同,你可以開始用一些額外的功能來裝飾這個物件。假設一個場景是這筆交易是發生在加拿大的一個省Québec,在這種情況下,購買者需要付聯邦稅和Québec省稅。根據裝飾模式的用法,你需要指明使用聯邦稅裝飾器和Québec省稅裝飾器來裝飾這個物件。然後你還可以給這個物件裝飾一些價格格式的功能。這個場景的使用方式可能是像這樣:
var sale = new Sale(100); // 價格是100美元
sale = sale.decorate('fedtax'); // 加上聯邦稅
sale = sale.decorate('quebec'); // 加上省稅
sale = sale.decorate('money'); // 格式化
sale.getPrice(); // "$112.88"
在另一種場景下,購買者在一個不需要交省稅的省,並且你想用加拿大元的格式來顯示價格,你可以這樣做:
var sale = new Sale(100); // 價格是100美元
sale = sale.decorate('fedtax'); // 加上聯邦稅
sale = sale.decorate('cdn'); // 用加拿大元格式化
sale.getPrice(); // "CDN$ 105.00"
如你所見,這種方法可以在執行時很靈活地新增功能和調整物件。我們來看一下如何來實現這種模式。
實現
一種實現裝飾模式的方法是讓每個裝飾器成為一個擁有應該被重寫的方法的物件。每個裝飾器實際上是繼承自已經被前一個裝飾器增強過的物件。裝飾器的每個方法都會呼叫父物件(繼承自的物件)的同名方法並取得值,然後做一些額外的處理。
最終的效果就是當你在第一個例子中呼叫sale.getPrice()
時,實際上是在呼叫money
裝飾器的方法(圖7-1)。但是因為每個裝飾器會先呼叫父物件的方法,money
的getPrice()
先呼叫quebec
的getPrice()
,而它又會去呼叫fedtax
的getPrice()
方法,依次類推。這個鏈會一直走到原始的未經裝飾的由Sale()
建構函式實現的getPrice()
。
這個實現以一個建構函式和一個原型方法開始:
function Sale(price) {
this.price = price || 100;
}
Sale.prototype.getPrice = function () {
return this.price;
};
裝飾器物件將都被作為建構函式的屬性實現:
Sale.decorators = {};
我們來看一個裝飾器的例子。這是一個物件,實現了一個自定義的getPrice()
方法。注意這個方法首先從父物件的方法中取值然後修改這個值:
Sale.decorators.fedtax = {
getPrice: function () {
var price = this.uber.getPrice();
price += price * 5 / 100;
return price;
}
};
使用類似的方法我們可以實現任意多個需要的裝飾器。它們的實現方式像外掛一樣來擴充套件核心的Sale()
的功能。它們甚至可以被放到額外的檔案中,被第三方的開發者來開發和共享:
Sale.decorators.quebec = {
getPrice: function () {
var price = this.uber.getPrice();
price += price * 7.5 / 100;
return price;
}
};
Sale.decorators.money = {
getPrice: function () {
return "$" + this.uber.getPrice().toFixed(2);
}
};
Sale.decorators.cdn = {
getPrice: function () {
return "CDN$ " + this.uber.getPrice().toFixed(2);
}
};
最後我們來看decorate()
這個神奇的方法,它把所有上面說的片段都串起來了。記住它是這樣被呼叫的:
sale = sale.decorate('fedtax');
字串'fedtax'
對應在Sale.decorators.fedtax
中實現的物件。被裝飾過的最新的物件newobj
將從現在有的物件(也就是this
物件,它要麼是原始的物件,要麼是經過最後一個裝飾器裝飾過的物件)中繼承。實現這一部分需要用到前面章節中提到的臨時建構函式模式。我們也設定一個uber
屬性給newobj
以便子物件可以訪問到父物件。然後我們從裝飾器中複製所有額外的屬性到被裝飾的物件newobj
中。最後,在我們的例子中,newobj
被返回並且成為被更新過的sale
物件。
Sale.prototype.decorate = function (decorator) {
var F = function () {},
overrides = this.constructor.decorators[decorator],
i, newobj;
F.prototype = this;
newobj = new F();
newobj.uber = F.prototype;
for (i in overrides) {
if (overrides.hasOwnProperty(i)) {
newobj[i] = overrides[i];
}
}
return newobj;
};
使用列表實現
我們來看另一個明顯不同的實現方法,得益於JavaScript的動態特性,它完全不需要使用繼承。同時,我們也可以簡單地將前一個方面的結果作為引數傳給下一個方法,而不需要每一個方法都去呼叫前一個方法。
這樣的實現方法還允許很容易地反裝飾(undecorating)或者撤銷一個裝飾,這僅僅需要從一個裝飾器列表中移除一個條目。
用法示例也會明顯簡單一些,因為我們不需要將decorate()
的返回值賦值給物件。在這個實現中,decorate()
不對物件做任何事情,它只是簡單地將裝飾器加入到一個列表中:
var sale = new Sale(100); // 價格是100美元
sale.decorate('fedtax'); // 加上聯邦稅
sale.decorate('quebec'); // 加上省稅
sale.decorate('money'); // 格式化
sale.getPrice(); // "$112.88"
Sale()
建構函式現在有了一個作為自己屬性存在的裝飾器列表:
function Sale(price) {
this.price = price || 100;
this.decorators_list = [];
}
可用的裝飾器仍然被實現為Sale.decorators
的屬性。注意getPrice()
方法現在更簡單了,因為它們不需要呼叫父物件的getPrice()
來獲取結果,結果已經作為引數傳遞給它們了:
Sale.decorators = {};
Sale.decorators.fedtax = {
getPrice: function (price) {
return price + price * 5 / 100;
}
};
Sale.decorators.quebec = {
getPrice: function (price) {
return price + price * 7.5 / 100;
}
};
Sale.decorators.money = {
getPrice: function (price) {
return "$" + price.toFixed(2);
}
};
最有趣的部分發生在父物件的decorate()
和getPrice()
方法上。在前一種實現方式中,decorate()
還是多少有些複雜,而getPrice()
十分簡單。在這種實現方式中事情反過來了:decorate()
只需要往列表中新增條目而getPrice()
做了其它所有的工作,包括遍歷現在新增的裝飾器的列表,然後呼叫它們的getPrice()
方法並將結果傳遞下去:
Sale.prototype.decorate = function (decorator) {
this.decorators_list.push(decorator);
};
Sale.prototype.getPrice = function () {
var price = this.price,
i,
max = this.decorators_list.length,
name;
for (i = 0; i < max; i += 1) {
name = this.decorators_list[i];
price = Sale.decorators[name].getPrice(price);
}
return price;
};
裝飾模式的第二種實現方式更簡單一些,並且沒有引入繼承。裝飾的方法也會簡單。所有的工作都由“同意”被裝飾的方法來做。在這個示例實現中,getPrice()
是唯一被允許裝飾的方法。如果你想有更多可以被裝飾的方法,那遍歷裝飾器列表的工作就需要由每個方法重複去做。但是,這可以很容易地被抽象到一個輔助方法中,給它傳一個方法然後使這個方法“可被裝飾”。如果這樣實現的話,decorators_list
屬性就應該是一個物件,它的屬性名字是方法名,值是裝飾器物件的陣列。
策略模式
策略模式允許在執行的時候選擇演算法。你的程式碼的使用者可以在處理特定任務的時候根據即將要做的事情的上下文來從一些可用的演算法中選擇一個。
使用策略模式的一個例子是解決表單驗證的問題。你可以建立一個validator
物件,有一個validate()
方法。這個方法被呼叫時不用區分具體的表單型別,它總是會返回同樣的結果——一個沒有通過驗證的列表和錯誤資訊。
但是根據具體的需要驗證的表單和資料,你程式碼的使用者可以選擇進行不同類別的檢查。你的validator
選擇最佳的策略來處理這個任務,然後將具體的資料檢查工作交給合適的演算法去做。
資料驗證示例
假設你有一個下面這樣的資料,它可能來自頁面上的一個表單,你希望驗證它是不是有效的資料:
var data = {
first_name: "Super",
last_name: "Man",
age: "unknown",
username: "o_O"
};
對這個例子中的validator
而言,它需要知道哪個是最佳策略,因此你需要先配置它,給它設定好規則以確定哪些是有效的資料。
假設你不需要姓,名字可以接受任何內容,但要求年齡是一個數字,並且使用者名稱只允許包含字母和數字。配置可能是這樣的:
validator.config = {
first_name: 'isNonEmpty',
age: 'isNumber',
username: 'isAlphaNum'
};
現在validator
物件已經有了用來處理資料的配置,你可以呼叫validate()
方法,然後將驗證錯誤列印到控制檯上:
validator.validate(data);
if (validator.hasErrors()) {
console.log(validator.messages.join("\n"));
}
它可能會打印出這樣的資訊:
Invalid value for *age*, the value can only be a valid number, e.g. 1, 3.14 or 2010
Invalid value for *username*, the value can only contain characters and numbers, no special symbols
現在我們來看一下這個validator
是如何實現的。所有可用的用來驗證的邏輯都是擁有一個validate()
方法的物件,它們還有一行輔助資訊用來顯示錯誤資訊:
// 驗證空值
validator.types.isNonEmpty = {
validate: function (value) {
return value !== "";
},
instructions: "the value cannot be empty"
};
// 驗證數字
validator.types.isNumber = {
validate: function (value) {
return !isNaN(value);
},
instructions: "the value can only be a valid number, e.g. 1, 3.14 or 2010"
};
// 驗證是否只包含字母和數字
validator.types.isAlphaNum = {
validate: function (value) {
return !/[^a-z0-9]/i.test(value);
},
instructions: "the value can only contain characters and numbers, no special symbols"
};
最後,validator
物件的核心是這樣的:
var validator = {
// 所有可用的驗證型別
types: {},
// 本次驗證所有的錯誤訊息
messages: [],
// 本次驗證的配置,格式為:
// name: validation type
config: {},
// 介面方法
// `data` 是名值對
validate: function (data) {
var i, msg, type, checker, result_ok;
// 重置所有的錯誤訊息
this.messages = [];
for (i in data) {
if (data.hasOwnProperty(i)) {
type = this.config[i];
checker = this.types[type];
if (!type) {
continue; // 不需要驗證
}
if (!checker) { // 沒有對應的驗證型別
throw {
name: "ValidationError",
message: "No handler to validate type " + type
};
}
result_ok = checker.validate(data[i]);
if (!result_ok) {
msg = "Invalid value for *" + i + "*, " + checker.instructions;
this.messages.push(msg);
}
}
}
return this.hasErrors();
},
// 輔助方法
hasErrors: function () {
return this.messages.length !== 0;
}
};
如你所見,validator
物件是通用的,在所有的需要驗證的場景下都可以保持這個樣子。改進它的辦法就是增加更多型別的檢查。如果你將它用在很多頁面上,那麼很快你就會有一個非常好的驗證型別的集合。然後在新的使用場景下使用時你需要做的僅僅是配置validator
然後呼叫validate()
方法。
外觀模式
外觀模式是一種很簡單的模式,它只是為物件提供了更多的可供選擇的介面。使方法保持短小而不是處理太多的工作是一種很好的實踐。在這種實踐的指導下,你會有一大堆的方法,而不是一個有著非常多引數的uber
方法。有些時候,兩個或者更多的方法會經常被一起呼叫。在這種情況下,建立另一個將這些重複呼叫包裹起來的方法就變得意義了。
例如,在處理瀏覽器事件的時候,有以下的方法:
-
stopPropagation()
阻止事件冒泡到父節點
-
preventDefault()
阻止瀏覽器執行預設動作(如開啟連結或者提交表單)
這是兩個有不同目的的相互獨立的方法,他們也應該被保持獨立,但與此同時,他們也經常被一起呼叫。所以為了不在應用中到處重複呼叫這兩個方法,你可以建立一個外觀方法來呼叫它們:
var myevent = {
// ……
stop: function (e) {
e.preventDefault();
e.stopPropagation();
}
// ……
};
外觀模式也適用於一些瀏覽器指令碼的場景,即將瀏覽器的差異隱藏在一個外觀方法下面。繼續前面的例子,你可以新增一些處理IE中事件API的程式碼:
var myevent = {
// ……
stop: function (e) {
// 其它瀏覽器
if (typeof e.preventDefault === "function") {
e.preventDefault();
}
if (typeof e.stopPropagation === "function") {
e.stopPropagation();
}
// IE
if (typeof e.returnValue === "boolean") {
e.returnValue = false;
}
if (typeof e.cancelBubble === "boolean") {
e.cancelBubble = true;
}
}
// ……
};
外觀模式在做一些重新設計和重構工作時也很有用。當你想用一個不同的實現來替換某個物件的時候,你可能需要花相當長一段時間才能完成(一個複雜的物件),與此同時,一些使用這個新物件的程式碼也在被同步編寫。你可以先想好新物件的API,然後在舊的物件前面使用新的API建立一個外觀方法。使用這種方式,當你完全替換掉舊的物件的時候,你只需要修改少量的呼叫程式碼,因為新的程式碼已經是在使用新的API了。
代理模式
在代理模式中,一個物件充當了另一個物件的介面的角色。它和外觀模式不一樣,外觀模式帶來的方便僅限於將幾個方法呼叫聯合起來。而代理物件位於某個物件和它的使用者之間,可以保護對物件的訪問。
這個模式看起來開銷有點大,但在出於效能考慮時非常有用。代理物件可以作為目標物件的保護者,讓目標物件做盡量少的工作。
一種示例用法是“懶初始化”(延遲初始化)。假設負責初始化的物件是開銷很大的,並且正好使用者將它初始化後並不真正使用它。在這種情況下,代理物件可以作為目標物件的介面起到幫助作用。代理物件接收到初始化請求,但在目標物件真正被使用之前都不會將請求傳遞過去。
圖7-2展示了這個場景,當使用目標物件的程式碼發出初始化請求時,代理物件回覆一切就緒,但並沒有將請求傳遞過去,只有在真正需要目標物件做些工作的時候才將兩個請求一起傳遞過去。
圖7-2 通過代理物件時目標物件與使用者的關係
一個例子
在目標物件做某件工作開銷很大時,代理模式很有用處。在web應用中,開銷最大的操作之一就是網路請求,此時儘可能地合併HTTP請求是有意義的。我們來看一個這種場景下應用代理模式的例項。
一個視訊列表(expando)
我們假設有一個用來播放選中視訊的應用。你可以在這裡看到真實的例子http://www.jspatterns.com/book/7/proxy.html。
頁面上有一個視訊標題的列表,當用戶點選視訊標題的時候,標題下方的區域會展開並顯示視訊的更多資訊,同時也使得視訊可被播放。視訊的詳細資訊和用來播放的URL並不是頁面的一部分,它們需要通過網路請求來獲取。服務端可以接受多個視訊ID,這樣我們就可以在合適的時候通過一次請求多個視訊資訊來減少HTTP請求以加快應用的速度。
我們的應用允許一次展開好幾個(或全部)視訊,所以這是一個合併網路請求的絕好機會。
圖7-3 真實的視訊列表
沒有代理物件的情況
這個應用中最主要的角色是兩個物件:
-
videos
負責對資訊區域展開/收起(
videos.getInfo()
方法)和播放視訊的響應(videos.getPlayer()
方法) -
http
負責通過
http.makeRequest()
方法與服務端通訊
當沒有代理物件的時候,videos.getInfo()
會為每個視訊呼叫一次http.makeRequest()
方法。當我們新增代理物件proxy
後,它將位於vidoes
和http
中間,接手對makeRequest()
的呼叫,並在可能的時候合併請求。
我們首先看一下沒有代理物件的程式碼,然後新增代理物件來提升應用的響應速度。
HTML
HTML程式碼僅僅是一個連結列表:
<p><span id="toggle-all">Toggle Checked</span></p>
<ol id="vids">
<li><input type="checkbox" checked><a
href="http://new.music.yahoo.com/videos/--2158073">Gravedigger</a></li>
<li><input type="checkbox" checked><a
href="http://new.music.yahoo.com/videos/--4472739">Save Me</a></li>
<li><input type="checkbox" checked><a
href="http://new.music.yahoo.com/videos/--45286339">Crush</a></li>
<li><input type="checkbox" checked><a
href="http://new.music.yahoo.com/videos/--2144530">Don't Drink The Water</a></li>
<li><input type="checkbox" checked><a
href="http://new.music.yahoo.com/videos/--217241800">Funny the Way It Is</a></li>
<li><input type="checkbox" checked><a
href="http://new.music.yahoo.com/videos/--2144532">What Would You Say</a></li>
</ol>
事件處理
現在我們來看一下事件處理的邏輯。首先我們定義一個方便的快捷函式$
:
var $ = function (id) {
return document.getElementById(id);
};
使用事件代理(第八章有更多關於這個模式的內容),我們將所有id="vids"
的條目上的點選事件統一放到一個函式中處理:
$('vids').onclick = function (e) {
var src, id;
e = e || window.event;
src = e.target || e.srcElement;
if (src.nodeName !== "A") {
return;
}
if (typeof e.preventDefault === "function") {
e.preventDefault();
}
e.returnValue = false;
id = src.href.split('--')[1];
if (src.className === "play") {
src.parentNode.innerHTML = videos.getPlayer(id);
return;
}
src.parentNode.id = "v" + id;
videos.getInfo(id);
};
videos
物件
videos
物件有三個方法:
-
getPlayer()
返回播放視訊需要的HTML程式碼(跟我們討論的無關)
-
updateList()
網路請求的回撥函式,接受從伺服器返回的資料,然後生成用於視訊詳細資訊的HTML程式碼。這一部分也沒有什麼需要關注的事情。
-
getInfo()
這個方法切換視訊資訊的可視狀態,同時也呼叫
http
物件的方法,並傳遞updaetList()
作為回撥函式。
下面是這個物件的程式碼片段:
var videos = {
getPlayer: function (id) {...},
updateList: function (data) {...},
getInfo: function (id) {
var info = $('info' + id);
if (!info) {
http.makeRequest([id], "videos.updateList");
return;
}
if (info.style.display === "none") {
info.style.display = '';
} else {
info.style.display = 'none';
}
}
};
http
物件
http
物件只有一個方法,它向Yahoo!的YQL服務發起一個JSONP請求:
var http = {
makeRequest: function (ids, callback) {
var url = 'http://query.yahooapis.com/v1/public/yql?q=',
sql = 'select * from music.video.id where ids IN ("%ID%")',
format = "format=json",
handler = "callback=" + callback,
script = document.createElement('script');
sql = sql.replace('%ID%', ids.join('","'));
sql = encodeURIComponent(sql);
url += sql + '&' + format + '&' + handler;
script.src = url;
document.body.appendChild(script);
}
};
YQL(Yahoo! Query Language)是一種web service,它提供了使用類似SQL的語法來呼叫很多其它web service的能力,使得使用者不需要學習每個service的API。
當所有的六個視訊都被選中後,將會向服務端發起六個獨立的像這樣的YQL請求:
select * from music.video.id where ids IN ("2158073")
代理物件
前面的程式碼工作得很好,但我們可以讓它工作得更好。proxy
物件就在這樣的場景中出現,並接管了http
和videos
物件之間的通訊。它將使用一個簡單的邏輯來嘗試合併請求:50ms的延遲。videos
物件並不直接呼叫後臺介面,而是呼叫proxy
物件的方法。proxy
物件在轉發這個請求前將會等待一段時間,如果在等待的50ms內有另一個來自videos
的呼叫,則它們將被合併為同一個請求。50ms的延遲對使用者來說幾乎是無感知的,但是卻可以用來合併請求以提升點選“toggle”時的體驗,一次展開多個視訊。它也可以顯著降低伺服器的負載,因為web伺服器只需要處理更少量的請求。
合併後查詢兩個視訊資訊的YQL大概是這樣:
select * from music.video.id where ids IN ("2158073", "123456")
在修改後的程式碼中,唯一的變化是videos.getInfo()
現在呼叫的是proxy.makeRequest()
而不是http.makeRequest()
,像這樣:
proxy.makeRequest(id, videos.updateList, videos);
proxy
物件建立了一個佇列來收集50ms之內接受到的視訊ID,然後將這個佇列傳遞給http
物件,並提供回撥函式,因為videos.updateList()
只能處理一個接收到的視訊資訊。
下面是proxy
物件的程式碼:
var proxy = {
ids: [],
delay: 50,
timeout: null,
callback: null,
context: null,
makeRequest: function (id, callback, context) {
// 新增到佇列
this.ids.push(id);
this.callback = callback;
this.context = context;
// 設定延時
if (!this.timeout) {
this.timeout = setTimeout(function () {
proxy.flush();
}, this.delay);
}
},
flush: function () {
http.makeRequest(this.ids, "proxy.handler");
// 清除延時和佇列
this.timeout = null;
this.ids = [];
},
handler: function (data) {
var i, max;
// 單個視訊
if (parseInt(data.query.count, 10) === 1) {
proxy.callback.call(proxy.context, data.query.results.Video);
return;
}
// 多個視訊
for (i = 0, max = data.query.results.Video.length; i < max; i += 1) {
proxy.callback.call(proxy.context, data.query.results.Video[i]);
}
}
};
使用代理模式可以在只改動一處原來程式碼的情況下,將多個web service請求合併為一個。
圖7-4和7-5展示了使用代理模式將與伺服器三次資料互動(不用代理模式時)變為一次互動的過程。
圖7-4 與伺服器三次資料互動
圖7-5 通過一個代理物件合併請求,減少與伺服器資料互動
使用代理物件做快取
在這個例子中,目標物件的使用者(videos
)已經可以做到不對同一個物件重複發出請求,但現實情況中並不總是這樣。其實這個代理物件還可以通過快取之前的請求結果到cache
屬性中來進一步保護http
物件(圖7-6)。然後當videos
物件需要對同一個ID的視訊請求第二次時,proxy
物件可以直接從快取中取出,從而避免一次網路互動。
圖7-6 代理快取
中介者模式
一個應用不論大小,都是由一些彼此獨立的物件組成的。所有的物件都需要一個通訊方式來保持可維護性,即你可以安全地修改應用的一部分而不破壞其它部分。隨著應用的開發和維護,會有越來越多的物件。然後,在重構程式碼的時候,物件可能會被移除或者被重新設計。當物件知道其它物件的太多資訊並且直接通訊(直接呼叫彼此的方法或者修改屬性)時,會導致我們不願意看到的緊耦合。當物件耦合很緊時,要修改一個物件而不影響其它的物件是很困難的。此時甚至連一個最簡單的修改都變得不那麼容易,甚至連一個修改需要用多長時間都難以評估。
中介者模式就是一個緩解此問題的辦法,它通過解耦來提升程式碼的可維護性(見圖7-7)。在這個模式中,各個彼此合作的物件並不直接通訊,而是通過一個mediator
(中介者)物件通訊。當一個物件改變了狀態後,它就通知中介者,然後中介者再將這個改變告知給其它應該知道這個變化的物件。
圖7-7 中介者模式中的物件關係
中介者示例
我們來看一個使用中介者模式的例項。這個應用是一個遊戲,它的玩法是比較兩位遊戲者在半分鐘內按下按鍵的次數,次數多的獲勝。玩家1需要按的是1,玩家2需要按的是0(這樣他們的手指不會攪在一起)。當前分數會顯示在一個計分板上。
物件列表如下:
Player1
Player2
Scoreboard
Mediator
中介者Mediator
知道所有的物件,它與輸入裝置(鍵盤)打交道,處理keypress
事件,決定現在是哪位玩家玩的,然後通知這個玩家(見圖7-8)。玩家負責玩(即給自己的分數加一分),然後通知中介者他這一輪已經玩完。中介者再告知計分板最新的分數,計分板更新顯示。
除了中介者之外,其它的物件都不知道有別的物件存在。這樣就使得更新這個遊戲變得很簡單,比如要新增一位玩家或者是新增另外一個顯示剩餘時間的地方。
你可以在這裡看到這個遊戲的線上演示http://jspatterns.com/book/7/mediator.html。
圖7-8 遊戲涉及的物件
玩家物件是通過Player()
建構函式來建立的,有自己的points
和name
屬性。原型上的play()
方法負責給自己加一分然後通知中介者:
function Player(name) {
this.points = 0;
this.name = name;
}
Player.prototype.play = function () {
this.points += 1;
mediator.played();
};
scoreboard
物件(計分板)有一個update()
方法,它會在每次玩家玩完後被中介者呼叫。計分板根本不知道玩家的任何資訊,也不儲存分數,它只負責顯示中介者給過來的分數:
var scoreboard = {
// 被更新的HTML元素
element: document.getElementById('results'),
// 更新分數顯示
update: function (score) {
var i, msg = '';
for (i in score) {
if (score.hasOwnProperty(i)) {
msg += '<p><strong>' + i + '<\/strong>: ';
msg += score[i];
msg += '<\/p>';
}
}
this.element.innerHTML = msg;
}
};
現在我們來看一下mediator
物件(中介者)。在遊戲初始化的時候,在setup()
方法中建立玩家,然後放入players
屬性以便後續使用。played()
方法會被玩家在每輪玩完後呼叫,它更新score
雜湊然表然後將它傳給scoreboard
用於顯示。最後一個方法是keypress()
,負責處理鍵盤事件,決定是哪位玩家玩的,並且通知它:
var mediator = {
// 所有的玩家
players: {},
// 初始化
setup: function () {
var players = this.players;
players.home = new Player('Home');
players.guest = new Player('Guest');
},
// 玩家玩完後更新分數
played: function () {
var players = this.players,
score = {
Home: players.home.points,
Guest: players.guest.points
};
scoreboard.update(score);
},
// 處理使用者互動
keypress: function (e) {
e = e || window.event; // IE
if (e.which === 49) { // 按鍵“1”
mediator.players.home.play();
return;
}
if (e.which === 48) { // 按鍵“0”
mediator.players.guest.play();
return;
}
}
};
最後一件事是初始化和結束遊戲:
// 開始
mediator.setup();
window.onkeypress = mediator.keypress;
// 遊戲在30秒後結束
setTimeout(function () {
window.onkeypress = null;
alert('Game over!');
}, 30000);
觀察者模式
觀察者模式被廣泛地應用於JavaScript客戶端程式設計中。所有的瀏覽器事件(mouseover
,keypress
等)都是使用觀察者模式的例子。這種模式的另一個名字叫“自定義事件”,意思是這些事件是被編寫出來的,和瀏覽器觸發的事件相對。它還有另外一個名字叫“訂閱者/釋出者”模式(Pub/Sub)。
使用這個模式的最主要目的就是促進程式碼解耦。在觀察者模式中,一個物件訂閱另一個物件的指定活動並得到通知,而不是呼叫另一個物件的方法。訂閱者也被叫作觀察者,被觀察的物件叫作釋出者或者被觀察者。當一個特定的事件發生的時候,釋出者會通知(呼叫)所有的訂閱者,同時還可能以事件物件的形式傳遞一些訊息。
例1:雜誌訂閱
為了理解觀察者模式的實現方式,我們來看一個具體的例子。我們假設有一個釋出者paper
,它發行一份日報和一份月刊。無論是日報還是月刊發行,有一個名叫joe
的訂閱者都會收到通知。
paper
物件有一個subscribers
屬性,它是一個數組,用來儲存所有的訂閱者。訂閱的過程就僅僅是將訂閱者放到這個陣列中而已。當一個事件發生時,paper
遍歷這個訂閱者列表,然後通知它們。通知的意思也就是呼叫訂閱者物件的一個方法。因此,在訂閱過程中,訂閱者需要提供一個方法給paper
物件的subscribe()
。
paper
物件也可以提供unsubscribe()
方法,它可以將訂閱者從陣列中移除。paper
物件的最後一個重要的方法是publish()
,它負責呼叫訂閱者的方法。總結一下,一個釋出者物件需要有這些成員:
-
subscribers
一個數組
-
subscribe()
將訂閱者加入陣列
-
unsubscribe()
從陣列中移除訂閱者
-
publish()
遍歷訂閱者並呼叫它們訂閱時提供的方法
所有三個方法都需要一個type
引數,因為一個釋出者可能觸發好幾種事件(比如同時釋出雜誌和報紙),而訂閱者可以選擇性地訂閱其中的一種或幾種。
因為這些成員對任何物件來說都是通用的,因此將它們作為一個單獨的物件提取出來是有意義的。然後,我們可以(通過混元模式)將它們複製到任何一個物件中,將這些物件轉換為訂閱者。
下面是這些釋出者通用功能的一個示例實現,它定義了上面列出來的所有成員,還有一個輔助的visitSubscribers()
方法:
var publisher = {
subscribers: {
any: [] // 對應事件型別的訂閱者
},
subscribe: function (fn, type) {
type = type || 'any';
if (typeof this.subscribers[type] === "undefined") {
this.subscribers[type] = [];
}
this.subscribers[type].push(fn);
},
unsubscribe: function (fn, type) {
this.visitSubscribers('unsubscribe', fn, type);
},
publish: function (publication, type) {
this.visitSubscribers('publish', publication, type);
},
visitSubscribers: function (action, arg, type) {
var pubtype = type || 'any',
subscribers = this.subscribers[pubtype],
i,
max = subscribers.length;
for (i = 0; i < max; i += 1) {
if (action === 'publish') {
subscribers[i](arg);
} else {
if (subscribers[i] === arg) {
subscribers.splice(i, 1);
}
}
}
}
};
下面這個函式接受一個物件作為引數,並通過複製通用釋出者的方法將這個物件轉變成釋出者:
function makePublisher(o) {
var i;
for (i in publisher) {
if (publisher.hasOwnProperty(i) && typeof publisher[i] === "function") {
o[i] = publisher[i];
}
}
o.subscribers = {any: []};
}
現在我們來實現paper
物件,它能做的事情就是釋出日報和月刊:
var paper = {
daily: function () {
this.publish("big news today");
},
monthly: function () {
this.publish("interesting analysis", "monthly");
}
};
將paper
物件變成釋出者:
makePublisher(paper);
現在我們有了一個釋出者,讓我們再來看一下訂閱者物件joe
,它有兩個方法:
var joe = {
drinkCoffee: function (paper) {
console.log('Just read ' + paper);
},
sundayPreNap: function (monthly) {
console.log('About to fall asleep reading this ' + monthly);
}
};
現在讓joe
來訂閱paper
:
paper.subscribe(joe.drinkCoffee);
paper.subscribe(joe.sundayPreNap, 'monthly');
如你所見,joe
提供了一個當預設的any
事件發生時被呼叫的方法,還提供了另一個當monthly
事件發生時被呼叫的方法。現在讓我們來觸發一些事件:
paper.daily();
paper.daily();
paper.daily();
paper.monthly();
這些釋出行為都會呼叫joe的對應方法,控制檯中輸出的結果是:
Just read big news today
Just read big news today
Just read big news today
About to fall asleep reading this interesting analysis
這裡值得稱道的地方就是paper
物件並沒有硬編碼寫上joe
,而joe
也同樣沒有硬編碼寫上paper
。這裡也沒有知道所有事情的中介者物件。所有涉及到的物件都是鬆耦合的,而且在不修改程式碼的前提下,我們可以給paper
新增更多的訂閱者,同時joe
也可以在任何時候取消訂閱。
讓我們更進一步,將joe
也變成一個釋出者。(畢竟,在部落格和微博上,任何人都可以是釋出者。)這樣,joe
變成釋出者之後就可以在Twitter上更新狀態:
makePublisher(joe);
joe.tweet = function (msg) {
this.publish(msg);
};
現在假設paper
的公關部門準備通過Twitter
收集讀者反饋,於是它訂閱了joe
,提供了一個方法readTweets()
:
paper.readTweets = function (tweet) {
alert('Call big meeting! Someone ' + tweet);
};
joe.subscribe(paper.readTweets);
這樣每當joe
發出訊息時,paper
就會彈出警告視窗:
joe.tweet("hated the paper today");
結果是一個警告視窗:“Call big meeting! Someone hated the paper today”。
你可以在http://jspatterns.com/book/7/observer.html看到完整的原始碼,並且在控制檯中執行這個例項。
例2:按鍵遊戲
我們來看另一個例子。我們將實現一個和中介者模式的示例一樣的按鈕遊戲,但這次使用觀察者模式。為了讓它看起來更高檔,我們允許接受無限個玩家,而不限於2個。我們仍然保留用來產生玩家的Player()
建構函式,也保留scoreboard
物件,只有mediator
會變成game
物件。
在中介者模式中,mediator
物件知道所有涉及到的物件,並且呼叫它們的方法。而觀察者模式中的game
物件不是這樣,它會讓物件來訂閱它們感興趣的事件。比如,scoreboard
會訂閱game
物件的scorechange
事件。
首先我們重新看一下通用的publisher
物件,並且將它的介面做一點小修改以更貼近瀏覽器的情況:
- 將
publish()
,subscribe()
,unsubscribe()
分別改為fire()
,on()
,remove()
- 事件的
type
每次都會被用到,所以把它變成三個方法的第一個引數 - 可以給訂閱者的方法額外加一個
context
引數,以便回撥方法可以用this
指向它自己所屬的物件
新的publisher
物件是這樣:
var publisher = {
subscribers: {
any: []
},
on: function (type, fn, context) {
type = type || 'any';
fn = typeof fn === "function" ? fn : context[fn];
if (typeof this.subscribers[type] === "undefined") {
this.subscribers[type] = [];
}
this.subscribers[type].push({fn: fn, context: context || this});
},
remove: function (type, fn, context) {
this.visitSubscribers('unsubscribe', type, fn, context);
},
fire: function (type, publication) {
this.visitSubscribers('publish', type, publication);
},
visitSubscribers: function (action, type, arg, context) {
var pubtype = type || 'any',
subscribers = this.subscribers[pubtype],
i,
max = subscribers ? subscribers.length : 0;
for (i = 0; i < max; i += 1) {
if (action === 'publish') {
subscribers[i].fn.call(subscribers[i].context, arg);
} else {
if (subscribers[i].fn === arg && subscribers[i].context === context) {
subscribers.splice(i, 1);
}
}
}
}
};
新的Player()
建構函式是這樣:
function Player(name, key) {
this.points = 0;
this.name = name;
this.key = key;
this.fire('newplayer', this);
}
Player.prototype.play = function () {
this.points += 1;
this.fire('play', this);
};
變動的部分是這個建構函式接受key
,代表這個玩家在鍵盤上用來按之後得分的按鍵。(這些鍵預先被硬編碼過。)每次建立一個新玩家的時候,一個newplayer
事件也會被觸發。類似的,每次有一個玩家玩的時候,會觸發play
事件。
scoreboard
物件和原來一樣,它只是簡單地將當前分數顯示出來。
game
物件會關注所有的玩家,這樣它就可以給出分數並且觸發scorechange
事件。它也會訂閱瀏覽器中所有的·keypress·事件,這樣它就會知道按鈕對應的玩家:
var game = {
keys: {},
addPlayer: function (player) {
var key = player.key.toString().charCodeAt(0);
this.keys[key] = player;
},
handleKeypress: function (e) {
e = e || window.event; // IE
if (game.keys[e.which]) {
game.keys[e.which].play();
}
},
handlePlay: function (player) {
var i,
players = this.keys,
score = {};
for (i in players) {
if (players.hasOwnProperty(i)) {
score[players[i].name] = players[i].points;
}
}
this.fire('scorechange', score);
}
};
用於將任意物件轉變為訂閱者的makePublisher()
還是和之前一樣。game
物件會變成釋出者(這樣它才可以觸發scorechange
事件),Player.prototype
也會變成釋出者,以使得每個玩家物件可以觸發play
和newplayer
事件:
makePublisher(Player.prototype);
makePublisher(game);
game
物件訂閱play
和newplayer
事件(以及瀏覽器的keypress
事件),scoreboard
訂閱scorechange
事件:
Player.prototype.on("newplayer", "addPlayer", game);
Player.prototype.on("play", "handlePlay", game);
game.on("scorechange", scoreboard.update, scoreboard);
window.onkeypress = game.handleKeypress;
如你所見,on()
方法允許訂閱者通過函式(scoreboard.update
)或者是字串("addPlayer"
)來指定回撥函式。當有提供context
(如game
)時,才能通過字串來指定回撥函式。
初始化的最後一點工作就是動態地建立玩家物件(以及它們物件的按鍵),使用者想要多少個就可以建立多少個:
var playername, key;
while (1) {
playername = prompt("Add player (name)");
if (!playername) {
break;
}
while (1) {
key = prompt("Key for " + playername + "?");
if (key) {
break;
}
}
new Player(playername, key);
}
這就是遊戲的全部。你可以在http://www.jspatterns.com/book/7/observer-game.html看到完整的原始碼並且試玩一下。
值得注意的是,在中介者模式中,mediator
物件必須知道所有的物件,然後在適當的時機去呼叫對應的方法。而這個例子中,game
物件會顯得笨一些(譯註:指知道的資訊少一些),遊戲依賴於物件去觀察特定的事件然後觸發相應的動作:如scoreboard
觀察scorechange
事件。這使得物件之間的耦合更鬆了(物件間知道彼此的資訊越少越好),而代價則是弄清事件和訂閱者之間的對應關係會更困難一些。在這個例子中,所有的訂閱行為都發生在程式碼中的同一個地方,而隨著應用規模的境長,on()
可能會被在各個地方呼叫(如在每個物件的初始化程式碼中)。這使得除錯更困難一些,因為沒有一個集中的地方來看這些程式碼並理解正在發生什麼事情。在觀察者模式中,你將不再能看到那種從開頭一直跟到結尾的順序執行方式。
小結
在這章中你學習到了若干種流行的設計模式,並且也知道了如何在JavaScript中實現它們。我們討論過的設計模式有:
-
單例模式
只建立