一、模板方法模式的定义和组成
模板方法模式是一种只需使用继承就可以实现的非常简单的模式。
模板方法模式由两部分结构组成,第一部分是抽象父类,第二部分是具体的实现子类。通常在抽象父类中封装了子类的算法框架,包括实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。
二、第一个例子——Coffee or Tea
我们先来泡一杯咖啡,泡咖啡的步骤通常如下:
- 把水煮沸
- 用沸水冲泡咖啡
- 把咖啡倒进杯子
- 加糖和牛奶
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();
接下来,开始准备我们的茶,泡茶的步骤跟泡咖啡的步骤相差并不大:
- 把水煮沸
- 用沸水浸泡茶叶
- 把茶水倒进杯子
- 加柠檬
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();
经过思考和比较,我们发现咖啡和茶的冲泡过程是大同小异:
泡咖啡 | 泡 茶 |
把水煮沸 | 把水煮沸 |
用沸水冲泡咖啡 | 用沸水浸泡茶叶 |
把咖啡倒进杯子 | 把茶水倒进杯子 |
加糖和牛奶 | 加柠檬 |
我们找到泡咖啡和泡茶主要有以下不同点。
- 原料不同。一个是咖啡,一个是茶,但我们可以把它们都抽象为“饮料”。
- 泡的方式不同。咖啡是冲泡,而茶叶是浸泡,我们可以把它们都抽象为“泡”。
- 加入的调料不同。一个是糖和牛奶,一个是柠檬,但我们可以把它们都抽象为“调料”。
经过抽象之后,不管是泡咖啡还是泡茶,我们都能整理为下面四步:
- 把水煮沸
- 用沸水冲泡饮料
- 把饮料倒进杯子
- 加调料
现在可以创建一个抽象父类来表示泡一杯饮料的整个过程。不论是Coffee,还是Tea,都被我们用Beverage 来表示:
var Beverage = function(){}; Beverage.prototype.boilWater = function(){ console.log( '把水煮沸' ); }; Beverage.prototype.brew = function(){}; // 空方法,应该由子类重写 Beverage.prototype.pourInCup = function(){}; // 空方法,应该由子类重写 Beverage.prototype.addCondiments = function(){}; // 空方法,应该由子类重写 Beverage.prototype.init = function(){ this.boilWater(); this.brew(); this.pourInCup(); this.addCondiments(); };
接下来我们要创建咖啡类和茶类,并让它们继承饮料类。只有“把水煮沸”这个行为可以直接使用父类Beverage中的boilWater 方法,其他方法都需要在子类中重写。
var Coffee = function(){}; Coffee.prototype = new Beverage(); Coffee.prototype.brew = function(){ console.log( '用沸水冲泡咖啡' ); }; Coffee.prototype.pourInCup = function(){ console.log( '把咖啡倒进杯子' ); }; Coffee.prototype.addCondiments = function(){ console.log( '加糖和牛奶' ); }; var Coffee = new Coffee(); Coffee.init(); var Tea = function(){}; Tea.prototype = new Beverage(); 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 方法中,算法内的每一个步骤都清楚地展示在我们眼前。
三、JavaScript 没有抽象类的缺点和解决方案
如果我们的Coffee 类或者Tea 类忘记实现这4 个方法中的一个呢?拿brew 方法举例,如果我们忘记编写Coffee.prototype.brew 方法,那么当请求coffee 对象的brew 时,请求会顺着原型链找到Beverage“父类”对应的Beverage.prototype.brew 方法,而Beverage.prototype.brew 方法到目前为止是一个空方法,这显然是不能符合我们需要的。
JavaScript 中却没有静态语言的检查工作。我们在编写代码的时候得不到任何形式的警告,完全寄托于程序员的记忆力和自觉性是很危险的,特别是当我们使用模板方法模式这种完全依赖继承而实现的设计模式时。
下面提供两种变通的解决方案。
- 用鸭子类型来模拟接口检查,以便确保子类中确实重写了父类的方法。但模拟接口检查会带来不必要的复杂性,而且要求程序员主动进行这些接口检查,这就要求我们在业务代码中添加一些跟业务逻辑无关的代码。
- 让Beverage.prototype.brew 等方法直接抛出一个异常,如果因为粗心忘记编写Coffee.prototype.brew 方法,那么至少我们会在程序运行时得到一个错误:
Beverage.prototype.brew = function(){ throw new Error( '子类必须重写brew 方法' ); };
第2 种解决方案的优点是实现简单,付出的额外代价很少;缺点是我们得到错误信息的时间点太靠后。
四、钩子方法
通过模板方法模式,我们在父类中封装了子类的算法框架。这些算法框架在正常状态下是适用于大多数子类的,但如果有一些特别“个性”的子类呢?
钩子方法(hook)可以用来解决这个问题,放置钩子是隔离变化的一种常见手段。我们在父类中容易变化的地方放置钩子,钩子可以有一个默认的实现,究竟要不要“挂钩”,这由子类自行决定。钩子方法的返回结果决定了模板方法后面部分的执行步骤,也就是程序接下来的走向,这样一来,程序就拥有了变化的可能。
Beverage.prototype.customerWantsCondiments = function(){ return true; // 默认需要调料 }; Beverage.prototype.init = function(){ this.boilWater(); this.brew(); this.pourInCup(); if ( this.customerWantsCondiments() ){ // 如果挂钩返回true,则需要调料 this.addCondiments(); } };
四、真的需要“继承”吗
模板方法模式是为数不多的基于继承的设计模式,但JavaScript 语言实际上没有提供真正的类式继承,继承是通过对象与对象之间的委托来实现的。也就是说,虽然我们在形式上借鉴了提供类式继承的语言,但本章学习到的模板方法模式并不十分正宗。而且在JavaScript 这般灵活的语言中,实现这样一个例子,是否真的需要继承这种重武器呢?
在好莱坞原则的指导之下,下面这段代码可以达到和继承一样的效果。
<script type="text/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(); </script>
小结
模板方法模式是一种典型的通过封装变化提高系统扩展性的设计模式。在传统的面向对象语言中,一个运用了模板方法模式的程序中,子类的方法种类和执行顺序都是不变的,所以我们把这部分逻辑抽象到父类的模板方法里面。而子类的方法具体怎么实现则是可变的,于是我们把这部分变化的逻辑封装到子类中。通过增加新的子类,我们便能给系统增加新的功能,并不需要改动抽象父类以及其他子类,这也是符合开放.封闭原则的。
但在JavaScript 中,我们很多时候都不需要依样画瓢地去实现一个模版方法模式,高阶函数是更好的选择。