JavaScript 设计模式学习与实践(四)

本文我们总结JavaScript中的命令模式,组合模式和模版方法。这几个模式比较相似。

命令模式

假设有一个快餐店,而我是该餐厅的点餐服务员,那么我一天的工作应该是这样的,当某位客人点餐后,需要把他的需求都写在清单上,然后交给厨房。客人不需要关心是哪些厨师帮他们炒菜。餐厅还可以满足客人需要的定时服务,如客人可能当前正在回家的路上,要求一个小时后才开始炒菜,只要订单还在,厨师就不会忘记。客人可以很方便地取消订单,当有太多客人的时候,也可以按照订单顺序排队。

命令模式最常见的应用场景是:有时候需要向某些对象发送请求,但是并不知道请求的接受者是谁,也不知道被请求的操作是什么。此时希望通过一种松耦合的方式来设计程序,使得请求发送者和接受者能够消除彼此之间的耦合关系

从消除耦合关系这一点讲,其实命令模式和发布订阅模式有着相似的目的。命令模式更关注的是根据不同的命令对象执行不同的命令操作,而发布订阅模式则更加关注改变程序之间的消息传递机制。

拿订餐来说,客人需要向厨师发送请求,但是完全不知道这些厨师的名字和联系方式,也不知道厨师炒菜的方式和步骤。命令模式把客人订餐的请求封装成command对象,也就是订餐对象中的订单对象。这个对象可以在程序中四处传递,就像订单可以从服务员手中传到厨师的手中。这样一来,客户不需要知道厨师的名字,从而解开了请求调用者和接收者之间的耦合关系。

命令模式的例子——菜单程序

设计模式的主题总是把不变的事物和变化的事物分离开来,命令模式也不例外。按下按钮之后会发生的一些事情是不变的,而具体发生什么事情是可变的。通过command对象的帮助,我们可以轻易改变这种关联,因此也可以在将来再次改变按钮的行文。

我们先绘制几个按钮:

1
2
3
4
5
6
7
8
9
10
<body>
<button id="button1">点击按钮1</button>
<button id="button2">点击按钮2</button>
<button id="button3">点击按钮3</button>
</body>
<script>
var button1 = document.getElementById('button1');
var button2 = document.getElementById('button2');
var button3 = document.getElementById('button3');
</script>

接下来定义setCommand函数,该函数负责往按钮上安装命令。可以肯定的是,点击按钮会执行某个command命令,执行命令的动作会被约定为调用command的execute方法,虽然还不知道这些命令究竟代表什么操作,但负责绘制按钮的程序员不关系这些事情,他只需要预留好安装命令的接口,command对象自知道如何正确的和对象沟通。

1
2
3
4
5
var setCommand = function(button, command) {
button.onclick = function() {
command.execute();
}
}

最后,负责编写点击按钮具体操作的程序员上交了他们的成果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
var MenuBar = {
refresh: function() {
console.log('刷新菜单目录')
}
}

var SubMenu = function() {
add: function() {
console.log('增加子菜单')
},
del: function() {
console.log('删除子菜单')
}
}

var RefreshMenuBarCommand = function(receiver) {
this.receiver = receiver;
}

RefreshMenuBarCommand.prototype.execute = function() {
this.receiver.refresh();
}

var AddSubMenuCommand = function(receiver) {
this.receiver = receiver;
}

AddSubMenuCommand.prototype.execute = function() {
this.receiver.add();
}

var DelSubMenuCommand = function(receiver) {
this.receiver = receiver;
}

DelSubMenuCommand.prototype.execute = function() {
this.receiver.del();
}

var refreshMenuBarCommand = new RefreshMenuBarCommand(MenuBar);
var addSubMenuCommand = new AddSubMenuCommand(SubMenu);
var delSubMenuCommand = new DelSubMenuCommand(SubMenu);

setCommand(button1, refreshMenuBarCommand);
setCommand(button2, addSubMenuCommand);
setCommand(button3, delSubMenuCommand);

