模板方法模式

在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 设计模式与开发实践》

相关文章
相关标签/搜索