模板方法模式

在JavaScript開發中用到集成的場景其實並非不少,衡多時候咱們都喜歡用mix-in(混入)的方式給對象擴展屬性。但這不表明繼承在JavaScript裏沒有用武之地,雖然沒有真正的類和繼承機制,但咱們能夠經過原型prototype來變相地實現繼承。javascript

不過本章並不是要討論繼承,而是討論一種基於繼承的設計模式——模板方法(Template Method)模式。java

1. 模板方法模式的定義和組成

模板方法模式是一種只須要使用繼承就能夠實現的很是簡單的模式。程序員

模板方法模式由兩部分結構組成,第一部分是抽象父類,第二部分是具體的實現子類。一般在抽象父類中封裝了子類的算法框架,包括實現一些公共方法以及封裝子類中全部方法的執行順序。子類經過繼承這個抽象類,也繼承了整個算法結構,而且能夠選擇重寫父類的方法。ajax

假如咱們有一些平行的子類,各個子類之間有一些相同的行爲,也有一些不一樣的行爲。若是相同和不一樣的行爲都混合在各個子類的實現中,說明這些相同的行爲會在各個子類中重複出現。但實際上,相同的行爲能夠被搬移到另一個單一的地方,模板方法模式就是爲解決這個問題而生的。在模板方法模式中,子類實現中的相同部分被上移到父類中,而將不一樣的部分留待子類來實現。這也很好地體現了泛化的思想。算法

2. 第一個例子——Coffee or Tea

咖啡與茶是一個經典的例子,常常用來說解模板方法模式,這個例子的原型來自《Head First 設計模式》。這一節咱們就用JavaScript來實現這個例子。設計模式

2. 1 先泡一杯咖啡

首先,咱們先來泡一杯咖啡,若是沒有什麼太個性化的需求,泡咖啡的步驟一般以下:安全

  1. 把水煮沸
  2. 用沸水沖泡咖啡
  3. 把咖啡倒進杯子
  4. 加糖和牛奶

經過下面這段代碼,咱們就能獲得一杯香濃的咖啡:架構

var Coffee = function(){};

Coffee.prototype.boilWater = function(){
    console.log('把水煮沸');
};
Coffee.prototype.brewCoffeeGriends = function(){
    console.log('用沸水沖泡咖啡');
};
Coffee.prototype.pourInCup = function(){
    console.log('把咖啡倒進杯子');
};
        
Coffee.prototype.addSugarAndMilk = function(){
    console.log('加糖和牛奶');
};

Coffee.prototype.init = function(){
    this.boilWater();
    this.brewCoffeeGriends();
    this.pourInCup();
    this.addSugarAndMilk();
};
        
var coffee = new Coffee();
coffee.init();

2. 2 泡一壺茶

接下來,開始準備咱們的茶,泡茶的步驟跟泡咖啡的步驟相差不大:框架

  1. 把水煮沸
  2. 用沸水㓎泡茶葉
  3. 把茶葉倒進杯子
  4. 加檸檬

一樣用一段代碼來實現泡茶的步驟:異步

var Tea = function(){};

Tea.prototype.boilWater = function(){
    console.log('把水煮沸');
};
Tea.prototype.steepTeaBag = function(){
    console.log('用沸水㓎泡茶葉');
};
Tea.prototype.pourInCup = function(){
    console.log('把茶水倒進杯子');
};          
Tea.prototype.addLemon = function(){
    console.log('加檸檬');
};

Tea.prototype.init = function(){
    this.boilWater();
    this.steepTeaBag();
    this.pourInCup();
    this.addLemon();
};
        
var tea = new Tea();
tea.init();

2. 3 分離出共同點

