将请求封装成对象,分离命令接收者和发起者之间的耦合。
主要目的是解耦,分三个对象:

  1. 发起者:发出调用命令即可,具体如何执行、谁执行并不关心。
  2. 接受者:有对应的接口处理不同的命令,至于命令是什么、谁发出的、不关心。
  3. 命令对象:接受发起者的调用,然后调用接受者的接口。

行为型模式:命令模式

命令模式 (Command Pattern)又称事务模式,将请求封装成对象,将命令的发送者和接受者解耦。本质上是对方法调用的封装
通过封装方法调用,也可以做一些有意思的事,例如记录日志,或者重复使用这些封装来实现撤销(undo)、重做(redo)操作。

注意: 本文可能用到一些 ES6 的语法 let/constClass 等,如果还没接触过可以点击链接稍加学习 ~

1. 你曾见过的命令模式

某日,著名门派蛋黄派于江湖互联网发布江湖通缉令一张「通缉偷电瓶车贼窃格瓦拉,抓捕归案奖鸭蛋 10 个」。对于通缉令发送者蛋黄派来说,不需向某个特定单位通知通缉令,而通缉令发布之后,蛋黄派也不用管是谁来完成这个通缉令,也就是说,通缉令的发送者和接受者之间被解耦了。
大学宿舍的时候,室友们都上床了,没人起来关灯,不知道有谁提了一句「谁起来把灯关一下」,此时比的是谁装睡装得像,如果沉不住气,就要做命令的执行者,去关灯了。
命令模式 - 图1
比较经典的例子是餐馆订餐,客人需要向厨师发送请求,但是不知道这些厨师的联系方式,也不知道厨师炒菜的流程和步骤,一般是将客人订餐的请求封装成命令对象,也就是订单。这个订单对象可以在程序中被四处传递,就像订单可以被服务员传递到某个厨师手中,客人不需要知道是哪个厨师完成自己的订单,厨师也不需要知道是哪个客户的订单。
在类似场景中,这些例子有以下特点:

  1. 命令的发送者和接收者解耦,发送者与接收者之间没有直接引用关系,发送请求的对象只需要知道如何发送请求,而不必知道如何完成请求;
  2. 对命令还可以进行撤销、排队等操作,比如用户等太久不想等了撤销订单,厨师不够了将订单进行排队,等等操作;

2. 实例的代码实现

为了方便演示命令的撤销和重做,下面使用 JavaScript 来实现对超级玛丽的操控 🤣。

2.1 马里奥的操控实现

首先我们新建一个移动对象类,在以后的代码中是通用的:

  1. var canvas = document.getElementById('my-canvas')
  2. var CanvasWidth = 400 // 画布宽度
  3. var CanvasHeight = 400 // 画布高度
  4. var CanvasStep = 40 // 动作步长
  5. canvas.width = CanvasWidth
  6. canvas.height = CanvasHeight
  7. // 移动对象类
  8. var Role = function(x, y, imgSrc) {
  9. this.position = { x, y }
  10. this.canvas = document.getElementById('my-canvas')
  11. this.ctx = this.canvas.getContext('2d')
  12. this.img = new Image()
  13. this.img.style.width = CanvasStep
  14. this.img.style.height = CanvasStep
  15. this.img.src = imgSrc
  16. this.img.onload = () => {
  17. this.ctx.drawImage(this.img, x, y, CanvasStep, CanvasStep)
  18. this.move(0, 0)
  19. }
  20. }
  21. Role.prototype.move = function(x, y) {
  22. var pos = this.position
  23. this.ctx.clearRect(pos.x, pos.y, CanvasStep, CanvasStep)
  24. pos.x += x
  25. pos.y += y
  26. this.ctx.drawImage(this.img, pos.x, pos.y, CanvasStep, CanvasStep)
  27. }

下面如果要实现操控超级玛丽,可以直接:

  1. var mario = new Role(200, 200, 'https://i.loli.net/2019/08/09/sqnjmxSZBdPfNtb.jpg')
  2. // 设置按钮回调
  3. var elementUp = document.getElementById('up-btn')
  4. elementUp.onclick = function() {
  5. mario.move(0, -CanvasStep)
  6. }
  7. var elementDown = document.getElementById('down-btn')
  8. elementDown.onclick = function() {
  9. mario.move(0, CanvasStep)
  10. }
  11. var elementLeft = document.getElementById('left-btn')
  12. elementLeft.onclick = function() {
  13. mario.move(-CanvasStep, 0)
  14. }
  15. var elementRight = document.getElementById('right-btn')
  16. elementRight.onclick = function() {
  17. mario.move(CanvasStep, 0)
  18. }

