理解命令模式
假设有一个快餐店,而我是该餐厅的点餐服务员,那么我一天的工作应该是这样的:当某位客人点餐或者打来订餐电话后,我会把他的需求都写在清单上,然后交给厨房,客人不用关心是哪些厨师帮他炒菜。我们餐厅还可以满足客人需要的定时服务,比如客人可能当前正在回家的路上,要求1个小时后才开始炒他的菜,只要订单还在,厨师就不会忘记。客人也可以很方便地打电话来撤销订单。另外如果有太多的客人点餐,厨房可以按照订单的顺序排队炒菜。
这些记录着订餐信息的清单,便是命令模式中的命令对象。
命令模式的用途
命令模式是最简单和优雅的模式之一,命令模式中的命令(command)指的是一个执行某些特定事情的指令。
命令模式最常见的应用场景是:有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么。此时希望用一种松耦合的方式来设计程序,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。 拿订餐来说,客人需要向厨师发送请求,但是完全不知道这些厨师的名字和联系方式,也不知道厨师炒菜的方式和步骤。 命令模式把客人订餐的请求封装成command对象,也就是订餐中的订单对象。这个对象可以在程序中被四处传递,就像订单可以从服务员手中传到厨师的手中。这样一来,客人不需要知道厨师的名字,从而解开了请求调用者和请求接收者之间的耦合关系。 命令模式的案例-菜单假设我们正在编写一个用户界面程序,该用户界面上至少有数十个Button按钮。因为项目比较复杂,所以我们决定让某个程序员负责绘制这些按钮,而另外一些程序员则负责编写点击按钮后的具体行为,这些行为都将被封装在对象里。在大型项目开发中,这是很正常的分工。对于绘制按钮的程序员来说,他完全不知道某个按钮未来将用来做什么,可能用来刷新菜单界面,也可能用来增加一些子菜单,他只知道点击这个按钮会发生某些事情。那么当完成这个按钮的绘制之后,应该如何给它绑定onclick事件呢?
回想一下命令模式的应用场景:
有时候需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是什么,此时希望用一种松耦合的方式来设计软件,使得请求发送者和请求接收者能够消除彼此之间的耦合关系。
我们很快可以找到在这里运用命令模式的理由:点击了按钮之后,必须向某些负责具体行为的对象发送请求,这些对象就是请求的接收者。但是目前并不知道接收者是什么对象,也不知道接收者究竟会做什么。此时我们需要借助命令对象的帮助,以便解开按钮和负责具体行为对象之间的耦合。
设计模式的主题总是把不变的事物和变化的事物分离开来,命令模式也不例外。按下按钮之后会发生一些事情是不变的,而具体会发生什么事情是可变的。通过command对象的帮助,将来我们可以轻易地改变这种关联,因此也可以在将来再次改变按钮的行为。
命令模式的由来,其实是回调(callback)函数的一个面向对象的替代品 在面向对象设计中,命令模式的接收者被当成command对象的属性保存起来,同时约定执行命令的操作调用command.execute方法。在使用闭包的命令模式实现中,接收者被封闭在闭包产生的环境中,执行命令的操作可以更加简单,仅仅执行回调函数即可。无论接收者被保存为对象的属性,还是被封闭在闭包产生的环境中,在将来执行命令的时候,接收者都能被顺利访问。用闭包实现的命令模式如下代码所示:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>命令模式</title> </head> <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'); var setCommand = function (button, func) { button.onclick = function () { func() } }; var menuBar = { refresh: function () { console.log('刷新菜单页面') }, add: function () { console.log('增加菜单页面') } }; var RefreshMenuBarCommand = function (receiver) { return function () { receiver.refresh() } }; var AddMenuBarCommand = function (receiver) { return function () { receiver.add() } }; var refreshMenuBarCommand = RefreshMenuBarCommand(menuBar); var addMenuBarCommand = AddMenuBarCommand(menuBar); setCommand(button1, refreshMenuBarCommand); setCommand(button2, addMenuBarCommand) </script> </body> </html>
当然,如果想更明确地表达当前正在使用命令模式,或者除了执行命令之外,将来有可能还要提供撤销命令等操作。那我们最好还是把执行函数改为调用execute方法:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>命令模式</title> </head> <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'); var setCommand = function (button, command) { button.onclick = function () { // 通过command.execute调用 command.execute() } }; var menuBar = { refresh: function () { console.log('刷新菜单页面') }, add: function () { console.log('增加菜单页面') } }; var RefreshMenuBarCommand = function (receiver) { return { // 返回一个execute函数 execute: function () { receiver.refresh() } } }; var AddMenuBarCommand = function (receiver) { return { execute: function () { receiver.refresh() } } }; var refreshMenuBarCommand = RefreshMenuBarCommand(menuBar); var addMenuBarCommand = AddMenuBarCommand(menuBar); setCommand(button1, refreshMenuBarCommand); setCommand(button2, addMenuBarCommand) </script> </body> </html>
撤销命令
命令模式的作用不仅是封装运算块,而且可以很方便地给命令对象增加撤销操作。记录上一次的操作,通过添加undo等方法回到上一步的状态
撤销与重做
很多时候,我们需要撤销一系列的命令。比如在一个围棋程序中,现在已经下了10步棋,我们需要一次性悔棋到第5步。在这之前,我们可以把所有执行过的下棋命令都储存在一个历史列表中,然后倒序循环来依次执行这些命令的undo操作,直到循环执行到第5个命令为止。
然而,在某些情况下无法顺利地利用undo操作让对象回到execute之前的状态。比如在一个Canvas画图的程序中,画布上有一些点,我们在这些点之间画了N条曲线把这些点相互连接起来,当然这是用命令模式来实现的。但是我们却很难为这里的命令对象定义一个擦除某条曲线的undo操作,因为在Canvas画图中,擦除一条线相对不容易实现。
这时候最好的办法是先清除画布,然后把刚才执行过的命令全部重新执行一遍,这一点同样可以利用一个历史列表堆栈办到。记录命令日志,然后重复执行它们,这是逆转不可逆命令的一个好办法。 假如托儿所想要查看自己所释放过的技能,原理跟Canvas画图的例子一样,我们把用户在键盘的输入都封装成命令,执行过的命令将被存放到堆栈中。查看技能释放录像的时候只需要从头开始依次执行这些命令便可,代码如下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>重做</title> </head> <body> <button id="replay">播放录像</button> <script> var skills = { Q: function () { console.log('斩钢闪') }, W: function () { console.log('风之障壁') }, E: function () { console.log('踏前斩') }, R: function () { console.log('狂风绝息斩') } }; var makeCommand = function (receiver, state) { // 创建命令 return function () { receiver[ state ](); } }; var commands = { '113': 'Q', // 前面的数字对应的ascii码的小写,后面的Q指的是上面的技能函数 '119': 'W', '101': 'E', '114': 'R', }; var commandStack = []; // 保存命令的堆栈 document.onkeypress = function (e) { // 用户按下键盘触发的事件 var keyCode = e.keyCode, command = makeCommand(skills, commands[keyCode]); if (command){ command(); // 执行命令 commandStack.push(command); //将刚刚执行的命令放入到堆栈 } }; document.getElementById('replay').onclick = function () { // 点击播放录像 var command; while(command = commandStack.shift()){ // 从堆栈中取出命令依次执行 command(); } } </script> </body> </html>
当我们在键盘上敲下Q、W、E、R这几个键来完成一些动作之后,再按下Replay按钮,此时便会重复播放之前的动作。
命令队列
所以我们可以把div的这些运动过程都封装成命令对象,再把它们压进一个队列堆栈,当动画执行完,也就是当前command对象的职责完成之后,会主动通知队列,此时取出正在队列中等待的第一个命令对象,并且执行它。
一个动画结束后该如何通知队列。通常可以使用回调函数来通知队列,除了回调函数之外,还可以选择发布-订阅模式。即在一个动画结束后发布一个消息,订阅者接=到这个消息之后,便开始执行队列里的下一个动画。读者可以尝试按照这个思路来自行实现一个队列动画。
宏命令
宏命令是一组命令的集合,通过执行宏命令的方式,可以一次执行一批命令。想象一下,家里有一个万能遥控器,每天回家的时候,只要按一个特别的按钮,它就会帮我们关上房间门,顺便打开电脑并登录QQ。
下面我们看看如何逐步创建一个宏命令。首先,我们依然要创建好各种Command:
var closeDoorCommand = { execute: function () { console.log('关门') } }; var openPicCommand = { execute: function () { console.log('开电脑') } }; var openQQCommand = { execute: function () { console.log('打开QQ') } };
接下来定义宏命令MacroCommand,它的结构也很简单。macroCommand.add方法表示把子命令添加进宏命令对象,当调用宏命令对象的execute方法时,会迭代这一组子命令对象,并且依次执行它们的execute方法:
var MacroCommand = function () { return { commandList: [], add: function (command) { this.commandList.push(command) }, execute: function () { for(var i=0,command; command=this.commandList[i++];){ command.execute(); } } } }; var macCommand = MacroCommand(); macCommand.add(closeDoorCommand); macCommand.add(openPicCommand); macCommand.add(openQQCommand); macCommand.execute();
当然我们还可以为宏命令添加撤销功能,跟macroCommand.execute类似,当调用macroCommand.undo方法时,宏命令里包含的所有子命令对象要依次执行各自的undo操作。
宏命令是命令模式与组合模式的联用产物。智能命令与傻瓜命令再看一下我们在之前创建的命令:
var closeDoorCommand = { execute: function () { console.log('关门') } };
很奇怪,closeDoorCommand中没有包含任何receiver的信息,它本身就包揽了执行请求的行为,这跟我们之前看到的命令对象都包含了一个receiver是矛盾的。
一般来说,命令模式都会在command对象中保存一个接收者来负责真正执行客户的请求,这种情况下命令对象是“傻瓜式”的,它只负责把客户的请求转交给接收者来执行,这种模式的好处是请求发起者和请求接收者之间尽可能地得到了解耦。
但是我们也可以定义一些更“聪明”的命令对象,“聪明”的命令对象可以直接实现请求,这样一来就不再需要接收者的存在,这种“聪明”的命令对象也叫作智能命令。没有接收者的智能命令,退化到和策略模式非常相近,从代码结构上已经无法分辨它们,能分辨的只有它们意图的不同。策略模式指向的问题域更小,所有策略对象的目标总是一致的,它们只是达到这个目标的不同手段,它们的内部实现是针对“算法”而言的。而智能命令模式指向的问题域更广,command对象解决的目标更具发散性。命令模式还可以完成撤销、排队等功能。
一、发送者(setCommond):不关心给哪个button,以及绑定什么事件,只要通过参数传入就好。
二、命令对象(commondObj):只需要接收到接受者的参数,当发送者发出命令时,执行就好。
三、接受者(menu):不用关心在哪里被调用被谁调用,只需要按需执行就好了。