第十一章 模版方法模式

在 JavaScript 中,我们可以通过原型 prototype 来变相地实现继承。

模版方法模式则是一种基于继承的设计模式。

11.1 模版方法模式的定义和组成

模版方法是一种只需使用继承就可以实现的模式。

分两个部分组成:第一部分是抽象父类,第二部分是具体的实现子类。

通常在抽象的父类中封装了子类的算法框架,包括实现一些公共方法以及封装子类中的所有方法的执行顺序。

子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

例如我们有一些平行的子类,各个子类之间有一些相同的行为,也有一些不同的行为。如果相同的和不同的行为都混合在各个子类的实现中,说明这些相同的行为会在各个子类中重复出现。但是实际上,相同的行为可以被搬移到另外一个单一的地方,模版方法模式就是为解决这个问题而生的。在模版方法模式中,子类实现中的相同部分被上移到父类中,而将不同的部分留待子类来实现。这也体现了泛化的思想。

11.2 第一个例子——Coffee or Tea

泡咖啡的例子可以用来讲解模版方法

11.2.1 先泡一杯咖啡

泡咖啡的步骤如下:

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

下面我们用代码实现:

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

11.2.2 泡一壶茶

接下来,开始准备我们的茶,泡茶的步骤跟泡咖啡的差不多

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

11.2.3 分离出共同点

经过比较,我们发现冲咖啡与茶的过程是大同小异的:

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

泡咖啡跟泡茶的不同点如下:

  • 原料不同。一个是咖啡,一个是茶,我们将其抽象成饮料
  • 泡的方式不同,咖啡用冲泡,茶叶是浸泡。我们将其抽象成泡
  • 调料不同,咖啡用糖和牛奶,茶用柠檬。我们将其抽象成调料

最后我们总结成如下过程:

  • 把水煮沸
  • 用沸水泡饮料
  • 把饮料倒进杯子
  • 加调料

那么,我们将冲泡的方法定义为 brew,将加调料的方法定义为 addCondiments,将饮料抽象成 Beverage 类

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

11.2.4 创建 Coffee 子类和 Tea 子类

创建 Coffee 类,并继承 Beverage 类

js
1var Coffee = function () {}
2
3Coffee.prototype = Object.create(Beverage.prototype)

接下来重写 Coffee 类的方法

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

当我们创建 coffee 对象后,调用 coffee 的 init 方法,此时 coffee 本身没有 init 方法,于是会顺着原型链,被委托给 Coffee 类的父类 Beverage 类的原型上的 init 方法。

由于 Beverage.prototype.init 方法中已经规定好了泡饮料的顺序,于是这时成功泡出了咖啡

js
1Beverage.prototype.init = function () {
2  this.boilWater()
3  this.brew()
4  this.pourInCup()
5  this.addCondiments()
6}

同样的方法我们还可以应用给 Tea 类,这里就不展开讨论了。

这里的 Beverage.prototype.init 就是模版方法。

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

11.3 抽象类

模版方法是一种严重依赖抽象类的设计模式。

11.3.1 抽象类的作用

在 java 中,类分两种,一种是具体类,另一种是抽象类。具体类可以被实例化,抽象类不能被实例化。

抽象类不能被实例化的原因可以参考“饮料”这个抽象类。饮料只是一个抽象名词,只有当我们真正明确饮料的类型后,才能得到一杯咖啡、茶或者可乐。

由于抽象类不能被实例化,如果有人编写了一个抽象类,那么这个抽象类一定是用来被某些具体类继承的。

抽象类和接口一样可以用于向上转型。在静态类型语言中,编译器对类型的检查总是一个绕不过的话题与困扰。虽然类型检查可以提高程序的安全性,但繁琐而严格的类型检查也时常会让程序员觉得麻烦。把对象的真正类型隐藏在抽象类或者接口之后,这些对象才可以被互相替换使用。这可以让我们的 Java 程序尽量遵守依赖倒置原则。

除了用于向上转型,抽象类也可以表示一种契约。继承了这个抽象类的所有子类都将拥有跟抽象类一致的接口方法,抽象类的主要作用是为了它的子类定义这些公共接口。如果我们在子类中删掉了这些方法的某一个,那么将不能通过编译器的检查,这在某些场景下是非常有用的。

比如,Beverage 类的 init 方法里规定了冲泡一杯饮料的顺序:

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

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