可以实现下面这样的效果:
命令模式 - 图2
如果要新建一个小怪兽角色,可以:

  1. var monster = new Role(160, 160, 'https://i.loli.net/2019/08/12/XCTzcdbhriLlskv.png')

代码和预览参见:Codepen - 状态模式Demo1

2.2 引入命令模式

上面的实现逻辑上没有问题,但当我们在页面上点击按钮发送操作请求时,需要向具体负责实现行为的对象发送请求操作,对应上面的例子中的 mariomonster,这些对象就是操作的接受者。也就是说,操作的发送者直接持有操作的接受者,逻辑直接暴露在页面 DOM 的事件回调中,耦合较强。如果要增加新的角色,需要对 DOM 的回调函数进行改动,如果对操作行为进行修改,对应地,也需修改 DOM 回调函数。
此时,我们可以引入命令模式,以便将操作的发送者和操作的接受者解耦。在这个例子中,我们将操作马里奥的行为包装成命令类,操作的发送者只需要持有对应的命令实例并执行,命令的内容是具体的行为逻辑。
多说无益,直接看代码(从这里之后就直接用 ES6):

  1. const canvas = document.getElementById('my-canvas')
  2. const CanvasWidth = 400 // 画布宽度
  3. const CanvasHeight = 400 // 画布高度
  4. const CanvasStep = 40 // 动作步长
  5. canvas.width = CanvasWidth
  6. canvas.height = CanvasHeight
  7. const btnUp = document.getElementById('up-btn')
  8. const btnDown = document.getElementById('down-btn')
  9. const btnLeft = document.getElementById('left-btn')
  10. const btnRight = document.getElementById('right-btn')
  11. // 移动对象类
  12. class Role {
  13. constructor(x, y, imgSrc) {
  14. this.x = x
  15. this.y = y
  16. this.canvas = document.getElementById('my-canvas')
  17. this.ctx = this.canvas.getContext('2d')
  18. this.img = new Image()
  19. this.img.style.width = CanvasStep
  20. this.img.style.height = CanvasStep
  21. this.img.src = imgSrc
  22. this.img.onload = () => {
  23. this.ctx.drawImage(this.img, x, y, CanvasStep, CanvasStep)
  24. this.move(0, 0)
  25. }
  26. }
  27. move(x, y) {
  28. this.ctx.clearRect(this.x, this.y, CanvasStep, CanvasStep)
  29. this.x += x
  30. this.y += y
  31. this.ctx.drawImage(this.img, this.x, this.y, CanvasStep, CanvasStep)
  32. }
  33. }
  34. // 向上移动命令类
  35. class MoveUpCommand {
  36. constructor(receiver) {
  37. this.receiver = receiver
  38. }
  39. execute(role) {
  40. this.receiver.move(0, -CanvasStep)
  41. }
  42. }
  43. // 向下移动命令类
  44. class MoveDownCommand {
  45. constructor(receiver) {
  46. this.receiver = receiver
  47. }
  48. execute(role) {
  49. this.receiver.move(0, CanvasStep)
  50. }
  51. }
  52. // 向左移动命令类
  53. class MoveLeftCommand {
  54. constructor(receiver) {
  55. this.receiver = receiver
  56. }
  57. execute(role) {
  58. this.receiver.move(-CanvasStep, 0)
  59. }
  60. }
  61. // 向右移动命令类
  62. class MoveRightCommand {
  63. constructor(receiver) {
  64. this.receiver = receiver
  65. }
  66. execute(role) {
  67. this.receiver.move(CanvasStep, 0)
  68. }
  69. }
  70. // 设置按钮命令
  71. const setCommand = function(element, command) {
  72. element.onclick = function() {
  73. command.execute()
  74. }
  75. }
  76. /* ----- 客户端 ----- */
  77. const mario = new Role(200, 200, 'https://i.loli.net/2019/08/09/sqnjmxSZBdPfNtb.jpg')
  78. const moveUpCommand = new MoveUpCommand(mario)
  79. const moveDownCommand = new MoveDownCommand(mario)
  80. const moveLeftCommand = new MoveLeftCommand(mario)
  81. const moveRightCommand = new MoveRightCommand(mario)
  82. setCommand(btnUp, moveUpCommand)
  83. setCommand(btnDown, moveDownCommand)
  84. setCommand(btnLeft, moveLeftCommand)
  85. setCommand(btnRight, moveRightCommand)

