mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3386 字
9 分钟
命令模式(Command Pattern)
2026-06-13

一、为什么需要命令模式#

你正在写一个文本编辑器。用户点击「加粗」,你直接调用 document.boldSelection();点击「插入图片」,你直接调用 document.insertImage(path);点击「删除行」,你直接调用 document.deleteLine()。每个操作都是 UI 控件直接调用业务方法,看起来没什么问题。

直到产品经理说:「我要撤销。」

你开始给每个操作写对应的反向操作:加粗的撤销是取消加粗,插入图片的撤销是删除图片,删除行的撤销是……等等,删除的行存哪?你发现每个操作都需要记住「之前是什么」,才能撤销。于是每个按钮的点击处理里塞满了状态保存和恢复逻辑,代码越来越乱。

更糟糕的是,产品经理又来了:「我要宏录制——用户点一系列操作,然后一键回放。」这下你彻底懵了。UI 控件直接调用业务方法,操作没有被记录下来,怎么回放?

问题的根源是:请求没有被封装。UI 控件(调用者)和业务对象(接收者)之间是直接耦合的。调用者知道接收者的方法签名,知道参数是什么,但不知道怎么撤销,也没法把操作存下来延迟执行或排队。

命令模式把请求封装成对象。每个操作是一个命令对象,包含执行所需的一切——接收者引用、参数、甚至撤销逻辑。调用者只需要调用 command.Execute(),不需要知道接收者是谁、方法是什么。命令对象可以存储、排队、记录日志、序列化——因为它是对象,不是方法调用。

二、现实类比#

餐厅点餐:顾客(调用者)在点餐单上写下「一份宫保鸡丁,微辣」,服务员把点餐单送到厨房。厨师(接收者)看到点餐单,按单做菜。顾客不需要知道厨师叫什么、灶台在哪,只需要在单子上写菜名。点餐单就是命令对象——它封装了「做什么菜」和「口味要求」,可以在服务员手里排队,也可以被撤回(「那个菜不要了」)。

遥控器是另一个经典类比:每个按钮绑定一个命令对象,按下按钮就是 command.Execute()。同一个按钮可以绑定不同设备的命令——今天控制电视,明天控制空调。按钮不关心命令发给谁,它只负责触发。

三、核心思想#

命令模式的核心是三层解耦:调用者(Invoker)只依赖命令接口,命令(Command)绑定接收者(Receiver)和动作,接收者执行实际工作。

classDiagram class Invoker { -command: Command -history: Command[] +SetCommand(cmd: Command) +ExecuteCommand() +UndoCommand() } class Command { <<interface>> +Execute() +Undo() } class ConcreteCommand { -receiver: Receiver -state: any +Execute() +Undo() } class Receiver { +Action() } Invoker --> Command : 存储/触发 ConcreteCommand ..|> Command : 实现 ConcreteCommand --> Receiver : 绑定

3.1 命令接口与具体命令#

命令接口定义两个方法:Execute() 执行操作,Undo() 撤销操作。具体命令持有接收者引用和执行所需的状态(参数、撤销快照等)。

// Command 是命令接口
type Command interface {
Execute()
Undo()
}

具体命令的关键在于:它把「调用哪个对象的哪个方法」和「传什么参数」全部固化在自身内部。调用者拿到命令对象后,只需要调 Execute(),完全不需要知道背后发生了什么。

3.2 调用者与命令栈#

调用者(Invoker)存储命令对象并触发执行。更关键的是,调用者维护一个命令栈用于撤销——每次执行命令后压栈,撤销时弹栈并调用 Undo()

// Invoker 管理命令的执行和撤销
type Invoker struct {
history []Command // 已执行命令的栈
}
func (inv *Invoker) Execute(cmd Command) {
cmd.Execute()
inv.history = append(inv.history, cmd)
}
func (inv *Invoker) Undo() {
if len(inv.history) == 0 {
return
}
cmd := inv.history[len(inv.history)-1]
inv.history = inv.history[:len(inv.history)-1]
cmd.Undo()
}

撤销栈是后进先出(LIFO)的——最后执行的操作最先撤销,这和用户的直觉一致。重做(Redo)则需要在撤销时把命令压入另一个栈,重做时从重做栈弹出执行。

3.3 宏命令#

宏命令是命令的组合:一个宏命令包含多个子命令,Execute() 依次执行所有子命令,Undo() 逆序撤销所有子命令。这就是宏录制的实现基础——用户操作时记录每个命令,回放时把命令序列包装成宏命令一次性执行。

// MacroCommand 宏命令——组合多个子命令
type MacroCommand struct {
commands []Command
}
func (mc *MacroCommand) Execute() {
for _, cmd := range mc.commands {
cmd.Execute()
}
}
func (mc *MacroCommand) Undo() {
// 逆序撤销,保证状态正确恢复
for i := len(mc.commands) - 1; i >= 0; i-- {
mc.commands[i].Undo()
}
}

