> 文章列表 > 设计模式之模板方法模式

设计模式之模板方法模式

设计模式之模板方法模式

参考资料

  • 曾探《JavaScript设计模式与开发实践》;
  • JavaScript设计模式之组合模式
  • JavaScript 设计模式(十六):模板方法模式

定义

在《JavaScript设计模式与开发实践》 中对模版方法模式的介绍:模版方法模式是一种只需使用继承就可以实现的非常简单的模式。
模版方法模式由两部分组成:抽象父类具体的实现子类。通常,在抽象父类中封装了子类的算法框架,包括实现一些公共方法以及封装子类中所有方法的执行顺序子类通过继承这个抽象类,也继承了整个算法结构,并且可以选择重写父类的方法。

使用场景:

  • 泡咖啡与茶;

  • 生命周期:比如Vue生命周期;

  • 构建UI组件

    当需要构建一系列UI组件,这些组件都有一些统一的步骤:
    (1)初始化div容器 -> (2)ajax拉取数据 -> (3)数据渲染到div容器中 -> (4)通知用户组件更新完毕
    我们可以把这个步骤抽象到父类的模版方法中,当子类继承了父类后,会重写第 (2) (3) 步

举例说明

经典例子:咖啡与茶,来讲解模版方法模式。当我们泡一杯茶或泡一杯咖啡通常需要这些步骤:

在这里插入图片描述

根据上面步骤,首先,我们需要找到不同点:

  • 原料不同,一个是茶,一个是咖啡,但我们把它抽象为饮料
  • 泡的方式不同:一个是冲泡,一个是浸泡,但我们可以把它抽象为
  • 加入调料不同:一个是糖和牛奶,一个是柠檬,但我们可以把它抽象为调料

经过抽象后,我们就可以把这两个事情整理成以下四步:

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

首先,我们创建一个抽象父类表示冲泡饮料的全过程:

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

其中,一些不同的逻辑:brewpourInCupaddCondiments应该是一个空方法,由子类重写。

然后,我们来创建Coffee子类和Tea子类:

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 Coffee();
tea.init();

当我们调用子类的init方法时,该请求会顺着原型链,被委托给父类Beverage原型上的init方法。

在上面的例子中,谁才是所谓的模版方法呢?答案就是:Beverage.prototype.init。原因是,该方法中封装了子类的算法框架,它作为一个算法的模版,指导子类以何种顺序去执行哪些方法。

钩子方法

通过模板方法模式,我们在父类中封装了子类的算法框架。这些算法框架在正常状态下是适用于大多数子类的,但如果有一些特别“个性”的子类呢?比如我们在饮料类 Beverage 中封装了饮料的冲泡顺序:

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

这 4 个冲泡饮料的步骤适用于咖啡和茶,在我们的饮料店里,根据这 4 个步骤制作出来的咖啡和茶,一直顺利地提供给绝大部分客人享用。但有一些客人喝咖啡是不加调料(糖和牛奶)的。既然 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中并没有从语法层面提供对抽象类的支持。抽象类的第一个作用是隐藏对象的具体类型,由于JavaScript是一门类型模糊语言,所以隐藏对象的类型在JavaScript中并不重要。
另一方面,当我们在Javascript中使用原型继承来模拟传统的类式继承时,并没有编译器帮助我们进行任何形式的检查,我们也没办法保证子类会重写父类中的抽象方法

如何解决

假如我们忘记了实现某一个方法呢?比如:忘记编写Coffee.prototype.brew,此时,当我们执行时,会找到父类中的brew方法Beverage.prototype.brew,而父类的brew方法是一个空方法,显然是不符合我们需要的。这种情况下,我们在编写代码的时候完全得不到警告,完全寄托于程序员的记忆力和自觉性是很危险。那么,应该如何去解决这个问题呢?

  • 鸭子类型模拟接口检查:

    var checkBrew = function(beverage) {return beverage.brew === Beverage.prototype.brew
    }// coffee为刚才创建的coffe实例 new Coffee()
    console.log('checkBrew', checkBrew(coffee)) // 子类有实现:false;子类没有实现:true
    复制代码
    

    Coffee子类没有实现brew方法时,checkBrew会返回true,表示当前的方法是继承父类的,否则为false

  • Beverage.prototype.brew方法抛出一个异常,至少我们在运行时会得到一个报错:

    Beverage.prototype.brew = function() {throw new Error('子类必须重写brew方法')
    }
    

相比这两种方式来说:
第一种方式可以在创建对象的时候用鸭子类型来检查,但需要在业务代码中添加这种与业务无关的逻辑;
第二种方式实现更加简单,付出的额外代价更少,但是我们得到错误的时间更靠后,只有在运行过程中才知道哪里发生了错误

好莱坞原则