如今咱們分別泡好了一杯咖啡和一壺茶,通過思考和比較,咱們發現咖啡和茶的沖泡過程是大同小異的。
泡咖啡和泡茶主要有如下不一樣點。

  • 原料不一樣。一個是咖啡,一個是茶,但咱們能夠把它們都抽象爲「飲料」。
  • 泡的方式不一樣。咖啡是沖泡,而茶葉是㓎泡,咱們能夠把它們都抽象爲「泡」。
  • 加入的調料不一樣。一個是糖和牛奶,一個是檸檬,但咱們能夠把它們都抽象爲「調料」。

通過抽象以後,無論是泡咖啡仍是泡茶,咱們都能整理爲下面四步:

  1. 把水煮沸
  2. 用沸水沖泡飲料
  3. 把飲料倒進杯子
  4. 加調料

因此,無論是沖泡仍是㓎泡,咱們都能給它一個新的方法名稱,好比說brew()。同理,無論是加糖和牛奶,仍是檸檬,咱們均可以稱之爲addCondiments()。

讓咱們忘記最開始建立的Coffee類和Tea類。如今能夠建立一個抽象父類來表示泡一杯飲料的整個過程。不管是Coffee,仍是Tea,都被咱們用Beverage來表示,代碼以下:

var Bevarage = function();

Bevarage.prototype.boilWater = function(){
    console.log('把水煮沸');
};
Bevarage.prototype.brew = function(){}; //空方法,應該由子類重寫
Bevarage.prototype.pourInCup = function(){};    //空方法,應該由子類重寫
Bevarage.prototype.addCondiments = function(){};    //空方法,應該由子類重寫
Bevarage.prototype.init = function(){
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
};

2. 4 建立Coffee子類和Tea子類

如今建立一個Beverage類的對象對咱們來講沒有意義,由於世界上能喝的東西沒有一種真正叫「飲料」的,飲料在這裏還只是一個抽象的存在。接下來咱們要建立咖啡類和茶類,並讓它們繼承飲料類:

var Coffee = function(){};  
Coffee.prototype = new Bevarage();

接下來要重寫抽象父類中的一些方法,只有「把水煮沸」這個行爲能夠直接使用父類Beverage中的boilWater方法,其餘方法都須要在Coffee子類中重寫,代碼以下:

Coffee.prototype.brew = function(){
    console.log('用沸水沖泡咖啡');
};
Coffee.prototype.pourInCup = function(){
    console.log('把咖啡倒進杯子');
};
Coffee.prototype.addCondiments = function(){
    console.log('加糖和牛奶');
};

var coffee = new Coffee();  
coffee.init();

至此咱們的Coffee類已經完成了,當調用coffee對象的init方法時,因爲coffee對象和Coffee構造器的原型prototype上都沒有對應的init方法,因此該請求會順着原型鏈,被委託給Coffee的「父類」Beverage原型上的init方法。

而Beverage.prototype.init方法中已經規定好了泡飲料的順序,因此咱們能成功地泡出一杯咖啡,代碼以下:

Bevarage.prototype.init = function(){
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
};

接下來照葫蘆畫瓢,來建立咱們的Tea類:

var Tea = function(){};

Tea.prototype = new Bevarage(); 
Tea.prototype.brew = function(){
    console.log('用沸水㓎泡茶葉');
};
Tea.prototype.pourInCup = function(){
    console.log('把茶倒進杯子');
};
Tea.prototype.addCondiments = function(){
    console.log('加檸檬');
};

var tea = new Tea();    
tea.init();

本章一直討論的是模板方法模式,那麼在上面的例子中,到底誰纔是所謂的模板方法呢?答案是 Beverage.prototype.init。

Beverage.prototype.init 被稱爲模板方法的緣由是,該方法中封裝了子類的算法框架,它做爲一個算法的模板,知道子類以何種順序去執行哪些方法。在 Beverage.prototype.init 方法中,算法的每個步驟都清楚的展現在咱們眼前。

3. 抽象類

