讲解

餐厅点菜:顾客不直接冲进厨房炒菜,而是把需求写在点菜单上交给服务员。这张单子是个「对象」:它可以排队(后厨按序处理)、可以转交、可以存档(查账)、可以作废(退菜)。命令模式就是把一个请求封装成对象——「做什么」和「谁来做、何时做」因此解耦,请求从此可以像数据一样被排队、记录、撤销。

GoF 的定义:将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。结构四角色:Command 接口(execute,可逆场景加 undo)、ConcreteCommand(持有接收者,execute 里调用接收者的具体方法)、Receiver(真正干活的,比如文档、灯、订单服务)、Invoker(持有并触发命令,比如按钮、快捷键、宏)。

命令模式最出名的应用是 undo/redo:每个命令既知道怎么执行、也知道怎么撤销(插入三个字的逆操作是删掉三个字),编辑器维护一个已执行命令的栈,撤销 = 弹出栈顶命令调 undo(),重做 = 再调 execute()。没有命令对象,撤销就得靠「每次操作前存整个文档的快照」(那是备忘录模式的思路,后面一章会讲),内存开销天差地别。

命令还是很多基础设施的骨架:任务队列里排的一个个 Job 是命令;宏命令(composite command)把多个命令组合成一个;操作审计日志把命令序列化存档,回放日志即可重建状态——事件溯源(event sourcing)的本质就是「命令的持久化日志」。

示例

一个支持撤销/重做的迷你文本编辑器:插入操作封装成命令,编辑器维护 done/undone 两个栈:

import assert from 'node:assert/strict';

// 命令接口:可执行,也可撤销
interface Command {
  execute(): void;
  undo(): void;
  describe(): string;
}

// 接收者:真正持有状态的对象
class TextDocument {
  private text = '';

  insert(content: string): void {
    this.text += content;
  }

  remove(count: number): void {
    this.text = this.text.slice(0, this.text.length - count);
  }

  value(): string {
    return this.text;
  }
}

// 具体命令:把「插入一段文字」连同它的逆操作一起封装
class InsertCommand implements Command {
  constructor(
    private readonly doc: TextDocument,
    private readonly content: string,
  ) {}

  execute(): void {
    this.doc.insert(this.content);
  }

  undo(): void {
    this.doc.remove(this.content.length);
  }

  describe(): string {
    return '插入「' + this.content + '」';
  }
}

// 调用者:触发命令,维护历史栈
class Editor {
  private readonly done: Command[] = [];
  private readonly undone: Command[] = [];

  run(command: Command): void {
    command.execute();
    this.done.push(command);
    this.undone.length = 0; // 新操作清空重做栈
  }

  undo(): string {
    const command = this.done.pop();
    if (command === undefined) return '无可撤销';
    command.undo();
    this.undone.push(command);
    return '撤销:' + command.describe();
  }

  redo(): string {
    const command = this.undone.pop();
    if (command === undefined) return '无可重做';
    command.execute();
    this.done.push(command);
    return '重做:' + command.describe();
  }
}

const doc = new TextDocument();
const editor = new Editor();

editor.run(new InsertCommand(doc, '你好'));
editor.run(new InsertCommand(doc, ',世界'));
assert.equal(doc.value(), '你好,世界');

assert.equal(editor.undo(), '撤销:插入「,世界」');
assert.equal(doc.value(), '你好');

assert.equal(editor.redo(), '重做:插入「,世界」');
assert.equal(doc.value(), '你好,世界');

console.log('当前文本:' + doc.value());

三个细节值得记住:命令对象自己携带逆操作所需的全部信息(插入了什么、多长),撤销时不需要问编辑器;「新操作清空重做栈」是几乎所有编辑器的标准行为,漏掉它用户会 redo 出幽灵操作;Editor 完全不认识 TextDocument 的方法——它只调度命令,这就是发送者与接收者的解耦。

常见坑

  • undo 靠「反向推理」而非现场数据:比如删除命令的 undo 想重新查出被删内容,而不是在 execute 时把内容存进命令对象——逆操作需要的一切都要在命令里自包含。
  • 新操作后忘记清空重做栈:undo 两次、再敲一个新字、然后 redo——理应无可重做,不清空的话会重放出早已作废的历史。
  • 命令里直接写死接收者:命令通过构造函数接收 receiver 才能复用(同一个 InsertCommand 类服务多个文档实例)。
  • 给不可撤销的操作硬套 undo:「发送邮件」没法撤销,硬实现的 undo 是假象;这类命令要么明确标记不可撤销,要么用补偿事务的思路另行设计。
  • 把命令当成万能胶水:简单的「按钮点击调一个方法」场景,直接回调就够了;命令的价值出现在需要排队/记录/撤销时。

小结

命令把请求对象化,execute/undo 自包含,换来排队、日志、宏和 undo/redo 能力;它是编辑器和事件溯源的骨架。下一章看一个已经被语言「收编」的模式:迭代器。