JavaScript 设计模式之命令模式

1,478 阅读9分钟

这是我参与8月更文挑战的第6天,活动详情查看:8月更文挑战

命令模式是最简单和优雅的模式之一,命令模式中的命令指的是一个执行某些特定事情的指令。

命令模式的用途

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

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

假设在写一个用户界面程序,该界面上至少有数十个 Button 按钮。因为项目比较复杂,所以我们分工给一个人负责绘制这些按钮,而另一个人负责编写点击按钮后的具体行为,这些行为都被封装在对象里。

那么,对于绘制按钮的程序员来说,他完全不知道某个按钮未来将用来做什么,他只知道点击这个按钮会发生某些事情。当完成这个按钮的绘制之后,应该如何给它绑定 onclick 事件呢。

很明显,在这里运用命令模式的理由是:点击了按钮之后,必须向某些负责具体行为的对象发送请求,这些对象就是请求的接收者。但是目前并不知道接收者是什么对象,也不知道接收者究竟会做什么。此时就需要借助命令对象的帮助,以便解开按钮和负责具体行为对象之间的耦合。

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

先在页面中绘制按钮:

<button id="button1"></button>
<button id="button2"></button>
<button id="button3"></button>

<script>
  var button1 = document.getElementById("button1");
  var button2 = document.getElementById("button2");
  var button3 = document.getElementById("button3");
</script>

接下来定义 setCommand 函数,setCommand 函数负责往按钮上面安装命令。我们可以确定的是,点击按钮会执行某个 command 命令,执行命令的动作被约定为调用 command 对象的 execute() 方法。负责绘制按钮的程序员只需要预留好安装命令的接口,command 对象知道如何和正确的对象沟通:

var setCommand = function(button, command) {
  button.onclick = function() {
    command.execute()
  }
}

点击按钮之后具体行为包括刷新菜单界面、增加子菜单和删除子菜单等,这几个功能被分布在 MenuBarSubMenu 这两个对象中:

var MenuBar = {
  refresh: function() {
    console.log('刷新菜单目录')
  }
}
var SubMenu = {
  add: function() {
    console.log('增加子菜单')
  },
  del: function(){
    console.log('删除子菜单');
  }
}

在让 button 变得有用起来之前,要先把这些行为都封装在命令类中:

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

最后就是把命令接收者传入到 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);

上述是一个很简单的命令模式示例,但是从中可以看到我们是如何把请求发送者和请求接收者解耦开的。

JavaScript 中的命令模式

从上述看起来,所谓的命令模式,看起来就是给对象的某个方法取了 execute 的名字。引入 command 对象和 receiver 写着两个无中生有的角色无非是把简单的事情复杂化了,即使不用什么模式,用下面寥寥几行代码就可以实现相同的功能:

var bindClick = function (button, func) {
  button.onclick = func;
};
var MenuBar = {
  refresh: function () {
    console.log("刷新菜单界面");
  },
};
var SubMenu = {
  add: function () {
    console.log("增加子菜单");
  },
  del: function () {
    console.log("删除子菜单");
  },
};
bindClick(button1, MenuBar.refresh);
bindClick(button2, SubMenu.add);
bindClick(button3, SubMenu.del);

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

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

JavaScript 中运算块不一定要封装在 command.execute 方法中,也可以封装在普通函数中。即使我们依然需要请求“接收者”,那也未必使用面向对象的方式,闭包可以完成同样的功能。

var setCommand = function (button, func) {
  button.onclick = function () {
    func();
  };
};
var MenuBar = {
  refresh: function () {
    console.log("刷新菜单界面");
  },
};
var RefreshMenuBarCommand = function (receiver) {
  return function () {
    receiver.refresh();
  };
};
var refreshMenuBarCommand = RefreshMenuBarCommand(MenuBar);
setCommand(button1, refreshMenuBarCommand);

当然,如果想更明确地表达当前正在使用命令模式,或者除了执行命令之外,将来有可能还要提供撤销命令等操作。那我们最好还是把执行函数改为调用 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);

撤销命令

命令模式的作用不仅是封装运算块,而且可以很方便地给命令对象增加撤销操作。

之前写过的一个 Animate 类来编写一个动画,这个动画的表现是让页面上的小球移动到水平方向的某个位置。现在页面中有一个 input 文本框和一个 button 按钮,文本框中可以输入一些数字,表示小球移动后的水平位置,小球在用户点击按钮后立刻开始移动,代码如下:

<div
  id="ball"
  style="position: absolute; background: #000; width: 50px; height: 50px"
></div>
输入小球移动后的位置:<input id="pos" />
<button id="moveBtn">开始移动</button>
<script>
  var ball = document.getElementById("ball");
  var pos = document.getElementById("pos");
  var moveBtn = document.getElementById("moveBtn");
  moveBtn.onclick = function () {
    var animate = new Animate(ball);
    animate.start("left", pos.value, 1000, "strongEaseOut");
  };
</script>