JavaScript的命令模式

也许我们会疑惑,所谓的命令模式,看起来就是给对象的某个方法取了execute的名字,引入command对象和receiver这两个无中生有的角色无非是把简单的事情复杂化了,下面的代码也能实现相同的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var bindClick = function(button, func) {
button.onclick = func;
}

var MenuBar = {
refresh: function() {
console.log('刷新菜单目录')
}
}

var SubMenu = function() {
add: function() {
console.log('增加子菜单')
},
del: function() {
console.log('删除子菜单')
}
}

bindClick(button1, MenuBar.refresh);
bindClick(button2, SubMenu.add);
bindClick(button3, SubMenu.del);

这种说法是正确的,上面的代码是模拟传统的面向对象语言的命令模式的实现。命令模式将过程式的请求调用封装在command对象的execute方法里,通过封装方法的调用,可以吧运算块包装成形,command对象可以被四处传递,所以调用命令的时候,客户可以不需要关心事情是如何进行的。

命令模式的由来,其实是回调函数的一个面向对象的替代品

JavaScript作为函数作为一等对象的语言,跟策略模式一样,命令模式也早已融入到了JavaScrip语言之中。运算块不一定要封装在command.execute中,可以封装在普通函数中,函数作为一等对象,本来就可以被四处传递。

在面向对象设计中,命令模式的接收者被当成command对象的属性保存起来,同时约定执行命令的操作调用command.execute方法。在使用闭包的命令模式中实现,接收者被封闭在闭包产生的环境中,执行命令的操作可以更加简单,仅仅执行回调函数就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var setCommand = function(button, func) {
button.onclick = function() {
func();
}
}

var MenuBar = {
refresh: function() {
console.log('刷新菜单目录')
}
}

var RefreshMenuBarCommand = function(receiver) {
return function() {
reciever.refresh();
}
}

var refreshMenuBarCommand = new RefreshMenuBarCommand(MenuBar);

setCommand(button1, refreshMenuBarCommand);

当然,如果想要更明确地表达当前正在使用命令模式,或者除了执行命令之外,将来有可能需要提供撤销命令的操作,最好还是把执行函数设置为执行execute方法

撤销命令

命令模式的作用不仅是封装运算块,还可以很方便地给命令对象增加撤销操作,就像订餐时客人可以通过电话来取消订单一样。

本节的目标是利用Animate类来编写一个动画,这个动画的表现是让页面上的小球移动到水平方向的某个位置。现在页面中有一个input文本框和一个button按钮,文本框输入一些数字,表示小球移动后的水平位置,小球在用户点击按钮后立即开始移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<body>
<div id="ball"></div>
输入小球移动后的位置:<input id="pos" />
<button id="moveBtn">开始移动</button>
</body>

<script>
var ball = document.getElementById('ball');
var pos = document.getElementById('pos');
var moveBtn = document.getElementById('moveBtn');

moveBtn.onclick = function() {
var animate = new Animate(ball);
aninate.start('left', pos.value, 1000, 'strongEaseOut');
}
</script>

如果文本框输入200,然后点击moveBtn按钮,可以看到小球顺利地移动到水平方向200的位置,现在我们需要一个方法让小球还原到移动之前的位置。当然也可以在文本框中输入-200,然后点击按钮。

这是一个方法,不过比较笨拙,页面上最好有一个撤销按钮,点击撤销按钮知州,小球便能回到上一次的位置

添加撤销按钮之前,我们先将代码改成命令模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var ball = document.getElementById('ball');
var pos = document.getElementById('pos');
var moveBtn = document.getElementById('moveBtn');

var MoveCommand = function(reveiver, pos) {
this.receiver = receiver;
this.pos = pos;
}

MoveCommand.prototype.execute = function() {
this.receiver.start('left', this.pos, 1000, 'strongEaseOut');
}

var moveCommand;

