JS常见设计模式 之 模板方法模式

125 阅读13分钟

相信各位前端er都知道JS有一个伪继承机制(行为委托)吧,不过大家应该在日常开发中都用比较少,毕竟JS对于函数式编程实在是太友好了,咱们今天并不是要来讲继承,我们今天要讲的是一种基于继承的设计模式-模板方法(Template Method)模式

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

模板方法模式是一种只需使用继承就可以实现的非常简单的模式。

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

假如我们有一些平行的子类,各个子类之间有一些相同的行为,也有一些不同的行为。如果相同和不同的行为都混合在各个子类中实现,那么我们就有必要把这些相同的行为给提取出来进行封装。在模板方法模式中,子类实现中的相同部分被上移到父类中,而将不同的部分留待子类来实现。这也很好地体现了泛化的思想。

生活中的例子:茶与咖啡

作为一名各类饮料爱好者,从咖啡到茶我基本都爱喝,黑咖拿铁摩卡各类绿茶各类黑红茶等等。对于冲泡咖啡喝冲泡茶的过程是一个非常适合用来讲解模板方法模式的例子,首先我们用JS来实现一下两个饮料的冲泡过程。

泡咖啡

作为一名咖啡爱好者,如果我们对于咖啡没有特别的要求,那么我们泡咖啡的过程通常如下:

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

根据上面的代码我们可以用如下代码实现:

class Coffee {

  boilWater() {
    console.log('把水煮沸')
  }

  brewCoffeeGriends() {
    console.log('用沸水冲泡咖啡')
  }

  pourInCup() {
    console.log('把咖啡倒进杯子')
  }

  addSugarAndMilk() {
    console.log('加糖或者牛奶')
  }

  init() {
    this.boilWater()
    this.brewCoffeeGriends()
    this.pourInCup()
    this.addSugarAndMilk()
  }
}

const coffee = new Coffee()
coffee.init()

image.png

哈!一杯拿铁就被我们给制作出来了(虚空拿铁),哈哈哈~

泡茶

同样的,作为一名茶爱好者,泡茶的步骤和泡咖啡的步骤其实相差并不大:

  1. 把水煮沸
  2. 用沸水浸泡茶叶
  3. 把茶水倒进杯子
  4. 加陈皮

我们也用代码来实现一下:

class Tea {
  
  boilWater() {
    console.log('把水煮沸')
  }

  steepTeaLeaves() {
    console.log('用沸水浸泡茶叶')
  }

  pourInCup() {
    console.log('把茶水倒进杯子')
  }

  addChenPi() {
    console.log('加陈皮')
  }

  init() {
    this.boilWater()
    this.steepTeaLeaves()
    this.pourInCup()
    this.addChenPi()
  }

}

const tea = new Tea()
tea.init()

image.png

提取出共同点

经过上面的一系列操作,我们泡出了一杯茶和一杯咖啡,但是呢我们不难发现一个问题,就是泡咖啡和泡茶的过程是大同小异的:

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

我们可以找他们之间的一些不同点:

  • 原来不同。一个是咖啡,一个是茶叶,但是我们可以把它们向上抽象成“饮料”。
  • 泡的方式不同。咖啡是冲泡,而茶叶是浸泡,我们可以把两种方式向上抽象成“泡”。
  • 当然我们最后加入的调味品也是不同的,咖啡是糖和牛奶,茶叶则是陈皮,据此我们可以把它们抽象成调料。

经过抽象之后,不管是泡咖啡还是泡茶,我们都能整理成以下四步:

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

所以,不管是哪种冲泡方式,我们都能把它定义为一个方法brew(),同样的对于添加调料,我们可以定义为addCondiments().

接下来就让我们忘记我们上面写的泡咖啡和泡茶的代码,现在我们需要去创建一个抽象的父类,用来表示制作一杯饮料的整个过程,我们可以用Beverage来表示,代码如下:

class Beverage {

  boilWater() {
    console.log('把水煮沸')
  }

  brew() {} // 空方法,应该由子类重写实现

  pourInCup() {} // 空方法,应该由子类重写实现

  addCondiments() {} // 空方法,应该由子类重写实现

  init() {
    this.boilWater()
    this.brew()
    this.pourInCup()
    this.addCondiments()
  }

}

创建Coffee子类和Tea子类

现在创建一个Beverage类的对象对于我们来说并没有任何意义,它只是一个抽象出来的类,我们需要用咖啡和茶来继承这个类:

// 咖啡
class Coffee extends Beverage {

  brew() {
    console.log('用沸水冲泡咖啡')
  }

  pourInCup() {
    console.log('把咖啡倒进杯子')
  }

  addCondiments() {
    console.log('加糖和牛奶')
  }

}

const coffee = new Coffee()
coffee.init()

image.png

在上面的代码中我们通过定义一个Coffee类来继承我们定义的抽象类Beverage,然后重写了其中部分需要子类特殊实现的方法,而对于其他方法,如果我们子类没有实现,比如init()方法,当我们调用的时候该请求会顺着原型链被委托给父类Beverage上的init方法。

对于泡茶的代码,相信大伙已经了然于胸了,照顾一些动手能力比较差的小伙伴,我还是把代码贴出来:

// tea
class Tea extends Beverage{

  brew() {
    console.log('用沸水浸泡茶叶')
  }

  pourInCup() {
    console.log('把茶倒进杯子')
  }

  addCondiments() {
    console.log('加陈皮')
  }

}

const tea = new Tea()
tea.init()

image.png

本文一直讨论的是模板方法模式,那么我们看了这么多,到底谁才是所谓的模板方法呢?答案是Beverage.prototype.init

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

抽象类

