zhangdizhangdi

命令模式 📖

定义

命令模式(Command Pattern) 的核心目的则是 “将‘请求/动作’本身物质化(变成一个对象)”。一旦动作变成了对象,你就可以对它进行 保存、排队、撤销(Undo)、重做(Redo)

餐厅点餐:你去餐厅吃饭(Client 客户端),把你想吃的菜告诉服务员(Invoker 调用者)。服务员不会做菜,他只负责把你的要求写在点菜单(Command 命令对象)上,然后把单子扔到后厨。厨师(Receiver 接收者)看到单子后开始做菜。

痛点:在 UI 交互中,“触发动作的按钮”和“真正执行动作的业务逻辑”往往紧紧耦合在一起。更要命的是,如果产品经理要求实现 “撤销(Ctrl+Z)”“重做(Ctrl+Y)” 功能,传统的函数调用一执行完就没影了,根本无迹可寻。

解决:把每一次操作都包成一个对象(有 execute 执行和 undo 撤销两个方法)。按钮只负责触发对象的 execute,系统把这个对象存进一个历史数组里。想撤销时,从数组里拿出上一个对象,调用它的 undo 即可。

与设计原则关系

  1. 极端的解耦(调用者与接收者隔离)
    • 发出请求的对象(按钮)和执行请求的对象(业务逻辑)完全解耦。按钮不需要知道到底是谁、用什么方式处理了这个请求。
  2. 开闭原则 (Open-Closed Principle)
    • 你可以轻松地引入新的命令(新增一种菜单或遥控器按键),而无需修改现有的调用者代码。

对比

🥊 对比:命令模式 vs 策略模式

  • 策略模式:关注的是 “怎么做(How)” 。目的是为了替换算法(比如都为了到达目的地,选汽车还是飞机)。
  • 命令模式:关注的是 “做什么(What)” 。目的是把行为记录下来,延迟执行或撤销(比如命令是“开灯”、“播放音乐”)。

实现

我们以一个前端极其常见的高级需求—— “富文本/图形编辑器中的撤销与重做” 为例。

javascript
// 1. 接收者 (Receiver):真正干活的业务逻辑
class Editor {
  constructor() {
    this.content = '';
  }
  type(text) { this.content += text; }
  delete(length) { this.content = this.content.slice(0, -length); }
  getContent() { return this.content; }
}

// 2. 命令接口/基类 (Command):规定必须有 execute 和 undo
class Command {
  execute() {}
  undo() {}
}

// 3. 具体命令 (Concrete Command)
// “输入文字”命令
class TypeCommand extends Command {
  constructor(editor, text) {
    super();
    this.editor = editor;
    this.text = text;
  }
  execute() {
    this.editor.type(this.text);
  }
  undo() {
    // 撤销输入的逻辑:删除刚才输入的长度
    this.editor.delete(this.text.length);
  }
}

// 4. 调用者 / 命令管理器 (Invoker):负责调度和记录历史
class CommandManager {
  constructor() {
    this.history = []; // 历史记录栈(用于撤销)
    this.redoStack =[]; // 重做栈
  }

  // 执行命令
  executeCommand(command) {
    command.execute();
    this.history.push(command); // 关键:把命令存起来!
    this.redoStack =[]; // 一旦有新操作,清空重做栈
  }

  // 撤销 (Ctrl+Z)
  undo() {
    if (this.history.length === 0) return;
    const command = this.history.pop();
    command.undo(); // 调用命令自身的撤销逻辑
    this.redoStack.push(command);
  }

  // 重做 (Ctrl+Y)
  redo() {
    if (this.redoStack.length === 0) return;
    const command = this.redoStack.pop();
    command.execute(); // 再次执行
    this.history.push(command);
  }
}

// ================= 使用测试 =================
const editor = new Editor();
const manager = new CommandManager();

console.log("初始内容:", editor.getContent()); // ""