11.3.2 抽象方法和具体方法

抽象方法被声明在抽象类中,抽象方法并没有具体的实现过程,是一些“哑”方法。比如 Beverage 类中的 brew 方法、pourInCup 方法和 addCondiments 方法,都被声明为抽象方法。当子类继承了这个抽象类时,必须重写父类的抽象方法。

除了抽象方法之外,如果每个子类中都有一些同样的具体实现方法,那这些方法也可以选择放在抽象类中,这可以节省代码以达到复用的效果,这些方法叫作具体方法。当代码需要改变时,我们只需要改动抽象类里的具体方法就可以了。比如饮料中的 boilWater 方法,假设冲泡所有的饮料之前,都要先把水煮沸,那我们自然可以把 boilWater 方法放在抽象类 Beverage 中。

11.3.3 用 ES6 实现 Coffee or Tea 的例子

js
1class Beverage {
2  constructor() {
3    this.init()
4  }
5  boilWater() {
6    console.log('把水煮沸')
7  }
8  brew() {}
9  pourInCup() {}
10  addCondiments() {}
11  init() {
12    this.boilWater()
13    this.brew()
14    this.pourInCup()
15    this.addCondiments()
16  }
17}
18
19class Coffee extends Beverage {
20  constructor() {
21    super()
22  }
23  brew() {
24    console.log('沸水冲泡咖啡')
25  }
26  pourInCup() {
27    console.log('咖啡倒进杯子')
28  }
29  addCondiments() {
30    console.log('加糖和牛奶')
31  }
32}
33const coffee = new Coffee()
34class Beverage {
35  constructor() {
36    this.init()
37  }
38  boilWater() {
39    console.log('把水煮沸')
40  }
41  brew() {}
42  pourInCup() {}
43  addCondiments() {}
44  init() {
45    this.boilWater()
46    this.brew()
47    this.pourInCup()
48    this.addCondiments()
49  }
50}
51
52class Coffee extends Beverage {
53  constructor() {
54    super()
55  }
56  brew() {
57    console.log('沸水冲泡咖啡')
58  }
59  pourInCup() {
60    console.log('咖啡倒进杯子')
61  }
62  addCondiments() {
63    console.log('加糖和牛奶')
64  }
65}
66const coffee = new Coffee()
67
68/* 
69"把水煮沸"
70"沸水冲泡咖啡"
71"咖啡倒进杯子"
72"加糖和牛奶" 
73*/

11.4 模版方法模式的使用场景

模版方法模式常被架构师用于搭建项目的框架,架构师定好了框架的骨架,程序员继承框架的结构之后,负责往里面填空。

在 web 开发中也能找到很多模版方法模式的适用场景,比如我们在构建一系列的 UI 组件,这些组件的构建过程一般如下所示:

  • 初始化一个 div 容器
  • 通过 ajax 请求拉取相应的数据
  • 把数据渲染到 div 容器中,完成组件构造
  • 通知用户组件渲染完毕

我们可以看到,任何组件的构建都遵循上面的 4 步,其中第 1 步和第 4 步是相同的。第 2 步不同的地方只是请求 ajax 的远程地址,第 3 步不同的地方是渲染数据的方式。

于是我们可以把 4 个步骤都抽象到父类的模版方法中,父类中还可以顺便提供第 1 步和第 4 步的具体实现,当子类继承这个父类后,就会重写模版方法里面的第 2 步和第 3 步。

11.5 Hook 方法

通过模版方法模式,我们在父类中封装了子类的算法框架。这些算法在正常状态下是适用于大部分子类的,但如果有一些特别个性的子类呢?

比如我们在饮料类中封装了 init 方法,里面表示子类的冲泡顺序:

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

如果有一些个性的客户不想要加调料呢?有什么方法可以不受这个约束?

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

下面的例子中,我们把挂钩的名字定为 customrWantsCondiments,然后把挂钩放入 Beverage 类中。

js
1class Beverage {
2  constructor() {
3    this.init()
4  }
5  boilWater() {
6    console.log('把水煮沸')
7  }
8  brew() {}
9  pourInCup() {}
10  addCondiments() {}
11  customrWantsCondiments() {
12    //父类的钩子方法
13    return true
14  }
15  init() {
16    this.boilWater()
17    this.brew()
18    this.pourInCup()
19    if (this.customrWantsCondiments()) {
20      //根据子类的钩子方法来决定顺序
21      this.addCondiments()
22    }
23  }
24}