如果文本框输入200,然后点击 moveBtn 按钮,可以看到小球顺利地移动到水平方向 200px 的位置。现在我们需要一个方法让小球还原到开始移动之前的位置。当然也可以在文本框中再次输入 -200,并且点击 moveBtn 按钮,这也是一个办法,但是很笨拙。页面上最好有一个撤销按钮,点击撤销后,小球便能回到上一次的位置。

在页面中增加撤销按钮之前,先把目前的代码改为用命令模式实现:

var ball = document.getElementById("ball");
var pos = document.getElementById("pos");
var moveBtn = document.getElementById("moveBtn");
var MoveCommand = function (receiver, 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, pos.value);
  moveCommand.execute();
};

接下来增加撤销按钮:

<div
  id="ball"
  style="position: absolute; background: #000; width: 50px; height: 50px"
></div>
输入小球移动后的位置:<input id="pos" />
<button id="moveBtn">开始移动</button>
<button id="cancelBtn">cancel</cancel> <!--增加取消按钮-->

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

<script>
var ball = document.getElementById('ball');
var pos = document.getElementById('pos');
var moveBtn = document.getElementById('moveBtn');
var cancelBtn = document.getElementById('cancelBtn');
var MoveCommand = function (receiver, 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, pos.value); moveCommand.execute();
};
cancelBtn.onclick = function () {
  moveCommand.undo();// 撤销命令
};
</script>

现在通过命令模式轻松地实现了撤销功能。如果用普通方法调用来实现,也许需要每次都手工记录小球的运动轨迹,才能让它还原到之前的位置。而命令模式中小球的原始位置在小球开始移动前已经作为 command 对象的属性被保存起来,所以只需要再提供一个 undo 方法,并且在 undo 方法中让小球会到刚刚记录的原始位置就可以了。

撤销是命令模式里一个非常有用的功能,同样,撤销命令还可以用于实现文本编辑器的 Ctrl+Z 功能。

宏命令

宏命令是一组命令的集合,通过执行宏命令的方式,可以一次执行一批命令。接下来,来逐步创建一个宏命令。

Step1:创建好各种 Command

var closeDoorCommand = {
  execute: function () {
    console.log('关门');
  }
};
var openPcCommand = {
  execute: function () {
    console.log('开电脑');
  }
};
var openQQCommand = {
  execute: function () {
    console.log('登录 QQ');
  }
};

Step2:定义宏命令 MacroCommand

macroCommand.add 方法表示把子命令添加进宏命令对象,当调用宏命令对象的 execute 方法时,会迭代这一组子命令对象,并且依次执行它们的 execute 方法:

var MacroCommand = function () {
  return {
    commandsList: [],
    add: function (command) {
      this.commandsList.push(command);
    },
    execute: function () {
      for (var i = 0, command; command = this.commandsList[i++];) {
        command.execute();
      }
    }
  }
};

var macroCommand = MacroCommand();
macroCommand.add(closeDoorCommand);
macroCommand.add(openPcCommand);
macroCommand.add(openQQCommand);
macroCommand.execute();

还可以为宏命令添加撤销功能,跟 macroCommand.execute 类似,当调用 macroCommand.undo 方法时,宏命令里包含的所有子命令对象要依次执行各自的 undo 操作。

智能命令与傻瓜命令

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

一般来说,命令模式都会在 command 对象中保存一个接收者来负责真正执行客户的请求,这种情况下命令对象是“傻瓜式”的,它只负责把客户的请求转交给执行者来执行,这种模式的好处是请求发起者和请求接收者之间尽可能地得到了解耦

但是我们也可以定义一些更“聪明”的命令对象,“聪明”的命令对象可以直接实现请求,这样一来就不再需要接收者的存在,这种“聪明”的命令对象也叫作智能命令。没有接收者的智能命令,退化到和策略模式非常相近,从代码结构上已经无法分辨它们,能分辨的只有它们意图的不同。策略模式指向的问题域更小,所有策略对象的目标总是一致的,它们只是达到这个目标的不同手段,它们的内部实现是针对“算法”而言的。而智能命令模式指向的问题域更广,command 对象解决的目标更具发散性。命令模式还可以完成撤销、排队等功能。

最后说一句

如果这篇文章对您有所帮助,或者有所启发的话,帮忙点赞关注一下,您的支持是我坚持写作最大的动力,多谢支持。

同系列文章

  1. JavaScript 设计模式之单例模式
  2. JavaScript 设计模式之策略模式
  3. JavaScript 设计模式之代理模式
  4. JavaScript 设计模式之迭代器模式
  5. JavaScript 设计模式之发布-订阅模式
  6. JavaScript 设计模式之命令模式
  7. JavaScript 设计模式之组合模式
  8. JavaScript 设计模式之模板方法模式
  9. JavaScript 设计模式之享元模式
  10. JavaScript 设计模式之职责链模式
  11. JavaScript 设计模式之中介者模式
  12. JavaScript 设计模式之装饰者模式
  13. JavaScript 设计模式之状态模式
  14. JavaScript 设计模式之适配器模式