3.4 复杂度分析#

操作时间复杂度说明
ExecuteO(1)直接调用接收者方法
UndoO(1)从栈顶弹出并调用撤销
宏命令 ExecuteO(n)n 为子命令数量
宏命令 UndoO(n)逆序撤销 n 个子命令

空间开销主要来自命令栈——每个已执行的命令对象都需要保留,直到被丢弃或栈被清空。对于长时间运行的编辑器,通常需要设置栈深度上限,超出时丢弃最早的命令。

四、变体与对比#

模式与命令的关系核心区别
策略两者都封装行为命令关注「何时做什么」,策略关注「怎么做」
事件两者都表示发生的事情事件是单向通知,命令隐含动作且通常期望响应
状态机命令可以触发状态转移状态机按状态转移,命令按请求封装
迭代器两者都封装访问模式迭代器封装遍历,命令封装操作

4.1 命令 vs 策略:请求对象 vs 算法对象#

命令和策略都把行为封装成对象,但意图不同。策略模式是「用不同的算法解决同一个问题」——排序策略、压缩策略、渲染策略。调用者知道自己在做什么,只是想换一种实现。命令模式是「把请求本身变成对象」——调用者可能根本不知道请求会被谁处理、什么时候处理。命令可以被存储、排队、撤销,策略通常不会。

// 策略:同一个操作的不同实现,调用者知道自己在排序
interface SortStrategy {
sort(data: number[]): number[];
}
// 命令:请求被封装成对象,调用者不知道也不关心背后是什么
interface Command {
execute(): void;
undo(): void;
}

一个简单的判断标准:如果对象需要 Undo(),它大概率是命令而不是策略。策略没有「撤销」的概念——你不会「撤销一次快速排序」。

4.2 命令 vs 事件:动作 vs 通知#

命令和事件都表示「发生了某件事」,但语义不同。命令是「请做这件事」——发送者期望接收者执行动作,命令有明确的意图和方向。事件是「这件事发生了」——发送者不关心谁在听,甚至可能没人听。

维度命令事件
方向点对点,发送者知道接收者发布-订阅,发送者不知道接收者
期望发送者期望动作被执行发送者只是通知,不期望特定响应
失败发送者需要处理失败事件丢失通常不影响发送者
撤销命令天然支持撤销事件没有撤销的概念

4.3 命令队列与命令总线#

命令对象可以放入队列,由工作线程依次取出执行——这就是命令队列。它解耦了命令的发起和执行时机:UI 线程提交命令,后台线程执行命令。数据库的写操作队列、消息中间件的任务队列,都是命令队列的应用。

命令总线(Command Bus)是命令队列的进阶形式,常见于 CQRS 架构。命令总线负责把命令路由到对应的处理器(Handler),命令对象只描述「做什么」,处理器决定「怎么做」。这种分离让命令的发送方和执行方完全解耦。

// CommandBus 命令总线——路由命令到处理器
type CommandBus struct {
handlers map[reflect.Type]CommandHandler
}
type CommandHandler interface {
Handle(cmd Command) error
}
func (bus *CommandBus) Dispatch(cmd Command) error {
handler, ok := bus.handlers[reflect.TypeOf(cmd)]
if !ok {
return fmt.Errorf("no handler for %T", cmd)
}
return handler.Handle(cmd)
}

4.4 撤销栈 vs 检查点回滚#

撤销有两种实现策略:命令栈记录每个操作的增量变化,撤销时逆序回放;检查点(Checkpoint)在关键节点保存完整状态快照,撤销时恢复到最近的检查点。

维度命令栈检查点
内存占用低——只存增量高——存完整快照
撤销速度O(1)——弹出栈顶O(1)——恢复快照
实现复杂度高——每个命令需实现 Undo低——直接序列化状态
适用场景操作粒度细、对象大操作粒度粗、对象小

Photoshop 用检查点(历史记录快照),VS Code 用命令栈(TextBuffer 的 undo 栈)。选择哪种取决于被操作对象的大小和操作的粒度。

五、多语言实现#

5.1 Go 实现#

