「咖啡和茶」的例子就可以很好的说明模版方法模式

泡一杯咖啡

  1. var Coffee = function () { };
  2. Coffee.prototype.boilWater = function () {
  3. console.log('把水煮沸');
  4. };
  5. Coffee.prototype.brewCoffeeGriends = function () {
  6. console.log('用沸水冲泡咖啡');
  7. };
  8. Coffee.prototype.pourInCup = function () {
  9. console.log('把咖啡倒进杯子');
  10. };
  11. Coffee.prototype.addSugarAndMilk = function () {
  12. console.log('加糖和牛奶');
  13. };
  14. Coffee.prototype.init = function () {
  15. this.boilWater();
  16. this.brewCoffeeGriends();
  17. this.pourInCup();
  18. this.addSugarAndMilk();
  19. };
  20. var coffee = new Coffee();
  21. coffee.init();

泡一杯茶

  1. var Tea = function () { };
  2. Tea.prototype.boilWater = function () {
  3. console.log('把水煮沸');
  4. };
  5. Tea.prototype.steepTeaBag = function () {
  6. console.log('用沸水浸泡茶叶');
  7. };
  8. Tea.prototype.pourInCup = function () {
  9. console.log('把茶水倒进杯子');
  10. };
  11. Tea.prototype.addLemon = function () {
  12. console.log('加柠檬');
  13. };
  14. Tea.prototype.init = function () {
  15. this.boilWater();
  16. this.steepTeaBag();
  17. this.pourInCup();
  18. this.addLemon();
  19. };
  20. var tea = new Tea();
  21. tea.init();

共同点

从上面可以看到,泡一杯咖啡和泡一杯茶的步骤是有如下的共同点的

  1. 把水煮沸;
  2. 用沸水冲泡咖啡;
  3. 把饮料倒进杯子
  4. 加调料

所以,现在可以创建一个抽象类来表示泡一杯饮料的整个过程,我们用Beverage来表示,如下

  1. var Beverage = function () { };
  2. Beverage.prototype.boilWater = function () {
  3. console.log('把水煮沸');
  4. };
  5. Beverage.prototype.brew = function () { }; // 空方法,应该由子类重写
  6. Beverage.prototype.pourInCup = function () { }; // 空方法,应该由子类重写
  7. Beverage.prototype.addCondiments = function () { }; // 空方法,应该由子类重写
  8. Beverage.prototype.init = function () {
  9. this.boilWater();
  10. this.brew();
  11. this.pourInCup();
  12. this.addCondiments();
  13. };

创建好了饮料Beverage的抽象类之后,在分别创建Coffee和Tea的子类
先创建咖啡:

  1. var Coffee = function () { };
  2. Coffee.prototype = new Beverage();

接下来要重写抽象父类中的一些方法,只有“把水煮沸”这个行为可以直接使用父类Beverage中的boilWater方法,其他方法都需要在Coffee子类中重写,代码如下:

  1. Coffee.prototype.brew = function () {
  2. console.log('用沸水冲泡咖啡');
  3. };
  4. Coffee.prototype.pourInCup = function () {
  5. console.log('把咖啡倒进杯子');
  6. };
  7. Coffee.prototype.addCondiments = function () {
  8. console.log('加糖和牛奶');
  9. };
  10. var Coffee = new Coffee();
  11. Coffee.init();

在用类似的方式来创建茶:

  1. var Tea = function () { };
  2. Tea.prototype = new Beverage();
  3. Tea.prototype.brew = function () {
  4. console.log('用沸水浸泡茶叶');
  5. };
  6. Tea.prototype.pourInCup = function () {
  7. console.log('把茶倒进杯子');
  8. };
  9. Tea.prototype.addCondiments = function () {
  10. console.log('加柠檬');
  11. };
  12. var tea = new Tea();
  13. tea.init();

至此我们的Coffee/Tea类已经完成了,当调用coffee/tea对象的init方法时,由于coffee/tea对象和Coffee/Tea构造器的原型prototype上都没有对应的init方法,所以该请求会顺着原型链,被委托给Coffee的“父类”Beverage原型上的init方法。

抽象类

抽象类的作用

这里是针对Java中抽象类的概念,具体类可以被实例化,抽象类不能被实例化。要了解抽象类不能被实例化的原因,我们可以思考“饮料”这个抽象类。抽象类可以进行向上转型和表示一种契约,即继承了这个抽象类的所有子类都将拥有跟抽象类一致的接口方法,抽象类的主要作用就是为它的子类定义这些公共接口。如果我们在子类中删掉了这些方法中的某一个,那么将不能通过编译器的检查,这在某些场景下是非常有用的,比如我们本章讨论的模板方法模式,Beverage类的init方法里规定了冲泡一杯饮料的顺序如下:

  1. this.boilWater(); // 把水煮沸
  2. this.brew(); // 用水泡原料
  3. this.pourInCup(); // 把原料倒进杯子
  4. this.addCondiments(); // 添加调料