代码和预览参见:Codepen-状态模式Demo2
我们把操作的逻辑分别提取到对应的 Command 类中,并约定 Command 类的 execute 方法存放命令接收者需要执行的逻辑,也就是前面例子中的 onclick 回调方法部分。
按下操作按钮之后会发生事情这个逻辑是不变的,而具体发生什么事情的逻辑是可变的,这里我们可以提取出公共逻辑,把一定发生事情这个逻辑提取到 setCommand 方法中,在这里调用命令类实例的 execute 方法,而不同事情具体逻辑的不同体现在各个 execute 方法的不同实现中。
至此,命令的发送者已经知道自己将会执行一个 Command 类实例的 execute 实例方法,但是具体是哪个操作类的类实例来执行,还不得而知,这时候需要调用 setCommand 方法来告诉命令的发送者,执行的是哪个命令。
综上,一个命令模式改造后的实例就完成了,但是在 JavaScript 中,命令不一定要使用类的形式:

  1. // 前面代码一致
  2. // 向上移动命令对象
  3. const MoveUpCommand = {
  4. execute(role) {
  5. role.move(0, -CanvasStep)
  6. }
  7. }
  8. // 向下移动命令对象
  9. const MoveDownCommand = {
  10. execute(role) {
  11. role.move(0, CanvasStep)
  12. }
  13. }
  14. // 向左移动命令对象
  15. const MoveLeftCommand = {
  16. execute(role) {
  17. role.move(-CanvasStep, 0)
  18. }
  19. }
  20. // 向右移动命令对象
  21. const MoveRightCommand = {
  22. execute(role) {
  23. role.move(CanvasStep, 0)
  24. }
  25. }
  26. // 设置按钮命令
  27. const setCommand = function(element, role, command) {
  28. element.onclick = function() {
  29. command.execute(role)
  30. }
  31. }
  32. /* ----- 客户端 ----- */
  33. const mario = new Role(200, 200, 'https://i.loli.net/2019/08/09/sqnjmxSZBdPfNtb.jpg')
  34. setCommand(btnUp, mario, MoveUpCommand)
  35. setCommand(btnDown, mario, MoveDownCommand)
  36. setCommand(btnLeft, mario, MoveLeftCommand)
  37. setCommand(btnRight, mario, MoveRightCommand)

代码和预览参见:Codepen-状态模式Demo3

2.3 命令模式升级

