命令模式是前端进阶必须掌握的设计模式之一。它不像观察者或策略模式那样随处可见,但在编辑器、可视化搭建、异步任务调度等复杂场景中,几乎没有更优雅的替代方案。本文从一个最朴素的生活例子出发,逐步深入到编辑器的撤销/重做、可取消的异步任务调度、宏命令、播放器控制等真实场景,带你感受前端开发中的命令模式。

一、从一个饭店点餐的故事说起
假设你去饭店吃饭,真正给你做菜的人是厨师。但你总不能直接跑进后厨,挨个问"谁能给我炒个菜"吧?这时候服务员出现了------你告诉服务员你想吃什么,服务员帮你下单给厨师。如果菜还没做,你突然不想要了,也可以通过服务员取消订单。
这一套流程中:
- 你(调用者) 不需要知道哪个厨师(接收者)会做你的菜。
- 服务员(Invoker) 负责接收命令、传递命令、记录命令。
- 菜单/订单(Command) 是请求的凭证,可以被记录、撤销。
这就是命令模式的核心思想:将请求封装成对象,消除调用者和接收者之间的耦合,并支持命令的记录与撤销。
二、命令模式的核心思想
将请求封装成对象,使发起请求和执行请求的双方解耦。每个命令对象都携带了执行和回滚所需的信息,可以被存储、排队、记录日志甚至远程传输。
关键点:
- 解耦发起与执行:调用方不直接操作接收者,而是通过命令对象间接调用。
- 可撤销 :命令对象保存执行前的状态或标识,支持
undo。 - 可组合:多个命令可以组合成宏命令批量执行。
如果面试官追问"和策略模式有什么区别",可以回答:策略模式关注"怎么处理"(选择算法),命令模式关注"做什么"(封装行为)。 命令对象可以存储、撤销、排队,策略对象只是可互换的计算逻辑。
三、场景1:一个最简单的命令模式 Demo------点餐系统
让我们用代码来模拟饭店的点餐和退单流程。
html
<div id="app">
<div class="commands-container">
<h3>菜单列表</h3>
<ul class="commands">
<li cmd="xhscjd">西红柿炒鸡蛋</li>
<li cmd="kgcjd">苦瓜炒鸡蛋</li>
<li cmd="ljclj">辣椒炒辣椒</li>
<li cmd="jdcjd">鸡蛋炒鸡蛋</li>
</ul>
</div>
<button id="undo">撤销最后一个订单</button>
</div>
javascript
// 服务员类(Invoker)------负责接收命令、执行命令、记录命令用于撤销
class Waiter {
constructor(menu) {
this.menu = menu; // 菜单集合(Receiver)
this.orderHistory = []; // 记录已执行的命令,用于撤销
}
execute(type) {
const order = this.menu[type].execute();
this.orderHistory.push(order.id);
console.log(`下单成功,订单ID:${order.id}`);
}
undo() {
const id = this.orderHistory.pop();
if (id) {
console.log(`已撤销订单,订单ID:${id}`);
} else {
console.log('没有可以撤销的订单');
}
}
}
// 菜单集合(Receiver)------真正执行操作的对象
const Menu = {
xhscjd: {
execute() {
console.log('厨师正在做:西红柿炒鸡蛋');
return { id: 'data-' + Date.now() };
}
},
kgcjd: {
execute() {
console.log('厨师正在做:苦瓜炒鸡蛋');
return { id: 'data-' + Date.now() };
}
},
ljclj: {
execute() {
console.log('厨师正在做:辣椒炒辣椒');
return { id: 'data-' + Date.now() };
}
},
jdcjd: {
execute() {
console.log('厨师正在做:鸡蛋炒鸡蛋');
return { id: 'data-' + Date.now() };
}
}
};
// 使用:点餐
const waiter = new Waiter(Menu);
document.querySelectorAll('.commands > li').forEach(item => {
item.addEventListener('click', function () {
waiter.execute(this.getAttribute('cmd'));
});
});
document.getElementById('undo').addEventListener('click', () => {
waiter.undo();
});
这个 Demo 展示了命令模式最朴素的价值:
- 调用者(按钮点击事件)不需要知道厨师(Menu)的存在,只需要通过服务员(Waiter)下单。
- 服务员记录了每一笔订单,可以随时撤销。
- 如果要新增一道菜(比如"宫保鸡丁"),只需在
Menu中加一个方法,完全不改调用方代码。
当然,你可能觉得这个需求用命令模式有点"杀鸡用牛刀"------如果不需要撤销功能,直接把按钮事件绑定到 Menu 的方法上就更简单。命令模式不是在所有场景下都必要的,只有在需要撤销、恢复、排队等操作时,它才真正发挥价值。 这就是设计模式的核心原则:基于需求合理使用,不要为了模式而模式。
四、场景2:编辑器的撤销与重做------命令模式最经典的应用
在富文本编辑器、在线表格、可视化搭建平台中,每一次用户操作都被封装成一个命令对象。Antv/X6 的 @antv/x6-plugin-history 插件就是命令模式的典型实现------它维护了 undoStack 和 redoStack 两个命令栈,每个命令都实现了 execute 和 revert 方法。
javascript
// 命令基类
class Command {
execute() { throw new Error('子类实现'); }
undo() { throw new Error('子类实现'); }
}
// 修改样式命令
class SetStyleCommand extends Command {
constructor(receiver, selector, property, newValue) {
super();
this.receiver = receiver;
this.selector = selector;
this.property = property;
this.newValue = newValue;
this.oldValue = null; // 在 execute 时保存旧值,用于撤销
}
execute() {
this.oldValue = this.receiver.getStyle(this.selector, this.property);
this.receiver.setStyle(this.selector, this.property, this.newValue);
}
undo() {
this.receiver.setStyle(this.selector, this.property, this.oldValue);
}
}
// 插入文字命令
class InsertTextCommand extends Command {
constructor(receiver, position, text) {
super();
this.receiver = receiver;
this.position = position;
this.text = text;
}
execute() {
this.receiver.insert(this.position, this.text);
}
undo() {
this.receiver.delete(this.position, this.text.length);
}
}
命令历史管理器负责管理两个栈:
javascript
class CommandHistory {
constructor() {
this.undoStack = [];
this.redoStack = [];
}
execute(command) {
command.execute();
this.undoStack.push(command);
this.redoStack = []; // 新操作清空重做栈
}
undo() {
const command = this.undoStack.pop();
if (command) {
command.undo();
this.redoStack.push(command);
}
}
redo() {
const command = this.redoStack.pop();
if (command) {
command.execute();
this.undoStack.push(command);
}
}
}
使用方式:
javascript
const history = new CommandHistory();
const editor = new Editor();
// 用户修改样式
const cmd = new SetStyleCommand(editor, 'h1', 'color', 'red');
history.execute(cmd); // 执行
history.undo(); // 撤销,样式恢复
history.redo(); // 重做,再次变红
核心价值 :新增一种操作类型(比如"锁定图层"),只需要新增一个 Command 子类,完全不影响历史管理逻辑。Antv/X6 的 History 插件正是这样设计的------Command 类定义了算法规范,各个具体的业务 Command 实现并重写它的方法,而 History 类统一管理命令栈。
五、场景3:可取消的异步任务调度------命令模式处理复杂异步
在做音乐审核后台时,需要批量缓存音频文件(每个文件 20 多 MB),用户希望一键缓存所有待审核的资源。这个需求涉及并发控制、优先级调整、中途取消------正是命令模式的绝佳用武之地。
可取消的任务设计
typescript
// 取消原因哨兵对象,使得调用方可以明确知道是取消而非错误信息
const CancelledReason = Symbol('Async task has been cancelled');
interface CancellableTask<T> {
action: (...args: any[]) => Promise<T>;
cancel: () => void;
}
function createCancellableTask<T>(
fn: (...args: any[]) => T | Promise<T>,
...args: any[]
): CancellableTask<T> {
let trigger: ((reason?: any) => void) | null = null;
let isCancel = false;
const action = () => {
if (isCancel) return Promise.reject(CancelledReason);
return new Promise<T>((resolve, reject) => {
trigger = reject;
Promise.resolve(fn(...args))
.then(resolve)
.catch(reject);
});
};
const cancel = () => {
isCancel = true;
typeof trigger === 'function' && trigger(CancelledReason);
};
return { action, cancel };
}
异步任务调度器
typescript
class AsyncTaskScheduler<T> {
private runningTask = 0;
private maxTask = 5;
private asyncTaskQueue: AsyncTaskNode<T>[] = [];
add(cancellableTask: CancellableTask<T>, position: 'push' | 'unshift' = 'push') {
return new Promise((resolve, reject) => {
if (this.runningTask < this.maxTask) {
this.runningTask++;
this.runTask(cancellableTask.action, resolve, reject);
} else {
this.asyncTaskQueue[position]({ resolve, reject, cancellableTask });
}
});
}
addFirst(cancellableTask: CancellableTask<T>) {
return this.add(cancellableTask, 'unshift');
}
private runTask(action, resolve, reject) {
action()
.then(res => { this.runningTask--; resolve(res); this.run(); })
.catch(err => { this.runningTask--; reject(err); this.run(); });
}
private run() {
while (this.asyncTaskQueue.length && this.runningTask < this.maxTask) {
const task = this.asyncTaskQueue.shift()!;
this.runningTask++;
this.runTask(task.cancellableTask.action, task.resolve, task.reject);
}
}
}
核心价值:命令模式让异步任务不再是"即发即忘"的调用,而变成了可存储、可取消、可排队的对象。取消操作不会侵入业务代码------业务函数仍然保持原子性,取消只是让调度器不再处理这个任务的结果。
六、场景4:播放器控制------命令模式解耦 UI 与逻辑
在音乐播放器应用中,用户通过按钮控制播放、暂停、上一首、下一首。命令模式将按钮与播放器逻辑解耦:
javascript
class PlayCommand {
constructor(player) { this.player = player; }
execute() { this.player.play(); }
}
class PauseCommand {
constructor(player) { this.player = player; }
execute() { this.player.pause(); }
}
class Button {
constructor(command) { this.command = command; }
onClick() { this.command.execute(); }
}
const player = new Player();
const playButton = new Button(new PlayCommand(player));
const pauseButton = new Button(new PauseCommand(player));
playButton.onClick(); // 播放音乐
pauseButton.onClick(); // 暂停音乐
核心价值:按钮不需要知道播放器的具体实现,只需要持有一个命令对象。同一个命令对象可以被多个 UI 入口复用------键盘快捷键、菜单项、工具栏按钮都可以共享同一个命令实例,保持行为一致。
七、什么时候不该用命令模式?
命令模式的核心价值在于可撤销、可排队、可记录。如果没有这些需求,使用命令模式可能就是"把简单的事复杂化"。
比如上面点餐的例子,如果不需要撤销订单,直接把按钮事件绑定到 Menu 的方法上就更简单:
javascript
// 简单场景:不需要命令模式
document.querySelectorAll('.commands > li').forEach(item => {
item.addEventListener('click', function () {
Menu[this.getAttribute('cmd')].execute();
});
});
一句话原则:只有当需要撤销、恢复、记录日志、任务排队等功能时,命令模式才真正发挥价值。不要为了模式而模式。
八、总结
命令模式在现代前端中的主要应用场景:
| 场景 | 核心价值 |
|---|---|
| 图编辑器撤销/重做 | 每个操作都是独立对象,自带执行和回滚逻辑 |
| Canvas 绘图命令 | 不同图形 API 封装成命令,统一管理执行与撤销 |
| 异步任务调度 | 支持并发控制、优先级、可取消 |
| 宏命令 / 批量操作 | 多个命令打包成一次操作,统一回滚 |
| 播放器/菜单控制 | UI 与业务逻辑解耦,同一命令可被多个入口复用 |
| Redux action | 将"发生了什么"封装为纯数据对象 |
命令模式的核心思想是将"行为"封装为"值"------一个命令对象就是一段可以被传递、存储、排队、记录日志的"待执行的行为"。当你需要撤销、恢复、排队等功能时,命令模式是最优雅的解决方案;当你只是想把一个按钮绑定到一个函数上时,直接绑定就好,别多想。