如果在Coffee子类中没有实现对应的brew方法,那么我们百分之百得不到一杯咖啡。既然父类规定了子类的方法和执行这些方法的顺序,子类就应该拥有这些方法,并且提供正确的实现。

用Java实现Coffee or Tea的例子

  1. // Java代码
  2. public abstract class Beverage { // 饮料抽象类
  3. final void init(){ // 模板方法
  4. boilWater();
  5. brew();
  6. pourInCup();
  7. addCondiments();
  8. }
  9. void boilWater(){ // 具体方法boilWater
  10. System.out.println( "把水煮沸" );
  11. }
  12. abstract void brew(); // 抽象方法brew
  13. abstract void addCondiments(); // 抽象方法addCondiments
  14. abstract void pourInCup(); // 抽象方法pourInCup
  15. }
  16. public class Coffee extends Beverage{ // Coffee类
  17. @Override
  18. void brew() { // 子类中重写brew方法
  19. System.out.println( "用沸水冲泡咖啡" );
  20. }
  21. @Override
  22. void pourInCup(){ // 子类中重写pourInCup方法
  23. System.out.println( "把咖啡倒进杯子" );
  24. }
  25. @Override
  26. void addCondiments() { // 子类中重写addCondiments方法
  27. System.out.println( "加糖和牛奶" );
  28. }
  29. }
  30. public class Tea extends Beverage{ // Tea类
  31. @Override
  32. void brew() { // 子类中重写brew方法
  33. System.out.println( "用沸水浸泡茶叶" );
  34. }
  35. @Override
  36. void pourInCup(){ // 子类中重写pourInCup方法
  37. System.out.println( "把茶倒进杯子" );
  38. }
  39. @Override
  40. void addCondiments() { // 子类中重写addCondiments方法
  41. System.out.println( "加柠檬" );
  42. }
  43. }
  44. public class Test {
  45. private static void prepareRecipe( Beverage beverage ){
  46. beverage.init();
  47. }
  48. public static void main( String args[] ){
  49. Beverage coffee = new Coffee(); // 创建coffee对象
  50. prepareRecipe( coffee ); // 开始泡咖啡
  51. // 把水煮沸
  52. // 用沸水冲泡咖啡
  53. // 把咖啡倒进杯子
  54. // 加糖和牛奶
  55. Beverage tea = new Tea(); // 创建tea对象
  56. prepareRecipe( tea ); // 开始泡茶
  57. // 把水煮沸
  58. // 用沸水浸泡茶叶
  59. // 把茶倒进杯子
  60. // 加柠檬
  61. }
  62. }

JavaScript没有抽象类的缺点和解决方案

JavaScript并没有从语法层面提供对抽象类的支持。抽象类的第一个作用是隐藏对象的具体类型,由于JavaScript是一门“类型模糊”的语言,所以隐藏对象的类型在JavaScript中并不重要。

  1. Beverage.prototype.brew = function () {
  2. throw new Error('子类必须重写brew方法');
  3. };
  4. Beverage.prototype.pourInCup = function () {
  5. throw new Error('子类必须重写pourInCup方法');
  6. };
  7. Beverage.prototype.addCondiments = function () {
  8. throw new Error('子类必须重写addCondiments方法');
  9. };

模板方法模式的使用场景

从大的方面来讲,模板方法模式常被架构师用于搭建项目的框架,架构师定好了框架的骨架,程序员继承框架的结构之后,负责往里面填空,比如Java程序员大多使用过HttpServlet技术来开发项目。
在Web开发中也能找到很多模板方法模式的适用场景,比如我们在构建一系列的UI组件,这些组件的构建过程一般如下所示:

  1. 初始化一个div容器;
  2. 通过ajax请求拉取相应的数据;
  3. 把数据渲染到div容器里面,完成组件的构造;
  4. 通知用户组件渲染完毕。

我们看到,任何组件的构建都遵循上面的4步,其中第(1)步和第(4)步是相同的。第(2)步不同的地方只是请求ajax的远程地址,第(3)步不同的地方是渲染数据的方式。
于是我们可以把这4个步骤都抽象到父类的模板方法里面,父类中还可以顺便提供第(1)步和第(4)步的具体实现。当子类继承这个父类之后,会重写模板方法里面的第(2)步和第(3)步。

钩子方法