可以对这个项目进行升级,记录这个角色的行动历史,并且提供一个 redoundo 按钮,撤销和重做角色的操作,可以想象一下如果不使用命令模式,记录的 Log 将比较乱,也不容易进行操作撤销和重做。
下面我们可以使用命令模式来对上面马里奥的例子进行重构,有下面几个要点:

  1. 命令对象包含有 execute 方法和 undo 方法,前者是执行和重做时执行的方法,后者是撤销时执行的反方法;
  2. 每次执行操作时将当前操作命令推入撤销命令栈,并将当前重做命令栈清空;
  3. 撤销操作时,将撤销命令栈中最后推入的命令取出并执行其 undo 方法,且将该命令推入重做命令栈;
  4. 重做命令时,将重做命令栈中最后推入的命令取出并执行其 execute 方法,且将其推入撤销命令栈;

    1. // 向上移动命令对象
    2. const MoveUpCommand = {
    3. execute(role) {
    4. role.move(0, -CanvasStep)
    5. },
    6. undo(role) {
    7. role.move(0, CanvasStep)
    8. }
    9. }
    10. // 向下移动命令对象
    11. const MoveDownCommand = {
    12. execute(role) {
    13. role.move(0, CanvasStep)
    14. },
    15. undo(role) {
    16. role.move(0, -CanvasStep)
    17. }
    18. }
    19. // 向左移动命令对象
    20. const MoveLeftCommand = {
    21. execute(role) {
    22. role.move(-CanvasStep, 0)
    23. },
    24. undo(role) {
    25. role.move(CanvasStep, 0)
    26. }
    27. }
    28. // 向右移动命令对象
    29. const MoveRightCommand = {
    30. execute(role) {
    31. role.move(CanvasStep, 0)
    32. },
    33. undo(role) {
    34. role.move(-CanvasStep, 0)
    35. }
    36. }
    37. // 命令管理者
    38. const CommandManager = {
    39. undoStack: [], // 撤销命令栈
    40. redoStack: [], // 重做命令栈
    41. executeCommand(role, command) {
    42. this.redoStack.length = 0 // 每次执行清空重做命令栈
    43. this.undoStack.push(command) // 推入撤销命令栈
    44. command.execute(role)
    45. },
    46. /* 撤销 */
    47. undo(role) {
    48. if (this.undoStack.length === 0) return
    49. const lastCommand = this.undoStack.pop()
    50. lastCommand.undo(role)
    51. this.redoStack.push(lastCommand) // 放入redo栈中
    52. },
    53. /* 重做 */
    54. redo(role) {
    55. if (this.redoStack.length === 0) return
    56. const lastCommand = this.redoStack.pop()
    57. lastCommand.execute(role)
    58. this.undoStack.push(lastCommand) // 放入undo栈中
    59. }
    60. }
    61. // 设置按钮命令
    62. const setCommand = function(element, role, command) {
    63. if (typeof command === 'object') {
    64. element.onclick = function() {
    65. CommandManager.executeCommand(role, command)
    66. }
    67. } else {
    68. element.onclick = function() {
    69. command.call(CommandManager, role)
    70. }
    71. }
    72. }
    73. /* ----- 客户端 ----- */
    74. const mario = new Role(200, 200, 'https://i.loli.net/2019/08/09/sqnjmxSZBdPfNtb.jpg')
    75. setCommand(btnUp, mario, MoveUpCommand)
    76. setCommand(btnDown, mario, MoveDownCommand)
    77. setCommand(btnLeft, mario, MoveLeftCommand)
    78. setCommand(btnRight, mario, MoveRightCommand)
    79. setCommand(btnUndo, mario, CommandManager.undo)
    80. setCommand(btnRedo, mario, CommandManager.redo)

    代码和预览参见:Codepen-状态模式Demo4
    我们可以给马里奥画一个蘑菇 ,当马里奥走到蘑菇上面的时候提示「挑战成功!」
    代码实现就不贴了,可以看看下面的实现链接。效果如下:
    命令模式 - 图3
    代码和预览参见:Codepen-状态模式Demo5
    有了撤销和重做命令之后,做一些小游戏比如围棋、象棋,会很容易就实现悔棋、复盘等功能。

3. 命令模式的优缺点

命令模式的优点:

  1. 命令模式将调用命令的请求对象与执行该命令的接收对象解耦,因此系统的可扩展性良好,加入新的命令不影响原有逻辑,所以增加新的命令也很容易;
  2. 命令对象可以被不同的请求者角色重用,方便复用;
  3. 可以将命令记入日志,根据日志可以容易地实现对命令的撤销和重做;

命令模式的缺点:命令类或者命令对象随着命令的变多而膨胀,如果命令对象很多,那么使用者需要谨慎使用,以免带来不必要的系统复杂度。

4. 命令模式的使用场景

  1. 需要将请求调用者和请求的接收者解耦的时候;
  2. 需要将请求排队、记录请求日志、撤销或重做操作时;

5. 其他相关模式

5.1 命令模式与职责链模式

命令模式和职责链模式可以结合使用,比如具体命令的执行,就可以引入职责链模式,让命令由职责链中合适的处理者执行。

5.2 命令模式与组合模式

命令模式和组合模式可以结合使用,比如不同的命令可以使用组合模式的方法形成一个宏命令,执行完一个命令之后,再继续执行其子命令。

5.3 命令模式与工厂模式

命令模式与工厂模式可以结合使用,比如命令模式中的命令可以由工厂模式来提供。