moveBtn.onclick = function() {
var animate = new Animate(ball);
moveCommand = new MoveCommand(animate, pose.value);
moveCommand.execute();
}

撤销操作的实现一般是给命令对象增加一个名为unexexute或者undo方法,在该方法里执行execute的反向操作,在execute方法让小球真正开始运动之前,我们需要先记录小球当前的位置,在unexecute或者undo操作时,再让小球回到刚刚记录下的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var ball = document.getElementById('ball');
var pos = document.getElementById('pos');
var moveBtn = document.getElementById('moveBtn');
var cancelBtn = document.getElementById('cancelBtn');

var MoveCommand = function(reveiver, pos) {
this.receiver = receiver;
this.pos = pos;
this.oldPos = null;
}

MoveCommand.prototype.execute = function() {
this.receiver.start('left', this.pos, 1000, 'strongEaseOut');
this.oldPos = this.receiver.dom.getBoundingClientRect()[this.receiver.propertyName];
}

MoveCommand.prototype.undo = function() {
this.receiver.start('left', this.oldPos, 1000, 'strongEaseOut');
}

var moveCommand;

moveBtn.onclick = function() {
var animate = new Animate(ball);
moveCommand = new MoveCommand(animate, pose.value);
moveCommand.execute();
}

cancaleBtn.onclick = function() {
moveCommand.undo();
}

撤销和重做

刚才我们讨论了如何撤销一个命令。很多时候,我们需要撤销一系列的命令,比如在一个围棋程序中,已经下了10步棋了,我们需要一次性悔棋到第五步。在这之前,我们可以将所有执行过的步骤都存储到一个历史列表中,然后倒序循环来依次执行这些命令的undo直到循环到第5个命令为止。

然而,某些情况下无法顺利地利用undo操作让对象回到execute之前的状态。比如在canvas画图的程序中,画布上有一些点,我们在这些点之间画N条曲线讲这些点互相连接。但是我我们很难为这里命令模式定义一个擦除某条曲线的undo操作,因为在canvas中,擦除一条线相对不容易。

这个时候最好的方法就是先清除画布,然后把刚才执行过的命令全部执行一遍,这一点同样可以利用一个历史列表的堆栈执行。记录命令日志,然后重复执行他们,这是逆转不可逆转命令的好办法。

比如我们有的游戏是有回放功能的,如果我们存储的是视频可能比较大,但是我们可以存储所有的命令,通过回放命令的方式来回放游戏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
var Ryu = {
attack: function() {
console.log('攻击');
},
defense: function() {
console.log('防御');
},
jump: function() {
console.log('跳跃');
},
crouch: function() {
console.log('蹲下');
},
}

var makdCommand = function(receiver, state) {
return function() {
recevier[state]();
}
}

var commands = {
'119': 'jump',
'115': 'crouch',
'97': 'defense',
'100': 'attack',
}

var commandStack = [];

document.onkeypress = function(ev) {
var keyCode = ev.keyCode;
var command = makeCommand(Ryu, commands[keyCode]);

if (command) {
command()l
commandStack.push(command);
}
}

document.getElementById('replay').onclick = function() {
var command;
while(command = commandStack.shift()) {
command();
}
}

宏命令

宏命令是一组命令的集合,通过执行宏命令的方式,可以一次性执行一批命令。

想象有一个万能遥控器,每天回家按一个特殊按钮,就可以帮我们关上房门,打开电脑并登录QQ

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var closeDoorCommand = {
execute: function() {
console.log('关门');
}
}

var openCommand = {
execute: function() {
console.log('开电脑')
}
}

var openQQCommand = {
execute: function() {
console.log('登陆QQ')
}
}

var MarcoCommand = function() {
return {
commandList: [],
add: function(command) {
this.commandList.push(command);
},
execute: function() {
for(const command of this.commandList) {
command.execute();
}
}
}
}