在刚才的例子中,我们使用继承的方式实现了咖啡与茶的例子。模版方法模式是基于继承的设计模式,而在JavaScript中,实际并没有提供真正的类式继承继承是通过对象与对象之间的委托(基于原型链的方式)实现的,而在JavaScript中,真的需要用这种方式来实现么?
这就需要介绍一个新的设计原则:好莱坞原则

好莱坞原则也被称为控制反转 (IoC) 原则。 好莱坞原则指出“不要打电话给我们,我们会打电话给你。” 这意味着不是开发人员调用函数,而是函数在准备好执行时调用开发人员。 在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 coffee = new Coffee();
coffee.init();

在这段代码中,我们把brew、pourInCup、addCondiments等方法传入Beverage函数,Beverage函数返回构造器 F
F 类中包含了模板方法F.prototype.init。跟继承得到的效果一样,该模板方法里依然封装了饮料子类的算法框架。

源码中的模版方法模式

Axios

Axios源码中的request方法就用到了模版方法模式request方法处理了发送请求、请求/响应拦截器的逻辑,如下所示的源码所示:

// lib/core/Axios.jsAxios.prototype.request = function request(config) {/*eslint no-param-reassign:0*/// Allow for axios('example/url'[, config]) a la fetch APIif (typeof config === 'string') {config = arguments[1] || {};config.url = arguments[0];} else {config = config || {};}config = mergeConfig(this.defaults, config);// Set config.methodif (config.method) {config.method = config.method.toLowerCase();} else if (this.defaults.method) {config.method = this.defaults.method.toLowerCase();} else {config.method = 'get'}// Hook up interceptors middlewarevar chain = [dispatchRequest, undefined];var promise = Promise.resolve(config);this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {chain.unshift(interceptor.fulfilled, interceptor.rejected);});this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {chain.push(interceptor.fulfilled, interceptor.rejected);});while (chain.length) {promise = promise.then(chain.shift(), chain.shift());}return promise;
};

通过request函数实现,我们可以了解到,当我们发起一个请求时

  • 首先对我们传入的config参数进行了处理

  • 然后创建一个chain用来将我们设置的请求拦截器、请求、响应拦截器按照一定的顺序执行,按照源码中的逻辑,我们生成的chain为:
    在这里插入图片描述

  • 最终我们返回的promise为:

在这里插入图片描述

为什么说request方法就用到了模版方法模式呢?
因为request函数封装一个请求的执行顺序,类比前面的泡一杯茶和咖啡一样,request函数相当于是抽象父类,将规定每次请求时子类的执行顺序,而Axios中的拦截器则相当于具体的实现子类。我们可以自行设置请求拦截器和响应拦截器,这样就可以保持拦截器灵活性的同时,确保每个请求中的执行顺序。

Vue生命周期

有了上面的例子,相信大家能够很容易的理解vue生命周期中所用到的模版方法模式。我们简单的看下Vue源码中的生命周期部分:

// src/core/instance/init.jsVue.prototype._init = function() {// ....initLifecycle(vm)initEvents(vm)initRender(vm)callHook(vm, 'beforeCreate')initInjections(vm) // resolve injections before data/propsinitState(vm)initProvide(vm) // resolve provide after data/propscallHook(vm, 'created')// ...if (vm.$options.el) {vm.$mount(vm.$options.el)}
}// src/plaforms/web/runtime
Vue.prototype.$mount = function (el?: string | Element,hydrating?: boolean
): Component {el = el && inBrowser ? query(el) : undefinedreturn mountComponent(this, el, hydrating)
}// src/core/instance/lifecycle.js
export function mountComponent (vm: Component,el: ?Element,hydrating?: boolean
): Component {// ...callHook(vm, 'beforeMount')// ...updateComponent = () => {vm._update(vm._render(), hydrating)}new Watcher(vm, updateComponent, noop, {before () {if (vm._isMounted) {callHook(vm, 'beforeUpdate')}}}, true /* isRenderWatcher */)hydrating = falseif (vm.$vnode == null) {vm._isMounted = truecallHook(vm, 'mounted')}return vm
}

从代码中可以看到,在beforeCreate是在组件initState之前,初始化完成后会执行到created,而在组件render之前会先调用beforeMount,在执行完vm._update()把 VNode patch 到真实DOM后,执行mounted钩子。这样,通过在vue源码中就定义好了抽象父类以及子类执行的顺序,至于每个生命周期中具体要做什么,那就由开发者自行实现了。这个思路和前面的例子都类似,同时使用到了模版方法模式

总结

模板方法模式是一种典型的通过封装变化提高系统扩展性的设计模式。通过增加新的子类,我们便能给系统增加新的功能,并不需要改动抽象父类以及其他子类,这也是符合开放-封闭原则的。
但在JavaScript中,我们很多时候都不需要依样画瓢地去实现一个模版方法模式,高阶函数是更好的选择。