JavaScript 模板方法模式
前言
模板方法模式的核心在于:定义一个操作中的算法骨架(模板方法),并将某些步骤延迟到子类中实现。通常我们会抽象一个父类,父类封装了子类的算法框架,包括实现一些公共方法和所有方法的执行顺序,子类继承父类,可以自己重写父类的方法。
在模板方法模式中,子类实现中相同部分被上移到父类中,而将不同的部分留给子类来实现,最后由父类的模板方法来控制整个算法的执行顺序和流程,子类只关心具体的算法实现。
这种方式十分依赖抽象类,但是JavaScript中很难去模仿一个抽象,但是我们也可以用自己的方式去实现。
咖啡和茶
这个是一个非常经典的例子,有助于我们理解什么是模板方法模式。
泡一杯咖啡
泡咖啡的步骤通常如下:
- 把水煮沸
- 用沸水冲泡咖啡
- 把咖啡倒进杯子
- 加糖和牛奶
代码实现如下:
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();
泡茶
泡茶的步骤和泡咖啡差不多:
- 把水煮沸
- 用沸水浸泡茶叶
- 把茶水倒进杯子
- 加柠檬
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();
找出共同点
可以发现泡咖啡和泡茶有很多共同的地方:
- 把水煮沸
- 用沸水冲泡
- 把它倒进杯子
- 加调料
这种将公共的部分抽离出来的结果就是抽象,我们将泡咖啡喝泡茶抽象出一个公共的处理方案: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();
茶
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();
当我们调用init
方法时,就会顺着原型链找到父类的init方法,父类的init方法规定好了泡饮料的顺序,所以我们能成功的泡咖啡和泡茶。
那么谁才是模板方法呢? 答案就是Beverage.prototype.init
。
Beverage.prototype.init 被称为模板方法的原因是,该方法中封装了子类的算法框架,它作 为一个算法的模板,指导子类以何种顺序去执行哪些方法。在 Beverage.prototype.init 方法中, 算法内的每一个步骤都清楚地展示在我们眼前。
抽象类
模板方法模式是一种严重依赖抽象类的设计模式,但是JavaScript本身并没有抽象类这种代码层的支持,我们也很难去模拟抽象类的实现。
在java中,抽象类的一大作用就是作为向上类型,从而实现面向对象的多态化,另一个作用就是一种契约约束,继承了抽象类的子类,它必须实现定义的接口,如果不实现将无法通过编译器的检查。
抽象类除了定义抽象方法,还可以有具体的实现,我们可以将公共的部分放在抽象类中,再通过继承实现代码的复用。
下面是java的实现:
public abstract class Beverage { // 饮料抽象类
final void init() { // 模板方法
boilWater();
brew();
pourInCup();
addCondiments();
}
void boilWater() { // 具体方法 boilWater
System.out.println("把水煮沸");
}
abstract void brew(); // 抽象方法 brew
abstract void addCondiments(); // 抽象方法 addCondiments
abstract void pourInCup(); // 抽象方法 pourInCup
}
public class Coffee extends Beverage { // Coffee 类
@Override
void brew() { // 子类中重写 brew 方法
System.out.println("用沸水冲泡咖啡");
}
@Override
void pourInCup() { // 子类中重写 pourInCup 方法
System.out.println("把咖啡倒进杯子");
}
@Override
void addCondiments() { // 子类中重写 addCondiments 方法
System.out.println("加糖和牛奶");
}
}
public class Tea extends Beverage { // Tea 类
@Override
void brew() { // 子类中重写 brew 方法
System.out.println("用沸水浸泡茶叶");
}
@Override
void pourInCup() { // 子类中重写 pourInCup 方法
System.out.println("把茶倒进杯子");
}
@Override
void addCondiments() { // 子类中重写 addCondiments 方法
System.out.println("加柠檬");
}
}
public class Test {
private static void prepareRecipe(Beverage beverage) {
beverage.init();
}
public static void main(String args[]) {
Beverage coffee = new Coffee(); // 创建 coffee 对象
prepareRecipe(coffee); // 开始泡咖啡
// 把水煮沸
// 用沸水冲泡咖啡
// 把咖啡倒进杯子
// 加糖和牛奶
Beverage tea = new Tea(); // 创建 tea 对象
prepareRecipe(tea); // 开始泡茶
// 把水煮沸
// 用沸水浸泡茶叶
// 把茶倒进杯子
// 加柠檬
}
}
JavaScript中的应对方式
JavaScript底层没有从语法层面上提供对抽象类的支持,当我们使用原型继承的时候,是不会任何形式的检查,我们也没法保证子类一定会重新父类的“抽象方法”。
那么我们如何保证在运行init
模板方法的时候不会出现问题呢?
- 第一种做法就是使用鸭子类型来模拟类型检查,我们在运行前先判断子类是否重写了父类的方法,但是这样会带来不必要的复杂性,而且这个判断会掺杂在业务逻辑中,影响单一原则。
判断是否重写了父类方法,可以如下:
// 封装一个函数来检查必要的方法是否被覆盖
function checkIfMethodImplemented(instance, methodName) {
if (typeof instance[methodName] !== 'function' || instance[methodName] === Beverage.prototype[methodName]) {
throw new Error(`Method ${methodName} must be implemented in${instance.constructor.name}`);
}
}
是否是一个函数以及是否是抽象类的方法。
- 第二种就是抛出异常,父类在实现的时候是抛出一个异常,如果子类不进行重写,就无法正常运行。
Beverage.prototype.brew = function() {
throw new Error('子类必须重写 brew 方法');
};
Beverage.prototype.pourInCup = function() {
throw new Error('子类必须重写 pourInCup 方法');
};
Beverage.prototype.addCondiments = function() {
throw new Error('子类必须重写 addCondiments 方法');
};
这种方式实现简单,付出的额外代价也很少,缺点就是我们只能在运行后才能知道问题所在。
在代码编程中,我们会有3种时机来获取错误信息:
- 编写代码的时候,通过编译器静态检查得到错误;
- 创建对象的时候,通过鸭子类型进行接口检查;
- 在程序运行过程中才知道哪里发生了错误;
模板方法模式的使用场景
在 Web 开发中也能找到很多模板方法模式的适用场景,比如我们在构建一系列的 UI 组件, 这些组件的构建过程一般如下所示:
- 初始化一个 div 容器;
- 通过 ajax 请求拉取相应的数据;
- 把数据渲染到 div 容器里面,完成组件的构造;
- 通知用户组件渲染完毕。
我们看到,任何组件的构建都遵循上面的 4 步,其中第(1)步和第(4)步是相同的。第(2)步不 同的地方只是请求 ajax 的远程地址,第(3)步不同的地方是渲染数据的方式。
于是我们可以把这 4 个步骤都抽象到父类的模板方法里面,父类中还可以顺便提供第(1)步和 第(4)步的具体实现。当子类继承这个父类之后,会重写模板方法里面的第(2)步和第(3)步。
钩子方法
通过模板方法模式,我们在父类中封装了子类的算法框架。这些算法框架在正常状态下是适 用于大多数子类的,但如果有一些特别“个性”的子类呢?
比如我们在饮料类 Beverage 中封装了 饮料的冲泡顺序:
- 把水煮沸
- 用沸水冲泡饮料
- 把饮料倒进杯子
- 加调料
假如有的顾客它不需要加调料,但是Beverage父类已经规定好了冲泡饮料的4个步骤,总不能因为部分用户不加调料,我就改动父类把第四步去掉吧,如果其他人又要,那我又要改动父类给加上,这显然不合适。
为此我们可以利用钩子方法(hook)来解决这个问题,我们父类提供一个钩子方法,这个方法可以有一个默认的实现,就是允许添加调料,子类可以重写该方法,从而控制是否添加调料。
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()) { // 如果挂钩返回 true,则需要调料
this.addCondiments();
}
};
var CoffeeWithHook = function() {};
CoffeeWithHook.prototype = new Beverage();
CoffeeWithHook.prototype.brew = function() {
console.log('用沸水冲泡咖啡');
};
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();
通过这种方式,不使用继承我们也能实现模板方法的效果,F
类的init方法依赖封装了饮料子类的算法框架。
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据