var marcoCommand = MarcoCommand();
marcoCommand.add(closeDoorCommand);
marcoCommand.add(openCommand);
marcoCommand.add(openQQCommand);

marcoCommand.execute();

宏命令其实是命令模式与组合模式的联合的产物。

智能命令与傻瓜命令

我们刚才创建的命令的样子是这个样子的:

1
2
3
4
5
var closeDoorCommand = {
execute: function() {
console.log('关门');
}
}

这里的closeDoorCommand函数中没有任何recevier的信息,它本身就包揽了执行请求的行为,这和我们之前看到的命令对象都包涵一个receiver是矛盾的。

一般来说,命令模式都会在command对象中保存一个接收者来负责真正执行客户的请求,这种命令是“傻瓜式”的,它只负责把客户端的请求转交给接收者来执行,这种模式的好处是请求发起者和接受者的解耦。

但是我们也可以定义一些更加智能的命令,他们可以直接实现请求,这种聪明的对象叫做“智能命令”。

没有接收者的命令,退化到和策略模式非常相似,从代码结构上已经无法区分它们,能分辨的只有他们的目的不同。策略模式指向的问题域更小,所有策略对象的目标总是一致的,他们只是达到这个目标的不同手段,它们的内部实现是针对算法而言的。而智能命令模式指向的问题域更广,command对象解决的目标更具发散性,同时命令模式还能撤销,重做,甚至结合队列缓冲依次执行命令对象。

组合模式

在程序设计中,我们会遇到这种需求,就是“事物是由相似的子事物构成的”。组合模式就是用小的子对象来构建更大的对象,而这些小的子对象本身可能是由更小的孙对象构成的。

比如我们刚才的宏命令的例子,marcoCommand就是组合对象,closeDoorCommand,openCommand,openQQCommand都是叶子对象。在marcoCommand的execute方法里,并不真正执行操作,而是遍历它所包含的所有叶子对象,把真正的execute请求委托给这些叶子对象。

marcoCommand表现得像一个命令,但它实际上只是一组真正命令的“代理”。并非真正的代理,虽然结构上相似,但是它的目的不在于控制叶子对象的访问。

组合模式的用途

组合模式将对象组合成树形结构,以表示“整体-部分”的层次结构。除了用来表示树形结构之外,组合模式的另一个好处是可以通过对象的多态性来表现,使得用户对单个对象和组合对象的使用具有一致性。

  • 表示树形结构。通过宏命令的例子,我们可以找到组合模式的一个优点,提供了一种遍历树形结构的方案。通过调用组合对象的execute方法,程序会递归调用组合对象下面的叶对象的execute方法。所以我们的万能遥控器只需要一次操作,便能依次完成关门,开电脑,打开QQ这几件事情。组合模式可以非常方便地描述对象部分-整体的层次结构。
  • 利用对象的多态性统一对待组合对象和单个对象。利用对象的多态性表现,可以使客户端忽略组合对象和单个对象的不同。在组合模式中客户将统一地使用组合结构中的所有对象,而不需要关心它究竟是组合对象还是单个对象。

这在实际开发中会给客户带来很大的便利性。当我们往万能遥控器中添加一个命令时,并不关心这个命令是宏命令还是普通的字命令,我们只需要知道它需要有个execute方法就好。

更强大的宏命令

现在我们增强一下我们的万能遥控器,功能如下:

  • 打开空调
  • 打开电视和音响
  • 关门,开电脑,登陆QQ
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
var MarcoCommand = function() {
return {
commandList: [],
add: function(command) {
this.commandList.push(command);
},
execute: function() {
for(const command of this.commandList) {
command.execute();
}
}
}
}

var openAcCommand = {
execute: function() {
console.log('打开空调')
}
}

var openTvCommand = {
execute: function() {
console.log('打开电视');
}
}

var openSoundCommand = {
execute: function() {
console.log('打开音响');
}
}

var marcoCommand1 = MarcoCommand();
marcoCommand1.add(openTvCommand);
marcoCommand1.add(openSoundCommand);