首先要說明的是,模板方法模式是一種嚴重依賴抽象類的設計模式。 JavaScript 在語言層面沒有提供對抽象類的支持,咱們也很難模擬抽象類的實現。這一節咱們將着重討論 Java 中抽象類的做用,以及 JavaScript 沒有抽象類時所作出的讓步和變通。

3. 1 抽象類的做用

在 Java 中,類分爲兩種,一種爲具體類,另外一種爲抽象類。具體能夠被實例化,抽象類不能被實例化。要了解抽象類不能被實例化的緣由,咱們能夠思考「飲料」這個抽象類。

想像這樣一個場景:咱們口渴了,去便利店想買一瓶飲料,咱們不能直接跟店員說:「來一瓶飲料。」若是咱們這樣說了,那麼店員接下來確定會問:「要什麼飲料?」飲料只是一個抽象名詞,只要當咱們真正明確了的飲料類型以後,才能獲得一瓶可樂或王老吉。

因爲抽象類不能被實例化,若是有人編寫了一個抽象類,那麼這個抽象類必定是用來被某些具體類類繼承的。

抽象類和接口同樣能夠用於向上轉型,在靜態類型語言中,編譯器對類型的檢查老是一個繞不過的話題與困擾。雖然類型檢查能夠提升程序的安全性,但繁瑣而嚴格的類型檢查也時常會讓程序員以爲麻煩。把對象的真正類型隱藏在抽象類或者接口以後,這些對象才能夠被互相替換使用。這可讓咱們的 Java 程序儘可能遵照依賴致使原則。

除了用於向上轉型,抽象類也能夠表示一種契約。繼承了這個抽象類的全部子類都將擁有跟抽象類一致的接口方法,抽象類的主要做用就是爲它的子類定義這些公共接口。若是咱們在子類中刪掉了這些方法中的某一個,那麼將不能經過編譯器的檢查,這在某些場景下是很是有用的,好比咱們本章討論的模板方法模式, Beverage 類的 init 方法裏規定了沖泡一杯飲料的順序以下:

this.boilWater();  //把水煮沸
this.brew();   //用水泡原料
this.pourInCup();  //把原料倒進杯子
this.addCondiments();  //添加調料

若是在Coffee子類中沒有實現對應的 brew 方法,那麼咱們百分百得不到一杯咖啡。既然父類規定了子類的方法和執行這些方法的順序,子類就應該擁有這些方法,而且提供正確的實現。

3. 2 抽象方法和具體方法

抽象方法被聲明在抽象類中,抽象方法並無具體的實現過程,是一些「啞方法」。好比 Beverage 類中的 brew 方法, pourInCup 方法和 addCondiments 方法,都被聲明爲抽象方法。當子類繼承了這個抽象類時,必須重寫父類的抽象方法。

除了抽象方法以外,若是每一個子類中都有一些一樣的具體實現方法,那這些方法也能夠選擇放在抽象類中,這能夠節省代碼以達到複用的效果,這些方法叫作具體方法。當代碼須要改變時,咱們只須要改動抽象類裏的具體方法就能夠了。好比飲料中的 boilWater 方法,假設沖泡全部的飲料以前,都要先把水煮沸,那咱們天然能夠把 boilWater 方法放在抽象類 Beverage 中。

3. 3 JavaScript沒有抽象類的缺點和解決方案

JavaScript 並無從語法層面提供對抽象類的支持。抽象類的第一個做用是隱藏對象的具體類型,因爲JavaScript是一門「類型模糊」的語言,因此隱藏對象的類型在 JavaScript 中並不重要。

另外一方面,當咱們在 JavaScript 中使用原型繼承來模擬傳統的類式繼承時,並無編譯器幫助咱們進行任何形式的檢查,咱們也沒有辦法保證子類會重寫父類中的「抽象方法」。

咱們知道, Beverage.prototype.init 方法做爲模板方法,已經規定了子類的算法框架,代碼以下:

Bevarage.prototype.init = function(){
    this.boilWater();
    this.brew();
    this.pourInCup();
    this.addCondiments();
};