// 执行打字命令
manager.executeCommand(new TypeCommand(editor, "Hello "));
manager.executeCommand(new TypeCommand(editor, "World!"));
console.log("输入后:", editor.getContent()); // "Hello World!"

// 撤销一次
manager.undo();
console.log("撤销一次:", editor.getContent()); // "Hello "

// 撤销两次
manager.undo();
console.log("撤销两次:", editor.getContent()); // ""

// 重做一次
manager.redo();
console.log("重做一次:", editor.getContent()); // "Hello "

💡 思考:如果没有命令模式,你怎么用普通的函数调用实现如此清晰的撤销/重做流?几乎不可能,代码会变成一团乱麻。

现代 JS 的闭包变体

在 JavaScript 中,函数是一等公民。如果你的命令不需要复杂的撤销功能(只需要延迟执行或解耦),你完全不需要写繁琐的 Class。利用闭包或者高阶函数就可以轻松实现“轻量级命令模式”:

javascript
// 用闭包包装一个命令
const createLogCommand = (receiver, message) => {
  // 返回的这个函数就是一个命令对象(闭包记住了环境)
  return function execute() {
    receiver.log(message);
  };
};

const myLogger = console;
const cmd1 = createLogCommand(myLogger, "系统启动完毕");
const cmd2 = createLogCommand(myLogger, "发生未知错误");

// 将命令排队或延迟执行
const queue = [cmd1, cmd2];
setTimeout(() => {
  queue.forEach(cmd => cmd()); // 遍历执行
}, 1000);

前端应用

1. 状态管理库:Redux / Vuex (Pinia)

Redux 是命令模式在前端架构上最著名、最极致的应用。

  • Action 对象:本质上就是命令对象(Command)。比如 { type: 'ADD_TODO', payload: '学习命令模式' }
  • Dispatch 方法:充当了调用者(Invoker)。你不用直接去改状态,而是通过 dispatch(action) 发出命令。
  • Reducer:充当了接收者(Receiver),接收到命令后真正修改 Store 中的数据。

正是因为 Redux 将每一个改变状态的操作都封装成了 Action 对象,所以 Redux DevTools 才能轻松实现牛逼的“时间旅行(Time Travel / Undo / Redo)”功能。

2. Canvas 图形编辑器 / Figma 网页版

在前端实现画板应用时,用户画了一条线、拖动了一个矩形、改变了颜色。
这些操作绝不是直接修改 Canvas 的像素,而是生成一个 DrawLineCommandMoveRectCommand

这带来了三大好处:

  1. 无限撤销/重做(将 Command 压栈和出栈)。
  2. 录像与回放(把操作数组存进数据库,下次拿出来 foreach 挨个 execute)。
  3. 多人协同操作(本地不直接渲染,而是把 Command 序列化发给服务器,服务器广播给其他终端)。

3. 宏命令 (Macro Command / 批量执行)

你可以把多个小命令组合成一个“大命令”。
比如在智能家居 UI 面板中,有一个“离家模式”按钮。
当点击时,它执行的是一个 MacroCommand,这个宏命令对象内部包含一个数组 [TurnOffLightCmd, TurnOffTvcCmd, LockDoorCmd],执行宏命令的 execute 就会遍历执行内部所有的子命令。

4. API 请求队列调度

当用户处于弱网/离线状态时(或者请求并发量过大需要控制时),你可以把发请求的操作封装成 Command 对象,塞进一个请求队列(Queue)中缓存起来。
等网络恢复或有空闲通道时,再从队列里取出来执行。这种设计在前端离线应用(PWA)或日志埋点上报系统中非常常见。

总结

当你面临以下需求时,请毫不犹豫地使用命令模式:

  1. 需要实现撤销(Undo)、重做(Redo)、时间旅行功能。
  2. 需要将用户的操作记录下来、保存到数据库、或者排队延迟执行。
  3. 需要极其严格地切断“UI 触发层”和“底层核心逻辑层”的联系,实现“请求发送者与接收者解耦”。