第九章 命令模式
假设有一个快餐店,而我是该餐厅的服务员,那么我一天的工作应该是这样的:
-
当某位客人点餐或者打来订餐电话后,
-
我会把他的需求都写在清单上,
-
然后交给厨房,
客人不用关心是哪些厨师帮他炒菜。
我们餐厅还可以满足客人需要的定时服务,比如客人可能要求 1 个小时后才开始炒他的菜,只要订单还在,厨师就不忘记。
客人也可以很方便地打电话来撤销订单。
另外如果有太多的客人点餐,厨房可以按照订单的顺序排队炒菜。
这些记录着订餐信息的清单,便是命令模式中的命令对象。
跟许多其他语言不同,JavaScript 可以用高阶函数非常方便地实现命令模式。
命令模式在 JavaScript 语言中是一种隐形的模式。
9.1 命令模式的用途
命令模式是最简单和优雅的模式之一,命令模式中的命令(command
)指的是一个执行某些特定事情的指令。
命令模式最常见的应用场景是:
有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么,此时希望用一种松耦合的方式来设计软件,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。
此时希望用一种松耦合的方式来设计程序,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。
相对于过程化的请求调用,command
对象拥有更长的生命周期。
对象的生命周期是跟初始请求无关的,因为这个请求已经被封装在了 command
对象的方法中,成为了这个对象的行为。
除了这两点之外,命令模式还支持撤销、排队等操作,本章稍后将会详细讲解。
9.2 命令模式的例子——菜单程序
假设我们正在编写一个用户界面程序,该用户界面上至少有数十个 Button 按钮。
设计模式的主题总是把不变的事物和变化的事物分离开来,命令模式也不例外。
按下按钮之后会发生一些事情是不变的,而具体会发生什么事情是可变的。
<body>
<button id="button1">点击按钮 1</button>
<button id="button2">点击按钮 2</button>
<button id="button3">点击按钮 3</button>
<script>
var button1 = document.getElementById("button1");
var button2 = document.getElementById("button2");
var button3 = document.getElementById("button3");
// 接下来,定义 `setCommand` 函数,`setCommand` 函数负责往按钮上面安装命令。
// 其被约定为调用 `command` 对象的 `execute()` 方法。
var setCommand = function (button, command) {
button.onclick = function () {
command.execute();
};
};
// 然后,刷新菜单界面、增加子菜单和删除子菜单这几个功能也完成了。
// 这几个功能被分布在 `MenuBar` 和 `SubMenu` 这两个对象中:
// 刷新菜单界面
var MenuBar = {
refresh: function () {
console.log("刷新菜单目录");
},
};
// 增加子菜单和删除子菜单
var SubMenu = {
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 () {
console.log("删除子菜单");
};
// 最后就是把命令接收者传入到 `command` 对象中,并且把 `command` 对象安装到 `button` 上面:
var refreshMenuBarCommand = new RefreshMenuBarCommand(MenuBar);
var addSubMenuCommand = new AddSubMenuCommand(SubMenu);
var delSubMenuCommand = new DelSubMenuCommand(SubMenu);
setCommand(button1, refreshMenuBarCommand);
setCommand(button2, addSubMenuCommand);
setCommand(button3, delSubMenuCommand);
</script>
</body>
上面的代码是模拟传统面向对象语言的命令模式实现。
命令模式将过程式的请求调用封装在 command
对象的 execute
方法里,通过封装方法调用,我们可以把运算块包装成形。
command
对象可以被四处传递,所以在调用命令的时候,客户(Client)不需要关心事情是如何进行的。
9.3 JavaScript 中的命令模式
命令模式的由来,其实是回调函数的一个面向对象的替代品。
JavaScript 作为将函数作为一等对象的语言,跟策略模式一样,命令模式也早已融入到了 JavaScript 语言之中。
运算块不一定要封装在 command.execute
方法中,也可以封装在普通函数中。
函数作为一等对象,本身就可以被四处传递。
即使我们依然需要请求“接收者”,那也未必使用面向对象的方式,闭包可以完成同样的功能。
在面向对象设计中,命令模式的接收者被当成 command
对象的属性保存起来,同时约定执行命令的操作调用 command.execute
方法。
在使用闭包的命令模式实现中,接收者被封闭在闭包产生的环境中,执行命令的操作可以更加简单,仅仅执行回调函数即可。
用闭包实现的命令模式如下代码所示:
// 当 `button` 按钮被点击时,`setCommand` 函数会执行 `func`。
var setCommand = function (button, func) {
button.onclick = function () {
func();
};
};
var MenuBar = {
refresh: function () {
console.log("刷新菜单界面");
},
};
// `RefreshMenuBarCommand` 是一个工厂函数,接受一个 `receiver` 对象作为参数
var RefreshMenuBarCommand = function (receiver) {
return function () {
receiver.refresh();
};
};
// 调用 `RefreshMenuBarCommand` 工厂函数并传入 `MenuBar` 对象来创建 `refreshMenuBarCommand` 函数。
var refreshMenuBarCommand = RefreshMenuBarCommand(MenuBar);
// 使用 `setCommand` 函数将这个函数安装到 `button1` 按钮上。
setCommand(button1, refreshMenuBarCommand);
工厂函数是一种设计模式,其主要目的是创建对象。
工厂函数是一个函数,返回一个新的对象(通常返回对象,但其实它可以返回任何类型),而不使用
new
关键字来构造一个新实例。工厂函数通常接受参数,并根据这些参数定制返回的对象。
工厂函数在很多场合都很有用,尤其是在需要避免使用
new
,或者需要更加动态地创建对象时。
但为了方便拓展更多的功能,比如撤消命令等,我们把执行函数改成调用 execute
方法:
var RefreshMenuBarCommand = function (receiver) {
return {
execute: function () {
receiver.refresh();
},
};
};
var setCommand = function (button, command) {
button.onclick = function () {
command.execute();
};
};
var refreshMenuBarCommand = RefreshMenuBarCommand(MenuBar);
setCommand(button1, refreshMenuBarCommand);
9.4 撤销命令
命令模式的作用不仅是封装运算块,而且可以很方便地给命令对象增加撤销操作。
撤销操作的实现一般是给命令对象增加一个名为 unexecude
或者 undo
的方法,在该方法里执行 execute
的反向操作。
9.5 撤消和重做
很多时候,我们需要撤销一系列的命令。
但在某些情况下无法顺利地利用 undo
操作让对象回到 execute
之前的状态。
比如在一个 Canvas 画图的程序中,画布上有一些点,我们在这些点之间画了 N 条曲线把这些点相互连接起来,当然这是用命令模式来实现的。
但是我们却很难为这里的命令对象定义一个擦除某条曲线的 undo
操作,因为在 Canvas 画图中,擦除一条线相对不容易实现。
这时候最好的办法是先清除画布,然后把刚才执行过的命令全部重新执行一遍,这一点同样可以利用一个历史列表堆栈办到。
在 HTML5 版《街头霸王》游戏中,命令模式可以用来实现播放录像功能。
原理是把用户在键盘的输入都封装成命令,执行过的命令将被存放到堆栈中。播放录像的时候只需要从头开始依次执行这些命令便可,代码如下:
<html>
<body>
<button id="replay">播放录像</button>
</body>
<script>
var Ryu = {
attack: function () {
console.log("攻击");
},
defense: function () {
console.log("防御");
},
jump: function () {
console.log("跳跃");
},
crouch: function () {
console.log("蹲下");
},
};
var makeCommand = function (receiver, state) {
// 创建命令
return function () {
receiver[state]();
};
};
var commands = {
119: "jump", // W
115: "crouch", // S
97: "defense", // A
100: "attack", // D
};
var commandStack = []; // 保存命令的堆栈
document.onkeypress = function (ev) {
var keyCode = ev.keyCode,
command = makeCommand(Ryu, commands[keyCode]);
if (command) {
command(); // 执行命令
commandStack.push(command); // 将刚刚执行过的命令保存进堆栈
}
};
document.getElementById("replay").onclick = function () {
// 点击播放录像
var command;
while ((command = commandStack.shift())) {
// 从堆栈里依次取出命令并执行
command();
}
};
</script>
</html>
9.6 命令队列
在订餐的故事中,如果订单的数量过多而厨师的人手不够,则可以让这些订单进行排队处理。
第一个订单完成之后,再开始执行跟第二个订单有关的操作。
把请求封装成命令对象的优点在这里再次体现了出来,对象的生命周期几乎是永久的,除非我们主动去回收它。
也就是说,命令对象的生命周期跟初始请求发生的时间无关,command 对象的 execute
方法可以在程序运行的任何时刻执行,即使点击按钮的请求早已发生,但我们的命令对象仍然是有生命的。
9.7 宏命令
宏命令是一组命令的集合,通过执行宏命令的方式,可以一次执行一批命令。
宏命令是命令模式与组合模式的联用产物。
9.8 智能命令和傻瓜命令
var closeDoorCommand = {
execute: function () {
console.log("关门");
},
};
closeDoorCommand
中没有包含任何 receiver
的信息,它本身就包揽了执行请求的行为,这跟我们之前看到的命令对象都包含了一个 receiver
是矛盾的。
一般来说,命令模式都会在 command 对象中保存一个接收者来负责真正执行客户的请求,这种情况下命令对象是“傻瓜式”的,
它只负责把客户的请求转交给接收者来执行,这种模式的好处是请求发起者和请求接收者之间尽可能地得到了解耦。
但是我们也可以定义一些更“聪明”的命令对象,“聪明”的命令对象可以直接实现请求,这样一来就不再需要接收者的存在,这种“聪明”的命令对象也叫作智能命令。
没有接收者的智能命令,退化到和策略模式非常相近,从代码结构上已经无法分辨它们,能分辨的只有它们意图的不同。
策略模式指向的问题域更小,所有策略对象的目标总是一致的,它们只是达到这个目标的不同手段,它们的内部实现是针对“算法”而言的。
而智能命令模式指向的问题域更广,command 对象解决的目标更具发散性。