若是咱們的 Coffee 類或者 Tea 類忘記實現這4個方法中的一個呢?拿 brew 方法舉例,若是咱們忘記編寫 Coffee.prototype.bre w方法,那麼當請求 Coffee 對象的 brew 時,請求會順着原型鏈找到 Beverage 「父類」對應的 Beverage.prototype.brew 方法,而 Beverage.prototype.brew 方法到目前爲止是一個空方法,這顯然是不能符合咱們須要的。

在 Java 中編譯器會保證子類會重寫父類中的抽象方法,但在 JavaScript 中卻沒有進行這些檢查工做。咱們在編寫代碼的時候得不到任何形式的警告,徹底寄託於程序員的記憶力和自覺性是很危險的,特別是當咱們使用模板方法模式這種徹底依賴繼承而實現的設計模式時。

下面提供兩種變通的解決方案。

  • 第一種方案是用鴨子類型來模擬接口檢查,以便確保子類中確實重寫了父類的方法。但模擬接口檢查會帶來沒必要要的複雜性,並且要求程序員主動進行這些接口檢查,這就要求咱們在業務代碼中添加一些跟野夫邏輯無關的代碼。
  • 第二種方案是讓 Beverage.prototype.brew 等方法直接拋出一個異常,若是由於粗心忘記編寫 Coffee.prototype.brew 方法,那麼至少咱們會在程序運行時獲得一個錯誤:

    Bevarage.prototype.brew = function(){
          throw new Error('子類必須重寫 brew 方法');
      };
      Bevarage.prototype.pourInCup = function(){
          throw new Error('子類必須重寫 pourIncup 方法');
      };
      Bevarage.prototype.addCondiments = function(){
          throw new Error('子類必須重寫 addCondiments方法 ');
      };

第二種解決方案的有點是實現簡單,付出的額外代價不多;缺點是咱們獲得錯誤信息的時間點太靠後。

咱們一共有 3 次機會獲得這個錯誤信息,第 1 次是在編寫代碼的時候,經過編譯器的檢查來獲得錯誤信息;第 2 次是在建立對象的時候用鴨子類型來進行「接口檢查」;而目前咱們不得不利用最後一次機會,在程序運行過程當中才知道哪裏發生了錯誤。

4. 模板方法的使用場景

從大的方面來說,模板方法模式常被架構師用於搭建項目的框架,架構師定好了框架的骨架,程序員繼承架構的結構以後,負責往裏面填空,好比 Java 程序員大多使用過 HttpServlet 技術來開發項目。

在 Web 開發中也能找到不少模板方法模式的適用場景,好比咱們在構建一系列的 UI 組件,這些組件的構建過程通常以下所示:

  1. 初始化一個 div 容器
  2. 經過 ajax 請求拉取相應的數據
  3. 把數據渲染到 div 容器裏面,完成組件的構造
  4. 通知用戶組件渲染完畢

咱們看到,任何組件的構建都遵循上面的 4 步,其中第 1 步和第 4 步是相同的。第 2 步不一樣的地方只是請求 ajax 的遠程地址,第 3 步不一樣的地方是渲染數據的方式。

因而咱們能夠把這 4 個步驟都抽象到父類的模板方法裏面,父類中還能夠順便提供第 1 步和第 4 步的具體實現。當子類繼承這個父類以後,會重寫模板方法裏面的第 2 步 和第 3 步。

5. 鉤子方法

經過模板方法模式,咱們在父類中封裝了子類的算法框架。這些算法框架在正常狀態下是適用於大多數子類的,但若是有一些特別「個性」的子類呢?好比咱們在飲料類 Beverage 中封裝了飲料的沖泡順序:

  1. 把水煮沸
  2. 用沸水沖泡飲料
  3. 把飲料倒進杯子
  4. 加調料

