命令模式:把请求装进盒子里
命令模式,说白了就是「把要做的事情打包成一个对象」。
我刚开始接触这个模式时,觉得有点绕——好好的方法调用不用,干嘛非要包一层?直到我在一个项目中接手了一个需要支持撤销功能的编辑器,才真正体会到它的威力。
为什么需要命令模式?
你想想看,在传统代码里,调用方直接执行某个操作:
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
});
}
}
这样,我们可以把命令序列化后写入日志文件。如果系统崩溃,重启后读取日志,反序列化出命令对象,重新执行一遍——这就是所谓的「命令日志化恢复」。我在做金融交易系统时就用过这个方案,确保每笔交易都能被完整回放。
命令模式的结构图
下面这张图展示了命令模式的核心结构:
什么时候用命令模式?
我个人习惯在以下几种场景优先考虑命令模式:
- 需要撤销/重做——比如编辑器、绘图工具、游戏操作
- 需要操作队列——比如任务调度、批量处理
- 需要操作日志——比如审计、故障恢复
- 需要参数化调用——比如菜单项、工具栏按钮,每个按钮绑定不同的命令
小技巧:命令对象可以做得非常轻量。如果命令只是转发调用,可以用函数式接口代替。但在需要撤销的场景下,还是老老实实写类吧。
注意:命令模式会增加类的数量。每个操作都要对应一个命令类,如果操作很多,类会膨胀得很快。我曾经在一个项目中定义了 50 多个命令类,管理起来确实有点头疼。建议配合工厂模式一起使用。
命令模式 vs 策略模式
这两个模式长得有点像,但意图完全不同:
| 对比维度 | 命令模式 | 策略模式 |
|---|---|---|
| 核心意图 | 封装请求,支持撤销/队列 | 封装算法,支持替换 |
| 状态管理 | 需要保存状态用于撤销 | 通常无状态 |
| 典型场景 | 撤销栈、事务日志 | 排序算法、支付方式 |
总结
命令模式的核心价值,就是把「请求」这个行为提升为一等公民。你可以把它存起来、传参、排队、甚至写到磁盘里。我个人觉得,它是设计模式里「抽象层次最高」的几个模式之一——因为它把「动作」本身变成了「对象」。
嗯,最后提醒一句:不要为了用模式而用模式。如果你的系统只需要一个按钮点一下执行某个操作,永远不需要撤销、不需要日志、不需要队列——那直接写个函数调用就完事了。命令模式是为「需要管理操作」的场景准备的。
公众号:蓝海资料掘金营,微信deep3321