《JavaScript 模式》讀書筆記(7)— 設計模式1
這些模式已經出現了相當長的一段時間,並被證明在許多情況下都非常有用。這也是為什麼需要自己熟悉並談論這些模式的原因。
雖然這些設計模式是與語言和實現方式無關的,並且人們已經對此研究了多年,但都主要是從強型別的靜態類語言的角度開展研究,比如C++和Java語言。
JavaScript是一種弱型別、動態的、基於原型的語言,這種語言特性使得它非常容易、甚至是普通的方式實現其中的一些模式。
讓我們先從第一個例子開始,即單體模式,理解其與基於類的靜態語言相比時,JavaScript中存在哪些區別。
一、單體模式
單體(singleton)模式的思想在於保證一個特定類僅有一個例項。這意味著當您第二次使用同一個建立新物件的時候,應該得到與第一次建立的物件完全相同的物件。
但是,如何將這種模式應用到JavaScript?在JavaScript中沒有類,只有物件。當您建立一個物件時,實際上沒有其他物件與其類似,因此新物件已經是單體了。使用物件字面量建立一個簡單的物件也是一個單體的例子:
var obj = { myprop: 'my value' }
在JavaScript中,物件之間永遠不會完全相等,除非它們是同一個物件,因此即使建立一個具有完全相同成員的同類物件,它也不會與第一個物件完全相同:
var obj2 = { myprop : 'my value' }; console.log(obj === obj2); console.log(obj== obj2);
因此,可以認為每次在使用物件字面量建立物件的時候,實際上就正在建立一個單體,並且不涉及任何特殊語法。
請注意,有時當人們在JavaScript上下文中談論單體時,他們的意思是指第五章中所討論的模組模式。
使用new操作符
JavaScript中並沒有類,因此對單體咬文嚼字的定義嚴格來說並沒有意義。但是JavaScript中具有new語法可使用建構函式來建立物件,而且有時可能需要使用這種語法的單體實現。這種思想在於當使用同一個建構函式以new操作符來建立多個物件時,應該僅獲得指向完全相同的物件的新指標。
對於在一些基於類的語言(即靜態的、強型別語言)中,其函式不是“第一型別物件”的那些語言來說,下面討論的主題並不是那麼有用,而是更多的作為一種理論上的模仿變通方法的運用。
下面的程式碼片段顯示了其與其行為(假定不認可多元宇宙的觀點,並且接受外在世界只有一個宇宙的觀點):
var uni = new Universe(); var uni2 = new Universe(); console.log(uni === uni2);
在上面這個例子中,uni物件僅在第一次呼叫建構函式時被建立。在第二次(以及第二次以後的每一次)建立時都會返回頭一個uni物件。這就是為什麼uni === uni2,因為它們本質上是指向同一個物件的兩個引用。那麼如何在JavaScript中實現這種模式呢?
需要Universe建構函式快取該物件例項的this,以便當第二次呼叫該建構函式時能夠建立並返回同一個物件。有多種選擇可以實現這一目標:
- 可以使用全域性變數來儲存該例項。但是並不推薦使用這種方法,因為在一般原則下,全域性變數是有缺點的。此外,任何人都能夠覆蓋該全域性變數,即使是意外事件。因此,讓我們不要再進一步討論這種方法。
- 可以在建構函式的靜態屬性中快取該例項。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 this; }
正如您所看到的,這是一個非常直接的解決方法,其唯一的缺點在於其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(); console.log(uni === uni2);
在上述程式碼執行時,當第一次呼叫原始建構函式時,它像往常一樣返回this。然後,在以後的每次呼叫時,將執行重寫建構函式的部分。該部分通過閉包訪問了私有instance變數,並且僅簡單的返回了該instance。
這個實現實際上來自於第四章的自定義函式模式的另一個例子。而這種方法的缺點我們已經在第四章中討論過,主要在於重寫建構函式(本例中也就是建構函式Universe)會丟失所有在初始定義和重定義時刻之間新增到它裡面的屬性。在這裡的特定情況下,任何新增到Universe()原型中的物件都不會存在指向由原始實現所建立例項的活動連結。
通過下面的一些測試,可以看到這個問題:
// 向原型新增屬性 Universe.prototype.nothing = true; var uni = new Universe(); // 在建立初始化物件之後,再次向該原型新增屬性 Universe.prototype.everthing = true; var uni2 = new Universe(); // 開始測試 // 僅有最初的原型連結到物件上 console.log(uni.nothing); // true console.log(uni2.nothing); //true console.log(uni.everthing); // undefined console.log(uni2.everthing); // undefined // 結果看上去是正確的 console.log(uni.constructor.name); //Universe // 但是這個很奇怪: console.log(uni.constructor === Universe); //false
之所以uni.constructor不再與Universe()建構函式相同,是因為uni.constructor仍然指向了原始的建構函式,而不是重新定義的那個建構函式。
從需求上來說,如果需要使原型和建構函式指標按照預期的那樣執行,那麼可以通過做一些調整來實現這個目標:
function Universe() { // 快取例項 var instance // 重寫該建構函式 Universe = function Universe() { return instance; } // 保留原型屬性 Universe.prototype = this; // 例項 instance = new Universe(); // 重置建構函式指標 instance.constructor = Universe; // 所有功能 instance.start_time = 0; instance.bang = 'Big'; return instance; } // 更新原型並建立例項 Universe.prototype.nothing = true; var uni = new Universe(); // 在建立初始化物件之後,再次向該原型新增屬性 Universe.prototype.everthing = true; var uni2 = new Universe(); // 它們是相同的例項 console.log(uni === uni2); //true // 無論這些原型屬性是何時定義的,所有原型屬性都起作用。 console.log(uni.nothing && uni.everthing && uni2.nothing && uni2.everthing); //true // 正常屬性起作用 console.log(uni.bang); //'Big' // 該建構函式指向正確 console.log(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。
- 一個名為factory()的CarMaker的靜態方法,該方法建立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 24 doors"
其中,這一部分:
var corolla = CarMaker.factory('Compact');
可能是工廠模式中最易辨別的部分。現在看到工廠方法接受在執行時以字串形式指定型別,然後建立並返回所請求型別的物件。程式碼中看不到任何具有new或物件字面量的建構函式,其中僅有一個函式根據字串所指定型別來建立物件。
下面是工廠模式的實現示例,這將會使得前面的程式碼片段正常執行:
// 父建構函式 function CarMaker (){} // a method of the parent CarMaker.prototype.drive = function () { return "Vroom,I Have " + this.doors + 'doors'; } // 靜態工廠方法 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; } var corolla = CarMaker.factory('Compact'); var solstice = CarMaker.factory('Convertible'); var cherokee = CarMaker.factory('SUV'); console.log(corolla.drive()); // "Vroom, I Have 4 doors" console.log(solstice.drive()); // "Vroom, I Have 2 doors" console.log(cherokee.drive()); // "Vroom, I Have 24 doors"
實現該工廠模式並沒有特別的困難。所有需要做的就是尋找能夠建立所需型別物件的建構函式。在這種情況下,簡潔的命名習慣可用於將物件型別對映到建立該物件的建構函式中。繼承部分僅是可以放進工廠方法的一個公用重複程式碼片段的範例,而不是對每中型別的建構函式的重複。
內建物件工廠
而對於“自然工廠”的例子,可以考慮內建的全域性Object()建構函式。他也表現出工廠的行為,因為它根據輸入型別而建立不同的物件。如果傳遞一個原始數字,那麼它能夠在後臺以Number()建構函式建立一個物件。對於字串和布林值也同樣成立。對於任何其他值,甚至包括無輸入的值,他都會建立一個常規的物件。
下面是該行為的一些例子和測試。請注意,無論使用new操作符與否,都可以呼叫Object():
var o = new Object(), n= new Object(1), s = new Object('1'), b = new Object(true); // test console.log(o.constructor === Object); //true console.log(n.constructor === Number); //true console.log(s.constructor === String); //true console.log(b.constructor === Boolean); //true
事實上,Object()也是一個實際用途不大的工廠,值得將它作為例子而提及的原因在於它是我們身邊常見的工廠模式。
三、迭代器模式
在迭代器模式中,通常有一個包含某種資料集合的物件。該資料可能儲存在一個複雜資料結構內部,而要提供一種簡單的方法能夠訪問資料結構中的每個元素。物件的消費者並不需要知道如何組織資料,所有需要做的就是取出單個數據進行工作。
在迭代器模式中,我們需要提供一個next()方法。一次呼叫next()必須返回下一個連續的元素。當然,在特定的資料結構中,“下一個”所代表的意義是由您來決定的。
假定物件名為agg,可以在類似下面這樣的一個迴圈中通過簡單呼叫next()即可訪問每個資料元素:
var element; while(element = agg.next()) { // 處理該元素... console.log(element); }
在迭代器模式中,聚合物件通常還提供了一個較為方便的hasNext()方法,因此,該物件的使用者可以使用該方法來確定是否已經到達了資料的末尾。此外,還有另一種順序訪問所有元素的方法,這次是使用hasNext(),其用法如下所示:
while (agg.hasNext()) { // 處理下一個元素.. console.log(agg.next()); }
現在已經有了用例,讓我們看看如何實現這樣的聚合物件。
當實現迭代器模式時,私下的儲存資料和指向下一個可用元素的指標是很有意義的,為了演示一個實現示例,讓我們假定資料只是普通陣列,而“特殊”的檢索下一個連續元素的邏輯為返回每隔一個的陣列元素。
var agg = (function () { var index = 0, data = [1,2,3,4,5], length = data.length; return { next: function () { var element; if(!this.hasNext()) { return null; } element = data[index]; index = index + 2; return element; }, hasNext:function () { return index < length; } }; }());
為了提供更簡單的訪問方式以及多次迭代資料的能力,您的物件可以提供額外的便利方法:
rewind():重置指標到初始位置。
current():返回當前元素,因為不可能在不前進指標的情況下使用next()執行該操作。
實現這些方法不存在任何困難,我們來看加上這兩個方法的完整示例:
var agg = (function () { var index = 0, data = [1,2,3,4,5], length = data.length; return { next: function () { var element; if(!this.hasNext()) { return null; } element = data[index]; index = index + 2; return element; }, hasNext:function () { return index < length; }, rewind:function () { index = 0; }, current: function () { return data[index]; } }; }()); // 測試迭代器 while (agg.hasNext()) { // 處理下一個元素.. console.log(agg.next()); } // 回退 agg.rewind(); console.log(agg.current());
輸出結果將記錄在控制檯中:即依次輸出1,3,5(從迴圈中),並且最後輸出1(在迴繞以後)。
好了,我們這篇學了三個設計模式,分別是單體模式、工廠模式以及迭代器模式。這三個模式比較簡單,也更容易理解。下一篇,我們來學習一下更為複雜的設計模式。