var closeDoorCommand = {
execute: function() {
console.log('关门');
}
}

var openCommand = {
execute: function() {
console.log('开电脑')
}
}

var openQQCommand = {
execute: function() {
console.log('登陆QQ')
}
}

var marcoCommand2 = MarcoCommand();
marcoCommand2.add(closeDoorCommand);
marcoCommand2.add(openCommand);
marcoCommand2.add(openQQCommand);

var marcoCommand = MarcoCommand();
marcoCommand.add(openAcCommand);
marcoCommand.add(marcoCommand1);
marcoCommand.add(marcoCommand2);

marcoCommand.execute();

从这个例子可以看出,基本对象可以被组合成更加复杂的组合对象,组合对象又可以被组合对象不断递归组合下去。

抽象类在组合模式中的作用

组合模式最大的优点在于可以一致地对待组合对象和基本对象。客户不需要知道当前处理的是宏命令还是普通命令,只要它是一个命令,并且有execute方法,这个命令就可以被添加到树上。

这种透明性带来的便利性在静态类型的语言中尤为明显。比如在Java中,实现组合模式的关键是Composite类和Leaf类都必须继承自一个Component抽象类,这个Component抽象类既代表组合对象,又代表叶对象,它也能够保证组合对象和叶对象又有同样名字的方法。

然而在JavaScript这种动态类型语言中,对象的多态性是与生俱来的,也没有编译器去检查变量的类型,所以我们通常不会去模拟一个怪异的抽象类,JavaScript中实现组合模式的要点在于保证组合对象和叶对象拥有同样的方法,这通常需要鸭子类型的思想进行接口检查。

透明性带来的安全问题

组合模式的透明性使得发起请求的客户不用去顾及树中组合对象和叶对象的区别,但是它们实际上是有区别的。

组合对象可以拥有子节点,但是叶对象就没有子节点,所以我们也许会发生一些误操作向叶对象中添加子节点。解决方案通常是给叶对象也加一个add方法,但是该方法内部直接报错。

一些值得注意的地方

1. 组合模式不是父子关系

组合模式的树形结构容易让人误以为组合对象和叶对象是父子关系,这是不正确的。

组合模式是一种HAS-A的关系,而不是IS-A。组合对象包含一组叶对象,但是Leaf不是Composite的子类。组合对象把请求委托给它所包含的所有叶对象,他们能够合作的关键是拥有相同的接口。

2. 对叶对象的操作具有一致性

组合模式出了要求组合对象和叶对象拥有相同的接口之外,还有一个必要条件,就是对一组对象的操作必须具有一致性。

比如公司要给全体员工发放元旦的过节费1000块,这个场景可以运用组合模式,但如果公司给今天过生日的员工发送一封生日祝福,组合模式就没有用武之地了,除非先行把过生日的挑出来。只有用一致的方式对待列表中的每个叶对象时,才能用组合模式。

3. 双向映射关系

发放过节费的通知步骤是从公司到各个部门,再到各个小组,再到每个员工的邮箱中。这本身是一个组合模式的好例子,但是可能某些员工属于多个组织架构,对象之间并不是严格意义上的层次结构,也是不适合组合模式的。

这种情况下,我们必须给父节点和子节点建立双向映射关系,一个简单的方法是给小组和员工都增加集合来保存对方的引用。但是这种相互引用的方式相当复杂,而且对象之间产生过多的耦合性,修改和删除一个对象都变得困难,此时我们可以引入中介者模式,这个思想有点类似分析模式中把不变的信息抽象为知识层。

4. 用职责链模式提高组合模式性能

在组合模式中,如果树的结构比较复杂,节点数量很多,在遍历树的过程中,性能方面也许表现得不够理想。有时候我们确实可以借助一些技巧,在实际操作中避免遍历整棵树。

