# 什么是“命令模式”?
命令模式(别名:动作模式、事务模式)定义:将一个请求封装为一个对象,从而使我们可用不同的请求对客户进行参数化;对请求排队或者记录请求日志,以及支持可撤销的操作。
简单来说,它的核心思想是:不直接调用类的内部方法,而是通过给“指令函数”传递参数,由“指令函数”来调用类的内部方法。
在这过程中,分别有 3 个不同的主体:调用者、传递者和执行者。
请求以命令的形式包裹在对象中,并传给调用对象。调用对象寻找可以处理该命令的合适的对象,并把该命令传给相应的对象,该对象执行命令。
# 应用场景
当想降低调用者与执行者(类的内部方法)之间的耦合度时,可以使用此种设计模式。比如:设计一个命令队列,将命令调用记入日志。
主要解决:在软件系统中,行为请求者与行为实现者通常是一种紧耦合的关系,但某些场合,比如需要对行为进行记录、撤销或重做、事务等处理时,这种无法抵御变化的紧耦合的设计就不太合适。
命令模式的案例-菜单
假设我们正在编写一个用户界面程序,该用户界面上至少有数十个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 actions = {
W: function() {
console.log('向前动作')
},
A: function() {
console.log('向左动作')
},
S: function() {
console.log('向后动作')
},
D: function() {
console.log('向右动作')
}
};
var makeCommand = function(receiver, state) { // 创建命令
return function() {
let func = receiver[state];
if (func instanceof Function)
func();
}
};
var commands = {
'119': 'W', // 前面的数字对应的ascii码的小写,后面的W指的是上面的技能函数
'97': 'A',
'115': 'S',
'100': 'D',
};
var commandStack = []; // 保存命令的堆栈
document.onkeypress = function(e) { // 用户按下键盘触发的事件
var keyCode = e.keyCode,
command = makeCommand(actions, commands[keyCode]);
if (command) {
command(); // 执行命令
commandStack.push(command); //将刚刚执行的命令放入到堆栈
}
};
document.getElementById('replay').onclick = function() { // 点击播放录像
console.log('-------开始播放动作录像--------')
var command;
while (command = commandStack.shift()) { // 从堆栈中取出命令依次执行
command();
}
}
</script>
</body>
</html>
当我们在键盘上敲下W、A、S、D这几个键来完成一些动作之后,再按下Replay按钮,此时便会重复播放之前的动作。
项目中层遇到了这个问题,还有一个是撤销,即同样维护一个撤销的undoList,在每点击一个动作,将更新undoList,当点击撤销命令的时候,将undoList中的command取出执行即可。而且可以设置最长撤销或者重做的步数,超过步数不予处理,这样就可以通过命令模式维护了一个队列,经过项目的实战运用起来还是不错的。
命令队列
所以我们可以把div的这些运动过程都封装成命令对象,再把它们压进一个队列堆栈,当动画执行完,也就是当前command对象的职责完成之后,会主动通知队列,此时取出正在队列中等待的第一个命令对象,并且执行它。
一个动画结束后该如何通知队列。通常可以使用回调函数来通知队列,除了回调函数之外,还可以选择发布-订阅模式。即在一个动画结束后发布一个消息,订阅者接=到这个消息之后,便开始执行队列里的下一个动画。读者可以尝试按照这个思路来自行实现一个队列动画。
可以参考本系列文章之设计模式之组合模式。中的宏命令设置。