命令模式:把请求装进盒子里

命令模式,说白了就是「把要做的事情打包成一个对象」。

我刚开始接触这个模式时,觉得有点绕——好好的方法调用不用,干嘛非要包一层?直到我在一个项目中接手了一个需要支持撤销功能的编辑器,才真正体会到它的威力。

为什么需要命令模式?

你想想看,在传统代码里,调用方直接执行某个操作:

button.onClick = () => {
    document.execCommand('bold');
};

这样写有什么问题?

  • 操作无法被记录——你没法知道用户刚才做了什么
  • 操作无法被撤销——没有「反操作」的概念
  • 操作无法被延迟执行——点击的瞬间就必须执行
  • 操作和调用方紧耦合——换一种触发方式就得改代码

命令模式就是来解决这些问题的。

核心思想

把「做什么」和「怎么做」拆开。命令对象只负责描述「我要做什么」,而具体的执行逻辑由接收者(Receiver)来完成。

命令模式三要素:

  • 命令(Command):封装了请求的对象,包含执行方法
  • 接收者(Receiver):真正干活的对象
  • 调用者(Invoker):触发命令执行的角色

一个简单的例子

假设我们有一个文本编辑器,需要支持「加粗」和「撤销加粗」。

// 命令接口
interface Command {
    execute(): void;
    undo(): void;
}

// 接收者——真正处理文本的对象
class TextEditor {
    private text: string = '';

    bold() {
        this.text = `**${this.text}**`;
        console.log('文本已加粗');
    }

    unBold() {
        this.text = this.text.replace(/\*\*/g, '');
        console.log('加粗已撤销');
    }
}

// 具体命令
class BoldCommand implements Command {
    private editor: TextEditor;

    constructor(editor: TextEditor) {
        this.editor = editor;
    }

    execute() {
        this.editor.bold();
    }

    undo() {
        this.editor.unBold();
    }
}

// 调用者——按钮
class Button {
    private command: Command;

    setCommand(cmd: Command) {
        this.command = cmd;
    }

    click() {
        this.command.execute();
    }
}

嗯,这里要注意:命令对象里持有接收者的引用。这样当调用 undo 时,它知道该找谁去执行反操作。

撤销与重做——命令模式的杀手锏

我在项目中遇到过最典型的场景就是撤销栈。用户每执行一个操作,就把对应的命令对象压入栈中。撤销时从栈顶弹出,调用它的 undo 方法。

class CommandHistory {
    private history: Command[] = [];
    private index: number = -1;

    executeCommand(cmd: Command) {
        cmd.execute();
        // 清除当前索引之后的命令(如果有重做的话)
        this.history.length = this.index + 1;
        this.history.push(cmd);
        this.index++;
    }

    undo() {
        if (this.index < 0) return;
        const cmd = this.history[this.index];
        cmd.undo();
        this.index--;
    }

    redo() {
        if (this.index >= this.history.length - 1) return;
        this.index++;
        const cmd = this.history[this.index];
        cmd.execute();
    }
}

我曾经踩过一个坑:撤销栈里存的是同一个命令对象的引用,结果 undo 时状态全乱了。后来我改成每次执行都 new 一个新的命令实例,问题就解决了。你想想看,如果多个操作共享同一个命令对象,undo 时根本不知道要回退到哪个状态。

队列化与日志化

命令模式还有一个很实用的场景:把命令序列化后存到队列里,或者写入日志文件。

比如一个游戏系统,玩家的每个操作都封装成命令:

class MoveCommand implements Command {
    private player: Player;
    private x: number;
    private y: number;

    constructor(player: Player, x: number, y: number) {
        this.player = player;
        this.x = x;
        this.y = y;
    }

    execute() {
        this.player.moveTo(this.x, this.y);
    }

    undo() {
        this.player.moveTo(-this.x, -this.y);
    }

    // 序列化——把命令转成可存储的格式
    serialize(): string {
        return JSON.stringify({
            type: 'MOVE',
            x: this.x,
            y: this.y
        });
    }
}

这样,我们可以把命令序列化后写入日志文件。如果系统崩溃,重启后读取日志,反序列化出命令对象,重新执行一遍——这就是所谓的「命令日志化恢复」。我在做金融交易系统时就用过这个方案,确保每笔交易都能被完整回放。

命令模式的结构图

下面这张图展示了命令模式的核心结构:

调用者 (Invoker) 持有命令引用 命令接口 +execute() +undo() 具体命令 实现 execute/undo 接收者 (Receiver) 真正干活的对象 调用者不直接操作接收者 而是通过命令对象间接调用 这样就能实现撤销、队列、日志等功能

什么时候用命令模式?

我个人习惯在以下几种场景优先考虑命令模式:

  • 需要撤销/重做——比如编辑器、绘图工具、游戏操作
  • 需要操作队列——比如任务调度、批量处理
  • 需要操作日志——比如审计、故障恢复
  • 需要参数化调用——比如菜单项、工具栏按钮,每个按钮绑定不同的命令

小技巧:命令对象可以做得非常轻量。如果命令只是转发调用,可以用函数式接口代替。但在需要撤销的场景下,还是老老实实写类吧。

注意:命令模式会增加类的数量。每个操作都要对应一个命令类,如果操作很多,类会膨胀得很快。我曾经在一个项目中定义了 50 多个命令类,管理起来确实有点头疼。建议配合工厂模式一起使用。

命令模式 vs 策略模式

这两个模式长得有点像,但意图完全不同:

对比维度 命令模式 策略模式
核心意图 封装请求,支持撤销/队列 封装算法,支持替换
状态管理 需要保存状态用于撤销 通常无状态
典型场景 撤销栈、事务日志 排序算法、支付方式

总结

命令模式的核心价值,就是把「请求」这个行为提升为一等公民。你可以把它存起来、传参、排队、甚至写到磁盘里。我个人觉得,它是设计模式里「抽象层次最高」的几个模式之一——因为它把「动作」本身变成了「对象」。

嗯,最后提醒一句:不要为了用模式而用模式。如果你的系统只需要一个按钮点一下执行某个操作,永远不需要撤销、不需要日志、不需要队列——那直接写个函数调用就完事了。命令模式是为「需要管理操作」的场景准备的。


公众号:蓝海资料掘金营,微信deep3321