有一种方案是借助职责链模式。职责链模式一般需要我们手动去设置链条,但是在组合模式中,父子对象之间的关系天然形成链条。让请求顺着链条从父对象向子对象传递,或者反过来。直到遇到可以处理该请求的对象为止。

何时使用组合模式

  • 表示对象的部分-整体结构。组合模式可以方便地构造一棵树来表示对象的部分-整体结构。特别是我们在开发期间不确定这棵树到底存在多少层的时候。在树的构造最终完成之后,我们只需要请求树的最顶层对象,便能对整棵树作统一的操作。在组合模式中添加和删除节点非常方便,这也符合开闭原则。

  • 客户希望统一对待树中的所有对象。组合模式使客户可以忽略组合对象和叶对象的区别,客户在面对这棵树的时候,不用关心当前正在处理的对象是组合对象还是叶对象,也不用写一堆if-else来处理它们。组合对象和叶对象会做各自正确的事情。

模版方法模式

在JavaScript开发中用到继承的场景并不多,很多时候我们都喜欢用mixin的方式。

JavaScript虽然没有类,但是可以通过原型来实现继承。模版方法就是一种基于继承的设计模式。

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

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

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

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

一个例子——Coffee Or Tea

假设我们现在要泡一杯咖啡和一杯茶,二者的冲泡过程对比如下:

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

我们先写抽象父类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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();
}

然后我们分别生命煮茶和煮咖啡

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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();

Beverage.prototype.init被称为模版方法原因是,该方法中封装了子类的算法框架,它作为一个算法的模版,指导子类以何种顺序去执行哪些方法。

JavaScript中没有抽象类的缺点和解决方案

JavaScript并没有从语法层面提供对抽象类的支持。抽象类的第一个作用是是隐藏对象的具体类型。由于JavaScript是一门“类型模糊”的语言,所以隐藏对象的类型在JavaScript红并不重要。

另一方面,我们在JavaScript中使用原型继承来模拟传统的类式继承时,并没有编译器帮助我们进行任何形式的检查,我们也没有办法保证子类会重写父类中的抽象方法。

我们提供两种变通方案:

  • 用鸭子类型来模拟接口检查,以便保证子类中确实重写了父类的方法,但是模拟接口检查会带来不必要的复杂性

  • Beverage.prototype.brew等方法直接抛出一个异常,如果因为粗心忘记了编写Coffee.prototype.init方法,我们至少会得到一个错误。

模版方法的使用场景

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

钩子方法

通过模版方法,我们在父类中封装了子类的算法框架。这个框架在正常情况下是可以正常运行的。但是如果我们遇到一个人,他不喜欢在喝咖啡的时候加糖和牛奶怎么办。

这个时候我们就需要用到钩子方法了,放置钩子来隔离变化是一种常用的手段,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var Beverage = function() {};

Beverage.prototype.boilWater = function() {
console.log('把水煮沸');
}

Beverage.prototype.customerWantsCondiments = function() {
return true;
}
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.init = function() {
this.boilWater();
this.brew();
this.pourInCup();
if (this.customerWantsCondiments()) {
this.addCondiments();
}
}

我们真的需要继承吗?

模版方法模式是为数不多的基于继承的设计模式,但是JavaScript实际上并没有真正的类继承,继承是通过对象与对象之间的委托来实现的,也就是说,虽然我们在形式上借鉴了类继承的语言,但是这样的模版方法并不正宗,而且我们是否需要用到继承这种方式来实现模版方法呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var Beverage = function(params) {
var boilWater = function(){
console.log('把水煮沸');
}

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

var pourInCup = params.pourInCup || function() {
throw new Error('子类必须重写pourInCup方法')
}

var addCondiments = params.addCondiments || function() {
throw new Error('子类必须重写addCondiments方法')
}

var F = function() {};

F.prototype.init = function() {
boilWater();
brew();
pourInCup();
addCondiments();
}
}

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

在JavaScritp中,很多时候,高阶函数是比模版方法更加有效的方法。