package main
import "fmt"
// Command 命令接口
type Command interface {
Execute()
Undo()
}
// TextBuffer 文本缓冲区——接收者
type TextBuffer struct {
content string
}
func (tb *TextBuffer) Insert(text string, pos int) {
tb.content = tb.content[:pos] + text + tb.content[pos:]
}
func (tb *TextBuffer) Delete(pos, length int) string {
deleted := tb.content[pos : pos+length]
tb.content = tb.content[:pos] + tb.content[pos+length:]
return deleted
}
func (tb *TextBuffer) Content() string {
return tb.content
}
// InsertCommand 插入命令
type InsertCommand struct {
buffer *TextBuffer
text string
pos int
}
func NewInsertCommand(buffer *TextBuffer, text string, pos int) *InsertCommand {
return &InsertCommand{buffer: buffer, text: text, pos: pos}
}
func (ic *InsertCommand) Execute() {
ic.buffer.Insert(ic.text, ic.pos)
}
func (ic *InsertCommand) Undo() {
ic.buffer.Delete(ic.pos, len(ic.text))
}
// DeleteCommand 删除命令
type DeleteCommand struct {
buffer *TextBuffer
pos int
length int
deleted string // 撤销时需要恢复的文本
}
func NewDeleteCommand(buffer *TextBuffer, pos, length int) *DeleteCommand {
return &DeleteCommand{buffer: buffer, pos: pos, length: length}
}
func (dc *DeleteCommand) Execute() {
dc.deleted = dc.buffer.Delete(dc.pos, dc.length)
}
func (dc *DeleteCommand) Undo() {
dc.buffer.Insert(dc.deleted, dc.pos)
}
// Editor 编辑器——调用者
type Editor struct {
buffer *TextBuffer
undoStack []Command
redoStack []Command
}
func NewEditor(buffer *TextBuffer) *Editor {
return &Editor{buffer: buffer}
}
func (e *Editor) Execute(cmd Command) {
cmd.Execute()
e.undoStack = append(e.undoStack, cmd)
e.redoStack = nil // 新操作清空重做栈
}
func (e *Editor) Undo() {
if len(e.undoStack) == 0 {
return
}
cmd := e.undoStack[len(e.undoStack)-1]
e.undoStack = e.undoStack[:len(e.undoStack)-1]
cmd.Undo()
e.redoStack = append(e.redoStack, cmd)
}
func (e *Editor) Redo() {
if len(e.redoStack) == 0 {
return
}
cmd := e.redoStack[len(e.redoStack)-1]
e.redoStack = e.redoStack[:len(e.redoStack)-1]
cmd.Execute()
e.undoStack = append(e.undoStack, cmd)
}
func main() {
buffer := &TextBuffer{}
editor := NewEditor(buffer)
// 插入 "Hello"
editor.Execute(NewInsertCommand(buffer, "Hello", 0))
fmt.Println(buffer.Content()) // Hello
// 插入 " World"
editor.Execute(NewInsertCommand(buffer, " World", 5))
fmt.Println(buffer.Content()) // Hello World
// 撤销
editor.Undo()
fmt.Println(buffer.Content()) // Hello
// 重做
editor.Redo()
fmt.Println(buffer.Content()) // Hello World
// 删除 " World"
editor.Execute(NewDeleteCommand(buffer, 5, 6))
fmt.Println(buffer.Content()) // Hello
// 撤销删除
editor.Undo()
fmt.Println(buffer.Content()) // Hello World
}

Go 的实现展示了命令模式的完整闭环:InsertCommandDeleteCommand 各自维护撤销所需的状态——插入命令记住插入位置和文本,撤销时删除对应内容;删除命令记住被删除的文本,撤销时重新插入。Editor 维护双栈(撤销栈 + 重做栈),新操作清空重做栈,撤销时命令从撤销栈移到重做栈,重做时反向移动。这是几乎所有编辑器 undo/redo 的标准实现。

5.2 TypeScript 实现#

