JavaScript 设计模式之模板方法模式

476 阅读8分钟

这是我参与8月更文挑战的第8天,活动详情查看:8月更文挑战

模板方法模式是一种只需要使用继承就可以实现的非常简单的模式。是一种典型的通过封装变化提高系统扩展性的设计模式。

模板方法模式的定义和组成

模板方式模式由两部分结构组成,第一部分是抽象父类,第二部分是具体的实现子类。通常在抽象父类中封装了子类的算法框架,包括实现一些公共方法以及封装子类中所有方法的执行顺序。子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

在模板方法模式中,子类实现中的相同部分被上移到父类中,而将不同的部分留在子类来实现,这很好的体现了泛化的思想。

第一个例子——Coffee or Tea

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 泡一壶茶

  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();

3 分离出共同点

上述我们发现咖啡和茶的冲泡过程是大同小异的。

泡咖啡泡 茶
把水煮沸把水煮沸
用沸水冲泡咖啡用沸水浸泡茶叶
把咖啡倒进杯子把茶水倒进杯子
加糖和牛奶加柠檬

现在来找下泡咖啡和泡茶的不同点:

  • 原料不同。一个是茶,一个是咖啡,可以都抽象为“饮料”。
  • 泡的方式不同。咖啡是冲泡,茶是浸泡,可以都抽象为“泡”。
  • 加入的调料不同。一个是糖和牛奶,一个是柠檬,可以都抽象为“调料”

经过抽象之后的步骤为:

  1. 把水煮沸
  2. 用沸水泡饮料
  3. 把饮料倒进杯子
  4. 加调料 那么,抽象之后的通用代码为:
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();
};

4 创建 Coffee 子类和 Tea 子类

创建咖啡类和茶类,并让它们继承饮料类:

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

接下来要重写抽象父类中的一些方法,只有“把水煮沸”这个行为可以直接使用父类 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 方法中定好了泡饮料的顺序,所以可以成功的泡出一杯咖啡。

根据上述步骤,依葫芦画瓢,可以完成茶的冲泡的 Tea 类。

那么在上面的例子中,谁才是所谓的模板方法呢?答案是 Beverage.prototype.init

Beverage.prototype.init 被称为模板方法的原因是,该方法中封装了子类的算法框架,它作为算法的一个模板,指导子类以何种顺序去执行哪些方法。在 Beverage.prototype.init 方法中,算法内的每一个步骤都清楚地展示在我们面前。

JavaScript 没有抽象类的解决方案

模板方法模式是一种严重依赖抽象类的设计模式。而 JavaScript 在语言层面并没有提供对抽象类的支持,那么它在没有情况下作出了那些让步和变通呢。

  1. 用鸭子类型来模拟接口检查,以便确保子类中确实重写了父类的方法。但模拟接口检查会带来不必要的复杂性,而且要求程序员主动进行这些接口检查,这就要求我们在业务代码中添加一些跟业务逻辑无关的代码。
  2. Beverage.prototype.brew 等方法直接抛出一个异常,如果因为粗心忘记编写 Coffee.prototype.brew 方法,那么至少我们会在程序运行时得到一个错误:
Beverage.prototype.brew = function () {
  throw new Error('子类必须重写 brew 方法');
};
Beverage.prototype.pourInCup = function () {
  throw new Error('子类必须重写 pourInCup 方法');
};
Beverage.prototype.addCondiments = function () {
  throw new Error('子类必须重写 addCondiments 方法');
};

模板方法模式的使用场景

在构建一系列的UI组件,这些组件的构建过程一般如下:

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

于是,可以把这四个步骤都抽象到父类的模板方法里面,父类还可以顺便提供第一步和第四步的具体实现。当子类继承这个父类后,只需要重写模板方法里面的 第二步和第三步即可。

钩子方法

我们在饮料类 Beverage 类中封装了饮料的冲泡顺序,那么如果有人喝咖啡不加调料呢?

钩子方法(hook)可以用来解决这个问题,放置钩子是隔离变化的一种常见手段。在父类中容易变化的地方放置钩子,钩子可以有一个默认的实现,究竟要不要“挂钩”,由子类自行决定。钩子方法的返回结果决定了模板方法后面部分的执行步骤,也就是程序接下来的走向,这样程序就拥有了变化的可能。

在冲泡饮料中,把挂钩的名字定义为 customerWantsCondiments,接下来将挂钩放入 Beverage 类:

var Beverage = function () { };
Beverage.prototype.boilWater = function () {
  console.log('把水煮沸');
};
Beverage.prototype.brew = function () {
  throw new Error('子类必须重写 brew 方法');
};
Beverage.prototype.pourInCup = function () {
  throw new Error('子类必须重写 pourInCup 方法');
};
Beverage.prototype.addCondiments = function () {
  throw new Error('子类必须重写 addCondiments 方法');
};
Beverage.prototype.customerWantsCondiments = function () {
  return true; // 默认需要调料
};
Beverage.prototype.init = function () {
  this.boilWater();
  this.brew();
  this.pourInCup();
  if (this.customerWantsCondiments()) {
    this.addCondiments();
  }
};

var CoffeeWithHook = function () { };
CoffeeWithHook.prototype = new Beverage();
CoffeeWithHook.prototype.brew = function () {
  console.log('用沸水冲泡咖啡');
};
// 如果挂钩返回 true,则需要调料
CoffeeWithHook.prototype.pourInCup = function () {
  console.log('把咖啡倒进杯子');
};
CoffeeWithHook.prototype.addCondiments = function () {
  console.log('加糖和牛奶');
};
CoffeeWithHook.prototype.customerWantsCondiments = function () {
  return window.confirm('请问需要调料吗?');
};
var coffeeWithHook = new CoffeeWithHook(); 
coffeeWithHook.init();

好莱坞原则

好莱坞无疑是演员的天堂,但好莱坞也有很多找不到工作的新人演员,许多新人演员在好莱坞把简历递给演艺公司之后就只有回家等待电话。有时候该演员等得不耐烦了,给演艺公司打电话询问情况,演艺公司往往这样回答:“不要来找我,我会给你打电话。”

在设计中,这样的规则被称为好莱坞原则。在这一原则的指导下,允许底层组件将自己挂钩到高层组件中,而高层组件会决定什么时候、以何种方式去使用这些底层组件,高层组件对待底层组件的方式,跟演艺公司对待新人演员一样,都是“别调用我们,我们会调用你”。

模板方法模式是好莱坞原则的一个典型使用场景,除此之外,好莱坞原则还常常应用于其他模式和场景,例如发布订阅模式和回调函数。

真的需要“继承”吗

模板方法模式是为数不多的基于继承的设计模式,但 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();

最后说一句

如果这篇文章对您有所帮助,或者有所启发的话,帮忙点赞关注一下,您的支持是我坚持写作最大的动力,多谢支持。

同系列文章

  1. JavaScript 设计模式之单例模式
  2. JavaScript 设计模式之策略模式
  3. JavaScript 设计模式之代理模式
  4. JavaScript 设计模式之迭代器模式
  5. JavaScript 设计模式之发布-订阅模式
  6. JavaScript 设计模式之命令模式
  7. JavaScript 设计模式之组合模式
  8. JavaScript 设计模式之模板方法模式
  9. JavaScript 设计模式之享元模式
  10. JavaScript 设计模式之职责链模式
  11. JavaScript 设计模式之中介者模式
  12. JavaScript 设计模式之装饰者模式
  13. JavaScript 设计模式之状态模式
  14. JavaScript 设计模式之适配器模式