這 4 個沖泡飲料的步驟適用於咖啡和茶,在咱們的飲料店裏,根據這 4 個步驟製做出來的咖啡和茶,一直順利地提供給大部分客人享用。但有一些客人喝咖啡是不加調料(糖和牛奶)的。既然 Beverage 做爲父類,已經規定好了沖泡飲料的 4 個步驟,那麼有什麼辦法可讓子類不受這個約束呢?

鉤子方法(hook)能夠用來解決這個問題,放置鉤子是隔離變化的一種常見手段。咱們在父類中容易變化的地方放置鉤子,鉤子能夠有一個默認的實現,究竟要不要「掛鉤」,這由子類自行決定。鉤子方法的返回結果決定了模板方法後面部分的執行步驟,也就是程序接下來的走向,這樣一來,程序就擁有了變化的可能。

在這個例子裏,咱們把掛鉤的名字定爲 customerWantsCondiments ,接下來將掛鉤放入 Beverage 類,看看咱們如何獲得一杯不須要糖和牛奶的咖啡,代碼以下:

var Bevarage = function(){};
Bevarage.prototype.boilWater = function(){
    console.log('把水煮沸');
};
Bevarage.prototype.brew = function(){
    throw new Error('子類必須重寫 brew 方法');
};
Bevarage.prototype.pourInCup = function(){
    throw new Error('子類必須重寫 pourIncup 方法');
};
Bevarage.prototype.addCondiments = function(){
    throw new Error('子類必須重寫 addCondiments方法 ');
};
Bevarage.prototype.customerWantscondiments = function(){
    return true;    //默認爲須要加入調料
};
Bevarage.prototype.init = function(){
    this.boilWater();
    this.brew();
    this.pourInCup();
    if(this.customerWantscondiments()){ //若是掛鉤返回 true ,則須要調料
        this.addCondiments();
    }
};

var Coffee = function(){};

Coffee.prototype = new Bevarage();
Coffee.prototype.brew = function(){
    console.log('用沸水沖泡咖啡');
};
Coffee.prototype.pourInCup = function(){
    console.log('把咖啡倒進杯子');
};
Coffee.prototype.addCondiments = function(){
    console.log('加糖和牛奶');
};
Coffee.prototype.customerWantscondiments = function(){
    return window.confirm('請問須要調料嗎?');
}

var coffeeWithHook = new Coffee();  
coffeeWithHook.init();

6. 好萊塢原則

學習完模板方法以後,咱們要引入一個新的設計原則——著名的「好萊塢原則」。

好萊塢無疑是演員的天堂,但好萊塢也有不少找不到工做的新人演員,許多新人演員在好萊塢把簡歷遞給演藝公司以後就只有回家等待電話。有時候該演員等得不賴煩了,給演藝公司打電話詢問狀況,演藝公司每每這樣回答:「不要來找我,我會給你打電話。」

在設計中,這樣的規則就稱爲好萊塢原則。在這一原則的指導下,咱們容許底層組件將本身掛鉤到高層組件中,而高層組件會決定何時,以何種方式去使用這些底層組件,高層組件對待底層組件的方式,跟演藝公司對待新人演員同樣,都是「別調用咱們,咱們會調用你」。

模板方法模式是最好的一個典型使用場景,它與好萊塢原則的聯繫很是明顯,當咱們用模板方法模式編寫一個程序時,就意味着子類放棄了對本身的控制權,而是改成父類通知子類,哪些方法應該在何時被調用。做爲子類,只負責一些設計上的細節。

除此以外,好萊塢原則還經常應用於其餘模式和場景,例如發佈——訂閱模式和回調函數。

  • 發佈——訂閱模式

在發佈——訂閱模式中,發佈者會把消息推送給訂閱者,這取代了原先不斷去 fetch 消息的形式。例如假設咱們乘坐出租車去一個不瞭解的地方,除了沒過 5 秒鐘就問司機「是否到達目的地」以外,還能夠在車上美美的睡上一覺,而後跟司機說好,等目的地到了就叫醒你。這也至關於好萊塢原則中提到的「別調用咱們,咱們會調用你」。

  • 回調函數