下面定义子类的方法

js
1class Coffee extends Beverage {
2  constructor() {
3    super()
4  }
5  brew() {
6    console.log('沸水冲泡咖啡')
7  }
8  pourInCup() {
9    console.log('咖啡倒进杯子')
10  }
11  customrWantsCondiments() {
12    return window.confirm('是否要加调料?') //子类定义钩子(hook)方法
13  }
14  addCondiments() {
15    console.log('加糖和牛奶')
16  }
17}
18const coffee = new Coffee()

11.6 好莱坞原则

好莱坞原则来源于好莱坞的新演员经常给演艺公司打电话询问面试情况,演艺公司往往这样回答:不要来找我,我会给你打电话。

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

别调用我们,我们会调用你。

模版方法模式就是好莱坞原则的典型使用场景,它与好莱坞原则的联系非常明显,当我们用模版方法模式编写一个程序时,就意味着子类放弃了对自己的控制权,而是改为父类通知子类,哪些方法应该在什么时候被调用。作为子类,只负责提供一些设计上的细节。

除此之外,好莱坞原则还应该应用于其他模式和场景,例如发布-订阅模式和回调函数。

  • 发布-订阅模式

    发布者将消息推送给订阅者,这取代了原先不断去 fetch 消息的形式。例如我们乘坐出租车去一个地方,除了每隔一段时间询问司机是否到目的地之外,还可以先跟司机说好(订阅),等到了目的地再喊你(发布),这样你在中间就可以睡一觉了。这就相当于好莱坞原则中的不要调用我,我会调用你。

  • 回调函数

    在 ajax 异步请求中,由于不知道请求返回的具体时间,而通过轮询去判断是否返回数据,这显然是不理智的行为。所以我们通常会把接下来的操作放在回调函数中,传入发起 ajax 异步请求的函数。当数据返回之后,这个回调函数才被执行,这也是好莱坞原则的一种体现。把需要执行的操作封装在回调函数里,然后把主动权交给另外一个函数。至于回调函数什么时候被执行,则是另外一个函数控制的。

11.7 真的需要继承吗?

模版方法是基于继承的一种设计模式,父类封装了子类的算法框架和方法的执行顺序,子类继承父类后,父类通知子类执行这些方法,好莱坞原则很好地诠释了这种设计模式,即高层组件调用底层组件。

模版方法是为数不多的基于继承的类式继承,但 JavaScript 语言没有真正实现类式继承,而是通过对象和对象之间的委托来实现的。

对于 JavaScript 来说,不一定需要继承才能完成模版方法模式。

还可以这样:

js
1function Beverage(params) {
2  function boilWater() {
3    console.log('把水煮沸')
4  }
5  var brew = params.brew || function () {}
6  var pourInCup = params.pourInCup || function () {}
7  var addCondiments = params.addCondiments || function () {}
8  var f = function () {}
9  f.prototype.init = function () {
10    boilWater()
11    brew()
12    pourInCup()
13    addCondiments()
14  }
15  return f
16}
17
18var Coffee = Beverage({
19  brew: function () {
20    console.log('沸水冲泡咖啡')
21  },
22  pourInCup: function () {
23    console.log('咖啡倒进杯子')
24  },
25  addCondiments: function () {
26    console.log('加糖和牛奶')
27  },
28})
29
30const coffee = new Coffee()
31coffee.init()

在上面的代码中,我们分别将 brew、pourIncup、addCondiments 传入 Beverage 中,当 Beverage 函数调用后返回 f 函数,这是一个构造器,它能够帮我们继续构造一个原型上具有 init 函数的对象,也可能实现继承的效果。

11.8 小结

模版方法模式是一种典型的通过封装变化提高系统扩展性的设计模式。传统的面向语言中,一个运用了模版方法模式的程序中,子类的方法种类和执行顺序是不变的,所以我们把这部分的逻辑抽象到父类的模版方法里。而子类的方法具体怎么实现是可变的,于是我们把可变的逻辑封装到子类中。通过增加新的子类,我们就可以给系统增加新的功能,并不需要改动抽象父类以及其他子类,这也是符合开放-封闭原则的。

在 javascript 中,我们很多时候并不需要用传统语言的方式来运用模版方法模式,高阶函数更合适。