// Command 命令接口
interface Command {
execute(): void;
undo(): void;
}
// UI 面板状态——接收者
interface PanelState {
visible: boolean;
position: { x: number; y: number };
width: number;
height: number;
}
class Panel {
private state: PanelState;
constructor(initial: PanelState) {
this.state = { ...initial };
}
show() { this.state.visible = true; }
hide() { this.state.visible = false; }
move(x: number, y: number) {
this.state.position = { x, y };
}
resize(width: number, height: number) {
this.state.width = width;
this.state.height = height;
}
getState(): PanelState {
// 返回深拷贝,防止外部修改
return JSON.parse(JSON.stringify(this.state));
}
restore(state: PanelState) {
this.state = JSON.parse(JSON.stringify(state));
}
}
// MovePanelCommand 移动面板命令
class MovePanelCommand implements Command {
private panel: Panel;
private oldPos: { x: number; y: number };
private newPos: { x: number; y: number };
constructor(panel: Panel, x: number, y: number) {
this.panel = panel;
this.newPos = { x, y };
this.oldPos = panel.getState().position;
}
execute() {
this.panel.move(this.newPos.x, this.newPos.y);
}
undo() {
this.panel.move(this.oldPos.x, this.oldPos.y);
}
}
// TogglePanelCommand 显示/隐藏面板命令
class TogglePanelCommand implements Command {
private panel: Panel;
private wasVisible: boolean;
constructor(panel: Panel) {
this.panel = panel;
this.wasVisible = panel.getState().visible;
}
execute() {
this.wasVisible ? this.panel.hide() : this.panel.show();
}
undo() {
this.wasVisible ? this.panel.show() : this.panel.hide();
}
}
// CommandManager 命令管理器
class CommandManager {
private undoStack: Command[] = [];
private redoStack: Command[] = [];
execute(cmd: Command) {
cmd.execute();
this.undoStack.push(cmd);
this.redoStack = []; // 新操作清空重做栈
}
undo() {
const cmd = this.undoStack.pop();
if (!cmd) return;
cmd.undo();
this.redoStack.push(cmd);
}
redo() {
const cmd = this.redoStack.pop();
if (!cmd) return;
cmd.execute();
this.undoStack.push(cmd);
}
canUndo(): boolean { return this.undoStack.length > 0; }
canRedo(): boolean { return this.redoStack.length > 0; }
}
// 使用示例
const panel = new Panel({
visible: true,
position: { x: 100, y: 100 },
width: 300,
height: 200,
});
const manager = new CommandManager();
// 移动面板
manager.execute(new MovePanelCommand(panel, 200, 150));
console.log(panel.getState().position); // { x: 200, y: 150 }
// 撤销移动
manager.undo();
console.log(panel.getState().position); // { x: 100, y: 100 }
// 重做
manager.redo();
console.log(panel.getState().position); // { x: 200, y: 150 }

TypeScript 的实现面向 UI 场景。MovePanelCommand 在构造时保存旧位置,撤销时恢复。TogglePanelCommand 记住操作前的可见状态,撤销时恢复到之前的状态。CommandManager 提供了 canUndo() / canRedo() 方法,方便 UI 层控制按钮的启用/禁用状态——这是实际前端项目中的常见需求。

六、生产验证#

  • JUnit 5 —— Command.java JUnit 的测试执行框架将每个测试用例封装为命令对象。TestDescriptor 描述「要执行什么测试」,TestExecutionListener 充当调用者,EngineExecutionContext 是接收者。测试的发现(Discovery)、执行(Execution)、报告(Reporting)是三种不同的命令阶段,每个阶段可以独立扩展。这种设计让 JUnit 5 支持参数化测试、重复测试、动态测试——这些本质上都是不同类型的命令,由框架统一调度。

  • Emacs —— keyboard.c#Lcmd_error Emacs 的命令循环(Command Loop)是命令模式的经典实现。用户按键被映射为命令符号(如 kill-lineyank),每个命令符号绑定到一个 Elisp 函数。命令循环不断读取按键、查找绑定、执行命令,执行结果修改缓冲区状态。Emacs 的撤销机制把缓冲区变更记录为撤销记录(undo record),每个记录就是一次命令的增量效果。宏录制则直接记录按键序列,回放时重新送入命令循环。

  • MediatR —— MediatR/src/MediatR/SendPipeline.cs MediatR 是 .NET 生态最流行的命令总线库。IRequest<TResponse> 接口定义命令(或查询),IRequestHandler<TRequest, TResponse> 定义处理器。IMediator.Send(command) 把命令路由到注册的处理器。MediatR 还支持管道行为(Pipeline Behavior)——在命令到达处理器之前执行横切逻辑(验证、日志、缓存),这和 HTTP 中间件的概念一致。CQRS 架构中,MediatR 的命令总线是写操作的标准入口,查询走另一条路径,读写分离。

七、小结#

何时使用

  • 编辑器和绘图工具——需要撤销/重做的交互式应用,每个操作封装为命令对象,双栈管理撤销和重做。VS Code、Figma、Photoshop 都在用
  • 任务队列和调度——命令对象可以序列化后存入队列,由工作进程取出执行。数据库写队列、CI/CD 流水线、消息中间件的任务分发
  • 宏录制和回放——用户操作记录为命令序列,一键回放。Excel 宏、Photoshop 动作、Emacs 键盘宏
  • CQRS 架构——命令总线将写操作封装为命令对象,路由到处理器,实现读写分离和横切关注点

何时不用

  • 操作不需要撤销——如果应用没有 undo 需求,命令模式引入的间接层就是过度设计。一个只读的数据展示页面,按钮直接调方法就好
  • 请求是同步且唯一的——调用者只有一个,接收者只有一个,不需要排队、不需要记录,直接调用更简单
  • 命令数量爆炸——如果每个操作都是不同的命令类,类数量会急剧膨胀。此时可以考虑用数据驱动的方式:一个通用命令类,操作类型作为字段,而不是为每种操作建一个类

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

命令模式(Command Pattern)
https://blog.souloss.com/posts/programming/behavioral/behavioral-command/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时