在 ajax 異步請求中,因爲不知道請求返回的具體時間,而經過輪詢去判斷是否返回數據,這顯然是不理智的行爲。因此咱們一般會把接下來的操做放在回調函數中,傳入發起 ajax 異步請求的函數。當數據返回以後,這個回調函數才被執行,這也是好萊塢原則的一種體現。把須要執行的操做封裝在回調函數裏,而後把主動權交給另一個函數。至於回調函數何時被執行,則是另一個函數控制的。

7. 真的須要「繼承」嗎

模板方法模式是基於繼承的一種設計模式,父類封裝了子類的算法框架和方法的執行順序,子類繼承父類以後,父類通知子類執行這些方法,好萊塢原則很好的詮釋了這種設計技巧,即高層組件調用底層組件。

本章咱們經過模板方法模式,編寫了一個 Coffee or Tea 的例子。模板方法模式是爲數很少的基於繼承的設計模式,當JavaScript語言實際上沒有提供真正的類式繼承,繼承是經過對象與對象之間的委託來實現的。也就是說,雖然咱們在形式上借鑑了提供類式繼承的語言,但本章學習到的模板放法模式並不十分正宗。並且在JavaScript這般靈活的語言中,實現這樣一個例子,是否真的須要繼承這種重武器呢?

在好萊塢原則的指導之下,下面這段代碼能夠達到和繼承同樣的效果。

var Beverage = function (param) {
    var boilWater = function () {
        console.log('把水煮沸');
    };
    var brew = param.brew || function () {
        throw new Error('必須傳遞 brew 方法');
    };
    var pourInCup = param.pourInCup || function () {
        throw new Error('必須傳遞 pourInCup 方法');
    };
    var addCondiments = param.addCondiments || function () {
        throw new Error('必須傳遞 addCondiments 方法');
    };
    
    var F = function(){};
    
    F.prototype.init = function () {
        boilWater();
        brew();
        pourInCup();
        addCondiments();
    }
    
    return F;
}

var Coffee = Beverage({
    brew: function () {
        console.log('用沸水沖泡咖啡');
    },
    pourInCup: function () {
        console.log('把咖啡倒進杯子');
    },
    addCondiments: function () {
        console.log('加糖和牛奶')
    }
});

var Tea = Beverage({
    brew: function () {
        console.log('用沸水㓎泡茶葉');
    },
    pourInCup: function () {
        console.log('把茶倒進杯子');
    },
    addCondiments: function () {
        console.log('加檸檬')
    }
});

var coffee = new Coffee();
coffee.init();

var tea = new Tea();
tea.init();

在這段代碼中,咱們把 brew,pourInCup,addCondiments,這些方法依次傳入 Beverage 函數, Beverage 函數被調用以後返回構造器 F 。 F 類中包含了「模板方法」 F.prototype.init 。跟繼承獲得的效果是同樣,該「模板方法」裏依然封裝了飲料子類的算法框架。

8. 小結

模板方法模式是一種典型的經過封裝變化提升系統擴展性的設計模式。在傳統的面嚮對象語言中,一個運用了模板方法莫斯的程序中,子類的方法種類和執行順序都是不變的,因此咱們把這部分邏輯抽象到父類的模板方法裏面。而子類的方法具體怎麼實現則是可變的,因而咱們把這部分變化的邏輯封裝到子類中。經過增長新的子類,咱們便能給系統增長新的功能,並不須要改動抽象父類以及其餘子類,這也是符合開發——封閉原則的。

但在 JavaScript 中,咱們不少時候都不須要依樣畫瓢地去實現一個模板方法模式,高階函數是更好的選擇。


參考書目:《JavaScript 設計模式與開發實踐》

相關文章
相關標籤/搜索