上面冲泡饮料例子,这4个冲泡饮料的步骤适用于咖啡和茶,在我们的饮料店里,根据这4个步骤制作出来的咖啡和茶,一直顺利地提供给绝大部分客人享用。但有一些客人喝咖啡是不加调料(糖和牛奶)的。既然Beverage作为父类,已经规定好了冲泡饮料的4个步骤,那么有什么办法可以让子类不受这个约束呢?
钩子方法(hook)可以用来解决这个问题,放置钩子是隔离变化的一种常见手段。我们在父类中容易变化的地方放置钩子,钩子可以有一个默认的实现,究竟要不要“挂钩”,这由子类自行决定。钩子方法的返回结果决定了模板方法后面部分的执行步骤,也就是程序接下来的走向,这样一来,程序就拥有了变化的可能。
在这个例子里,我们把挂钩的名字定为customerWantsCondiments,接下来将挂钩放入Beverage类,看看我们如何得到一杯不需要糖和牛奶的咖啡,代码如下:

  1. var Beverage = function () { };
  2. Beverage.prototype.boilWater = function () {
  3. console.log('把水煮沸');
  4. };
  5. Beverage.prototype.brew = function () {
  6. throw new Error('子类必须重写brew方法');
  7. };
  8. Beverage.prototype.pourInCup = function () {
  9. throw new Error('子类必须重写pourInCup方法');
  10. };
  11. Beverage.prototype.addCondiments = function () {
  12. throw new Error('子类必须重写addCondiments方法');
  13. };
  14. Beverage.prototype.customerWantsCondiments = function () {
  15. return true; // 默认需要调料
  16. };
  17. Beverage.prototype.init = function () {
  18. this.boilWater();
  19. this.brew();
  20. this.pourInCup();
  21. if (this.customerWantsCondiments()) { // 如果挂钩返回true,则需要调料
  22. this.addCondiments();
  23. }
  24. };
  25. var CoffeeWithHook = function () { };
  26. CoffeeWithHook.prototype = new Beverage();
  27. CoffeeWithHook.prototype.brew = function () {
  28. console.log('用沸水冲泡咖啡');
  29. };
  30. CoffeeWithHook.prototype.pourInCup = function () {
  31. console.log('把咖啡倒进杯子');
  32. };
  33. CoffeeWithHook.prototype.addCondiments = function () {
  34. console.log('加糖和牛奶');
  35. };
  36. CoffeeWithHook.prototype.customerWantsCondiments = function () {
  37. return window.confirm('请问需要调料吗?');
  38. };
  39. var coffeeWithHook = new CoffeeWithHook();
  40. coffeeWithHook.init();

真的需要“继承”吗

JavaScript语言实际上没有提供真正的类式继承,继承是通过对象与对象之间的委托来实现的。也就是说,虽然我们在形式上借鉴了提供类式继承的语言,但本章学习到的模板方法模式并不十分正宗。而且在JavaScript这般灵活的语言中,实现这样一个例子,是否真的需要继承这种重武器呢?

  1. var Beverage = function (param) {
  2. var boilWater = function () {
  3. console.log('把水煮沸');
  4. };
  5. var brew = param.brew || function () {
  6. throw new Error('必须传递brew方法');
  7. };
  8. var pourInCup = param.pourInCup || function () {
  9. throw new Error('必须传递pourInCup方法');
  10. };
  11. var addCondiments = param.addCondiments || function () {
  12. throw new Error('必须传递addCondiments方法');
  13. };
  14. var F = function () { };
  15. F.prototype.init = function () {
  16. boilWater();
  17. brew();
  18. pourInCup();
  19. addCondiments();
  20. };
  21. return F;
  22. };
  23. var Coffee = Beverage({
  24. brew: function () {
  25. console.log('用沸水冲泡咖啡');
  26. },
  27. pourInCup: function () {
  28. console.log('把咖啡倒进杯子');
  29. },
  30. addCondiments: function () {
  31. console.log('加糖和牛奶');
  32. }
  33. });
  34. var Tea = Beverage({
  35. brew: function () {
  36. console.log('用沸水浸泡茶叶');
  37. },
  38. pourInCup: function () {
  39. console.log('把茶倒进杯子');
  40. },
  41. addCondiments: function () {
  42. console.log('加柠檬');
  43. }
  44. });
  45. var coffee = new Coffee();
  46. coffee.init();
  47. var tea = new Tea();
  48. tea.init();

小结

模板方法模式是一种典型的通过封装变化提高系统扩展性的设计模式。在传统的面向对象语言中,一个运用了模板方法模式的程序中,子类的方法种类和执行顺序都是不变的,所以我们把这部分逻辑抽象到父类的模板方法里面。而子类的方法具体怎么实现则是可变的,于是我们把这部分变化的逻辑封装到子类中。通过增加新的子类,我们便能给系统增加新的功能,并不需要改动抽象父类以及其他子类,这也是符合开放-封闭原则的。
但在JavaScript中,我们很多时候都不需要依样画瓢地去实现一个模版方法模式,高阶函数是更好的选择。