《js 设计模式与开发实践》读书笔记 11
在 js 开发中用到继承的场景其实并不很多,很多时候我们都喜欢用 min-in 的方式给对象扩展属性。但这不代表继承在 js 里没有用武之地,我们可以通过 prototype 来变相的实现继承。我们讨论一种基于继承的设计模式-模版方法模式。
模版方法模式是一种只需要使用继承就可以实现的非常简单的模式。模版方法模式由两部分结构组成,第一部分是抽象父类,第二部分是具体的实现子类。在模版方法模式中,子类实现中的相同部分被上移到父类中,而将不同的部分留待子类来实现。这也很好地体现了泛化的思想。
咖啡与茶是一个经典的例子,经常用来将模版方法模式。首先,我们先来泡一杯咖啡,步骤基本是 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()
接下来,开始准备我们的茶,泡茶的步骤跟泡咖啡的步骤相差并不大:1 把水煮沸,2 用沸水浸泡茶叶,3 把茶水倒进杯子,4 加柠檬。我们对比一下发现,咖啡喝茶的冲泡过程是大同小异的。1 原料不同,一个是咖啡,一个是茶,但我们可以把它们都抽象为饮料。2 泡的方式不同。咖啡是冲泡,而茶叶是浸泡,我们可以把它们都抽象为泡。3 加入的调料不同。一个是糖和牛奶,一个是柠檬,但我们可以把它们都抽象为调料。我们整理后的四步就变成了:1 把水煮沸,2 用沸水泡饮料,3 把饮料倒进杯子,4 加调料。所以不管是冲泡还是浸泡,我们都给它一个新的方法名称,比如说 brew(),同理不管是加糖和牛奶,还是加柠檬。我们都可以称之为 addCondiments()。现在我们创建一个抽象父类来表示泡一杯饮料的整个过程。不论是 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 类的对象对我们来说没有意义,因为世界上能喝的东西美誉一种真正叫饮料的,饮料再这里还只是一个抽象的存在。接下来我们要创建咖啡类和茶类。
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()
至此我们的 Coffee 类已经完成了,当调用 coffee 对象的 init 方法是,由于 coffee 对象和 Coffee 构造器的原型 prototype 上都没有对应的 init 方法,所以该请求会顺着原型链,被委托给 Coffee 的父类 Beverage 原型上的 init 方法。而 Beverage.prototype.init 中已经规定好了泡饮料的顺序,所以我们能成功的泡一杯咖啡,照着葫芦画瓢,创建我们的 Tea 类。
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 方法中,算法内的每一个步骤都清楚的展示在我们眼前。在 java 中编译器会保证子类会重写父类中的抽象方法,但在 js 中却没有进行这些检查工作。我们在编写代码的时候得不到任何形式的警告,完全寄托于程序员的记忆力和自觉性是很危险的,特别是当我们使用模版方法模式这种完全依赖继承而实现的设计模式时。有两种变通的解决方案。1 用鸭子类型来模拟接口检查,以确保子类中确实重写了父类的方法。2 时让 Beverage.prototype.brew 等方法直接抛出一个异常,如果因为粗心忘记编写 Coffee.prototype.brew 方法,那么至少我们会在程序运行时得到一个错误。Beverage.prototype.brew=function(){throw new Error('子类必须重写 brew 方法')}.第二种解决方案的优点是实现简单,付出的额外代价很少;缺点是我们的恶道的错误信息的时间点太靠后。我们一共有 3 次机会得到这个错误信息,第一次在编写代码的时候,通过编译器的检查来得到错误信息;第二次是在创建对象的时候用鸭子类型来进行接口检查;而目前我们不得不利用最后一次机会,在程序运行中才知道哪里发生了错误。
从大的方面来讲,模版方法模式常被架构师用于搭建项目的框架,架构师定好了框架的骨架,程序员继承框架的结构之后,负责往里面填空。通过模版方法模式,我们在父类中封装了子类的算法框架。这些算法框架在正常状态下适用于大多数子类的。但如果有一些特别个性的子类呢。比如我们在饮料类 Beverage 中封装了饮料的冲泡顺序:把水煮沸,用沸水冲泡饮料,把饮料倒入杯子,加调料。这 4 个冲泡饮料的步骤适用于咖啡和茶,在我们的饮料店里,根据这 4 个步骤制作出来的咖啡和茶,一直顺利的提供给绝大部分客人享用。但有一些客人可咖啡是不加调料的。既然 Beverage 作为父类,已经规定好了冲泡饮料的 4 个步骤,那么有什么办法可以让子类不受这个约束呢?
钩子(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 Coffee = function () {}
Coffee.prototype = new Beverage()
Coffee.prototype.brew = function () {
console.log('用沸水冲泡咖啡')
}
Coffee.prototype.pourInCup = function () {
console.log('把咖啡倒进杯子')
}
Coffee.prototype.addCondiments = function () {
console.log('加糖和牛奶')
}
Coffee.prototype.customerWantsCondiments = function () {
return window.confirm('请问需要调料吗?')
}
var coffee = new Coffee()
coffee.init()
学习完模板方法模式之后,我们要引入一个新的设计原则好莱坞原则。好莱坞是演员的天堂,但好莱坞也有很多找不到工作的新人演员,许多新人演员在好莱坞把简历递给演艺公司之后就只有回家等待电话。有时候等得不耐烦了,给演艺公司打电话询问情况,演艺公司会说,不要来找我,我会给你打电话。在设计中,这样的规则就称为好莱坞原则。在这个原则的指导下,我们允许底层组件将自己挂钩到高层组件中,而高层组件会决定什么时候,以何种方式去使用这些底层组件,高层组件对待底层组件的方式,就是别调用我们,我们会调用你。
当我们用模板方法 模式编写一个程序时,就意味着子类放弃了对自己的控制权,而是改为父类通知子类,哪些方法在什么时候被调用。作为子类,只负责提供一些设计上的细节。好莱坞原则也常用于发布-订阅模式,回调函数中。在发布-订阅模式中,发布者会把消息推给订阅者,这取代了原先不断去 fetch 消息的形式。例如假设我们乘坐出租车去一个不了解的地方,除了每过 5 秒钟就问司机是否到达目的地之外,还可以在车上睡上一觉,然后跟司机说好,等目的地到了就叫醒你。相当于别调用我们,我们会调用你。回调函数也累死,在 ajax 异步请求中,由于不知道请求返回的具体时间,而通过轮询去判断是否返回数据,这显然是不理智的行为。所以我们通常会把接下来的操作放在回调函数中,传入发起 ajax 异步请求的函数。
模板方法模式是基于继承的一种设计模式,父类封装了子类的算法框架和方法的执行顺序,子类继承父类之后,父类通知子类执行这些方法。我们上面的代码中更多的是照着面向对象语言的写法。我们 js 可以在好莱坞原则的指导下,用下面这种方法实现。
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 coffee = new Coffee()
coffee.init()
上面这种写法,我们把brew,pourInCup,addCondiments这些方法依次传入Beverage函数,Beverage函数被调用之后返回构造器F.F类中包含了模板方法F.prototype.init。跟继承得到的效果一样,该模板方法里依然封装了饮料子类的算法框架。
模板方法模式是一种典型的通过封装变化提高系统扩展性的设计模式。这个模式适合子类的方法种类和执行顺序不变,我们把这部分逻辑抽象到父类的模板房里里。但在js中,我们很多时候都不需要照葫芦画瓢去实现一个模版方法模式,高阶函数是更好的选择。