很值得一提的是,模板方法模式是一种严重依赖抽象类的设计模式。ES6之前,JS在语言层面对于抽象类的支持是没有的,但是自从ES6之后,就有了class和extends可以让我们去在JS里面实现一种伪继承的操作,为什么说是伪继承呢,因为class类其实还是属于语法糖,是对function行为委托的一种封装

抽象类的作用

关于我们去定义类,我们可以定义成两种类:一种为具体类,一种为抽象类。具体类可以被实例化,而抽象类不能被实例化。如果想知道抽象类为什么不能被实例化的原因,我们可以深入思考”饮料“这个抽象类。

想象一个场景,在一个夏日炎炎的夏天我们口渴了,想去便利店买一瓶饮料解渴,我们最好不要直接对店员说:”来一瓶饮料“,因为当我们说了这个之后,店员接下来肯定会问:”要什么饮料?“饮料只是一个抽象名词,只有当我们真正明确了饮料的具体类型之后,我们才能得到我们想要的脉动或者东方树叶亦或者农夫三拳。

由于抽象类不能被实例化,如果有人编写了一个抽象类,那么这个抽象类一定是用来被某些具体类继承的。 那么在JS中我们该怎么编写代码防止该类被实例化呢,我们可以通过在classconstructor内部定义new.target的规则去进行防范,比如我们去修改一下Beverage类,在其中加入如下代码:

  constructor() {
    if(new.target === Beverage) {
      throw new Error('cant new this class')
    }
  }

这样之后,当我们尝试去new Beverage的时候,代码就会报错,阻止我们去进行实例化。

抽象类除了能帮我们去提取出公共代码,还有另一个好处就是我们可以在抽象类的抽象方法中抛出一些错误,防止在使用抽象类的时候没有去重写需要抽象的方法,比如我们可以在Beverage类的brew方法中添加一个抛出错误:

class Beverage {

  constructor() {
    if(new.target === Beverage) {
      throw new Error('cant new this class')
    }
  }

  boilWater() {
    console.log('把水煮沸')
  }

  brew() {
    throw new Error('请重写brew方法')
  } // 空方法,应该由子类重写实现

  pourInCup() {} // 空方法,应该由子类重写实现

  addCondiments() {} // 空方法,应该由子类重写实现

  init() {
    this.boilWater()
    this.brew()
    this.pourInCup()
    this.addCondiments()
  }

}

然后我们在Tea这个类里没有去重写brew方法:

class Tea extends Beverage{

  // brew() {
  //   console.log('用沸水浸泡茶叶')
  // }

  pourInCup() {
    console.log('把茶倒进杯子')
  }

  addCondiments() {
    console.log('加陈皮')
  }

}

const tea = new Tea()
tea.init()

我们就会发现当我们去new Tea()的时候会抛出错误:

image.png

如果在Tea子类中我们没有去实现brew方法,我们是100%得不到一杯茶的。既然父类规定了子类的方法和执行这些方法的顺序,那么子类就应该拥有这些方法,并提供正确的实现。

抽象方法和具体方法

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

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

模板方法模式的使用场景

从大的方面来讲,模板方法模式常被架构师用于搭建项目的框架,架构师定好了框架的骨架,程序员继承框架的架构之后,负责往里面填空,比如我们经常用Vue2去写代码的模式。而小的方面,比如在Web开发中我们需要去构建一些列的UI组件,这些组件的构建过程一般如下所示:

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

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

于是我们可以把这4个步骤都抽象到父类的模板方法里面,父类中还可以把第1步和第4步定义为具体方法,把第2步和第3步定义为抽象方法,当子类继承了父类的之后,就需要去重写抽象方法。

钩子函数

通过模板方法模式,我们在父类中封装了子类的算法框架。这些算法框架在正常状态下是使用大多数的,但如果有一些特别”个性“的子类呢?比如我们上面写的类Beverage中封装了饮料的冲泡顺序,但是如果我们有的人就是不想加调料品喜欢喝原汁原味的呢,那么有什么办法可以让子类不受这个约束呢?

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

真的需要”继承“吗?

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

本章我们通过模板方法模式编写了一个Coffee or Tea的例子。模板方法模式是为数不多的基于继承的设计模式,但JS语言实际上没有提供真正的类式继承,继承是通过对象与对象之间的行为委托来实现的。也就是说,虽然我们在形式上借鉴了提供类式继承的语言,但本章学习到的模板方法模式并不十分正宗。而且在JS这样灵活的语言中,实现这样一个例子,是否真的需要用继承呢?

我们用钩子函数的方式来改写一下之前的代码,一样能实现和继承一样的效果:

const Beverage = (options) => {
  const boilWater = () => {
    console.log('把水煮沸')
  }

  const brew = options.brew || (() => {
    throw new Error('need function brew')
  })

  const pourInCup = options.pourInCup || (() => {
    throw new Error('need function pourInCup')
  })

  const addCondiments = options.brew || (() => {
    throw new Error('need function addCondiments')
  })

  const Fn = function () {}
  Fn.prototype.init = () => {
    boilWater()
    brew()
    pourInCup()
    addCondiments()
  }
  return Fn
}

const Coffee = Beverage({
  brew() {
    console.log('用沸水冲泡咖啡')
  },
  pourInCup() {
    console.log('把咖啡倒进杯子')
  },
  addCondiments() {
    console.log('加糖和牛奶')
  },
})

const coffee = new Coffee()
coffee.init()

image.png

在上面的代码中,我们通过把brewpourInCupaddCondiments这些函数通过钩子函数的方式进行传递,一样达到了跟继承一样的效果,实现了模板方法设计模式。

小结

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

但在JS中,我们很多时候其实并不需要模仿其他语言照葫芦画瓢地去实现一个模板方法模式,高阶函数是更好的选择。