命令模式
问题
什么是命令模式?如何用命令模式实现撤销/重做功能?前端有哪些典型的应用场景(如 Canvas 画板、富文本编辑器、快捷键绑定)?
答案
命令模式(Command Pattern)是 GoF 23 种设计模式之一,属于行为型模式。它将"请求"封装为一个对象,从而使你可以用不同的请求对客户进行参数化,对请求排队或记录请求日志,以及支持可撤销的操作。
GoF 定义:Encapsulate a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations.
命令模式的核心思想是将"做什么"和"谁来做"解耦——调用者不需要知道具体操作的实现细节,只需要调用命令对象的 execute() 方法。
核心概念与四个角色
| 角色 | 说明 | 前端类比 |
|---|---|---|
| Command | 命令接口,声明 execute() 和 undo() | 操作的抽象定义 |
| ConcreteCommand | 具体命令,持有 Receiver 引用并实现操作 | 加粗命令、绘制命令等 |
| Invoker | 调用者,持有命令并触发执行 | 工具栏按钮、快捷键管理器 |
| Receiver | 接收者,真正执行操作的对象 | 文档模型、Canvas 上下文 |
| Client | 客户端,负责创建命令并组装关系 | 应用初始化代码 |
- 解耦调用者与执行者:按钮不需要知道具体执行什么操作
- 支持撤销/重做:每个命令知道如何撤销自己
- 支持操作排队:命令可以放入队列延迟执行
- 支持宏命令:多个命令组合为一个复合命令
- 支持日志/序列化:命令可以被记录和回放
TypeScript 基础实现
Command 接口定义
// 命令接口
interface Command {
execute(): void;
undo(): void;
}
// 可选:支持描述信息,便于操作历史展示
interface DescribableCommand extends Command {
readonly description: string;
}
具体命令与接收者
// Receiver(接收者)—— 真正执行操作的对象
class Light {
private brightness: number = 0;
on(): void {
this.brightness = 100;
console.log('灯已打开,亮度:', this.brightness);
}
off(): void {
this.brightness = 0;
console.log('灯已关闭');
}
setBrightness(value: number): void {
this.brightness = value;
console.log('亮度设为:', this.brightness);
}
getBrightness(): number {
return this.brightness;
}
}
// ConcreteCommand(具体命令)
class LightOnCommand implements Command {
private previousBrightness: number = 0; // 保存旧状态,用于 undo
constructor(private light: Light) {}
execute(): void {
this.previousBrightness = this.light.getBrightness();
this.light.on();
}
undo(): void {
this.light.setBrightness(this.previousBrightness);
}
}
class LightOffCommand implements Command {
private previousBrightness: number = 0;
constructor(private light: Light) {}
execute(): void {
this.previousBrightness = this.light.getBrightness();
this.light.off();
}
undo(): void {
this.light.setBrightness(this.previousBrightness);
}
}
Invoker(调用者)
// Invoker(调用者)—— 遥控器
class RemoteControl {
private history: Command[] = [];
executeCommand(command: Command): void {
command.execute();
this.history.push(command);
}
undoLast(): void {
const command = this.history.pop();
if (command) {
command.undo();
}
}
}
// Client(客户端)—— 组装各角色
const light = new Light();
const remote = new RemoteControl();
remote.executeCommand(new LightOnCommand(light)); // 灯已打开
remote.executeCommand(new LightOffCommand(light)); // 灯已关闭
remote.undoLast(); // 撤销 → 灯恢复打开
撤销/重做(Undo/Redo)
撤销/重做是命令模式最经典的应用。核心是维护两个栈:undoStack(撤销栈)和 redoStack(重做栈)。
完整的历史管理器
class HistoryManager {
private undoStack: Command[] = [];
private redoStack: Command[] = [];
private maxHistorySize: number;
constructor(maxHistorySize: number = 100) {
this.maxHistorySize = maxHistorySize;
}
execute(command: Command): void {
command.execute();
this.undoStack.push(command);
// 执行新命令后,清空重做栈
this.redoStack = [];
// 超过最大历史记录则淘汰最早的命令
if (this.undoStack.length > this.maxHistorySize) {
this.undoStack.shift();
}
}
undo(): boolean {
const command = this.undoStack.pop();
if (!command) return false;
command.undo();
this.redoStack.push(command);
return true;
}
redo(): boolean {
const command = this.redoStack.pop();
if (!command) return false;
command.execute();
this.undoStack.push(command);
return true;
}
get canUndo(): boolean {
return this.undoStack.length > 0;
}
get canRedo(): boolean {
return this.redoStack.length > 0;
}
clear(): void {
this.undoStack = [];
this.redoStack = [];
}
}
编辑器撤销/重做示例
// 文档模型(Receiver)
class TextDocument {
private content: string = '';
insert(position: number, text: string): void {
this.content =
this.content.slice(0, position) + text + this.content.slice(position);
}
delete(position: number, length: number): string {
const deleted = this.content.slice(position, position + length);
this.content =
this.content.slice(0, position) + this.content.slice(position + length);
return deleted;
}
getContent(): string {
return this.content;
}
}
// 插入命令
class InsertTextCommand implements Command {
constructor(
private doc: TextDocument,
private position: number,
private text: string
) {}
execute(): void {
this.doc.insert(this.position, this.text);
}
undo(): void {
this.doc.delete(this.position, this.text.length);
}
}
// 删除命令
class DeleteTextCommand implements Command {
private deletedText: string = ''; // 保存被删除的文本用于恢复
constructor(
private doc: TextDocument,
private position: number,
private length: number
) {}
execute(): void {
this.deletedText = this.doc.delete(this.position, this.length);
}
undo(): void {
this.doc.insert(this.position, this.deletedText);
}
}
// 使用
const doc = new TextDocument();
const history = new HistoryManager();
history.execute(new InsertTextCommand(doc, 0, 'Hello'));
console.log(doc.getContent()); // "Hello"
history.execute(new InsertTextCommand(doc, 5, ' World'));
console.log(doc.getContent()); // "Hello World"
history.execute(new DeleteTextCommand(doc, 5, 6));
console.log(doc.getContent()); // "Hello"
history.undo();
console.log(doc.getContent()); // "Hello World"
history.undo();
console.log(doc.getContent()); // "Hello"
history.redo();
console.log(doc.getContent()); // "Hello World"
- 执行新命令必须清空 redoStack:否则重做历史会与当前状态不一致
- 命令必须保存足够的上下文信息:如
DeleteTextCommand要保存被删除的文本 - 注意命令的不可变性:同一个命令对象不应被重复 execute,否则状态混乱
- 历史栈大小限制:不加限制可能导致内存问题,建议设置上限
宏命令(MacroCommand)
宏命令可以将多个命令组合为一个复合命令,常用于"批量操作"和"事务回滚"场景。
class MacroCommand implements Command {
private commands: Command[] = [];
add(command: Command): MacroCommand {
this.commands.push(command);
return this; // 链式调用
}
execute(): void {
// 按顺序执行所有子命令
for (const command of this.commands) {
command.execute();
}
}
undo(): void {
// 按逆序撤销所有子命令
for (let i = this.commands.length - 1; i >= 0; i--) {
this.commands[i].undo();
}
}
}
// 使用场景:格式化文本(同时加粗 + 斜体 + 变色)
class SetBoldCommand implements Command {
private previousBold: boolean = false;
constructor(private editor: RichTextState) {}
execute(): void {
this.previousBold = this.editor.bold;
this.editor.bold = true;
}
undo(): void {
this.editor.bold = this.previousBold;
}
}
class SetItalicCommand implements Command {
private previousItalic: boolean = false;
constructor(private editor: RichTextState) {}
execute(): void {
this.previousItalic = this.editor.italic;
this.editor.italic = true;
}
undo(): void {
this.editor.italic = this.previousItalic;
}
}
interface RichTextState {
bold: boolean;
italic: boolean;
color: string;
}
// 宏命令 —— 一键加粗+斜体
const state: RichTextState = { bold: false, italic: false, color: '#000' };
const formatCommand = new MacroCommand()
.add(new SetBoldCommand(state))
.add(new SetItalicCommand(state));
formatCommand.execute(); // bold: true, italic: true
formatCommand.undo(); // bold: false, italic: false
带事务回滚的宏命令
class TransactionalMacroCommand implements Command {
private commands: Command[] = [];
private executedCommands: Command[] = [];
add(command: Command): this {
this.commands.push(command);
return this;
}
execute(): void {
this.executedCommands = [];
try {
for (const command of this.commands) {
command.execute();
this.executedCommands.push(command);
}
} catch (error) {
// 某个命令失败,回滚已执行的命令
console.error('命令执行失败,开始回滚:', error);
this.rollback();
throw error;
}
}
undo(): void {
this.rollback();
}
private rollback(): void {
for (let i = this.executedCommands.length - 1; i >= 0; i--) {
this.executedCommands[i].undo();
}
this.executedCommands = [];
}
}
对于用户来说,宏命令是"一次操作"——按一次 Ctrl+Z 就撤销整个宏,而不是撤销宏内的单个子命令。这正是将宏命令作为一个整体推入历史栈的原因。
操作队列与异步命令
命令模式天然适合实现操作队列和异步调度。
命令队列
class CommandQueue {
private queue: Command[] = [];
private isProcessing: boolean = false;
enqueue(command: Command): void {
this.queue.push(command);
this.process();
}
private process(): void {
if (this.isProcessing || this.queue.length === 0) return;
this.isProcessing = true;
const command = this.queue.shift()!;
try {
command.execute();
} finally {
this.isProcessing = false;
// 继续处理下一个
if (this.queue.length > 0) {
this.process();
}
}
}
}
异步命令与调度器
// 异步命令接口
interface AsyncCommand {
execute(): Promise<void>;
undo(): Promise<void>;
}
class AsyncCommandScheduler {
private queue: AsyncCommand[] = [];
private isProcessing: boolean = false;
private history: AsyncCommand[] = [];
async enqueue(command: AsyncCommand): Promise<void> {
this.queue.push(command);
if (!this.isProcessing) {
await this.processQueue();
}
}
private async processQueue(): Promise<void> {
this.isProcessing = true;
while (this.queue.length > 0) {
const command = this.queue.shift()!;
try {
await command.execute();
this.history.push(command);
} catch (error) {
console.error('异步命令执行失败:', error);
// 可选:跳过或重试
}
}
this.isProcessing = false;
}
async undoLast(): Promise<boolean> {
const command = this.history.pop();
if (!command) return false;
await command.undo();
return true;
}
}
// 实际使用:API 操作命令
class ApiUpdateCommand implements AsyncCommand {
private previousData: unknown = null;
constructor(
private endpoint: string,
private newData: Record<string, unknown>
) {}
async execute(): Promise<void> {
// 保存旧数据用于撤销
const response = await fetch(this.endpoint);
this.previousData = await response.json();
// 执行更新
await fetch(this.endpoint, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.newData),
});
}
async undo(): Promise<void> {
if (this.previousData) {
await fetch(this.endpoint, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.previousData),
});
}
}
}
前端实际应用
1. Canvas 画板
// Canvas 画板状态
interface CanvasState {
ctx: CanvasRenderingContext2D;
snapshot: ImageData | null;
}
// 保存/恢复画布快照的基类
abstract class CanvasCommand implements Command {
protected prevSnapshot: ImageData | null = null;
constructor(protected canvas: CanvasState) {}
protected saveSnapshot(): void {
const { ctx } = this.canvas;
this.prevSnapshot = ctx.getImageData(
0, 0, ctx.canvas.width, ctx.canvas.height
);
}
undo(): void {
if (this.prevSnapshot) {
this.canvas.ctx.putImageData(this.prevSnapshot, 0, 0);
}
}
abstract execute(): void;
}
// 画线命令
class DrawLineCommand extends CanvasCommand {
constructor(
canvas: CanvasState,
private startX: number,
private startY: number,
private endX: number,
private endY: number,
private color: string = '#000',
private lineWidth: number = 2
) {
super(canvas);
}
execute(): void {
this.saveSnapshot();
const { ctx } = this.canvas;
ctx.beginPath();
ctx.moveTo(this.startX, this.startY);
ctx.lineTo(this.endX, this.endY);
ctx.strokeStyle = this.color;
ctx.lineWidth = this.lineWidth;
ctx.stroke();
}
}
// 画矩形命令
class DrawRectCommand extends CanvasCommand {
constructor(
canvas: CanvasState,
private x: number,
private y: number,
private width: number,
private height: number,
private color: string = '#000',
private fill: boolean = false
) {
super(canvas);
}
execute(): void {
this.saveSnapshot();
const { ctx } = this.canvas;
if (this.fill) {
ctx.fillStyle = this.color;
ctx.fillRect(this.x, this.y, this.width, this.height);
} else {
ctx.strokeStyle = this.color;
ctx.strokeRect(this.x, this.y, this.width, this.height);
}
}
}
// 橡皮擦命令
class EraseCommand extends CanvasCommand {
constructor(
canvas: CanvasState,
private x: number,
private y: number,
private radius: number = 10
) {
super(canvas);
}
execute(): void {
this.saveSnapshot();
const { ctx } = this.canvas;
ctx.save();
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.clip();
ctx.clearRect(
this.x - this.radius, this.y - this.radius,
this.radius * 2, this.radius * 2
);
ctx.restore();
}
}
在实际的 Canvas 画板项目中(如 设计在线图片编辑器),每一个绘制操作都是一个命令,推入历史栈后即可实现撤销/重做。对于复杂的笔迹(由大量点组成的路径),通常将一整条笔迹视为一个命令,而不是每个点一个命令。
2. 富文本编辑器操作
// 编辑器 Receiver
interface EditorReceiver {
getSelection(): { start: number; end: number } | null;
applyFormat(format: string, value: unknown): void;
removeFormat(format: string): void;
getFormat(format: string): unknown;
}
// 格式化命令基类
abstract class FormatCommand implements Command {
protected prevValue: unknown = null;
protected selection: { start: number; end: number } | null = null;
constructor(
protected editor: EditorReceiver,
protected format: string,
protected value: unknown
) {}
execute(): void {
this.selection = this.editor.getSelection();
this.prevValue = this.editor.getFormat(this.format);
this.editor.applyFormat(this.format, this.value);
}
undo(): void {
if (this.prevValue !== null) {
this.editor.applyFormat(this.format, this.prevValue);
} else {
this.editor.removeFormat(this.format);
}
}
}
// 具体格式命令
class BoldCommand extends FormatCommand {
constructor(editor: EditorReceiver) {
super(editor, 'bold', true);
}
}
class ItalicCommand extends FormatCommand {
constructor(editor: EditorReceiver) {
super(editor, 'italic', true);
}
}
class FontSizeCommand extends FormatCommand {
constructor(editor: EditorReceiver, size: number) {
super(editor, 'fontSize', size);
}
}
class AlignCommand extends FormatCommand {
constructor(editor: EditorReceiver, align: 'left' | 'center' | 'right' | 'justify') {
super(editor, 'textAlign', align);
}
}
更多富文本编辑器的架构设计,参考 设计富文本编辑器。
3. 表单操作历史
interface FormState {
[key: string]: unknown;
}
class FormFieldChangeCommand implements Command {
private previousValue: unknown;
constructor(
private formState: FormState,
private fieldName: string,
private newValue: unknown
) {
this.previousValue = formState[fieldName];
}
execute(): void {
this.formState[this.fieldName] = this.newValue;
}
undo(): void {
this.formState[this.fieldName] = this.previousValue;
}
}
// React Hook 封装
function useFormHistory(initialState: FormState) {
const history = new HistoryManager(50);
let state = { ...initialState };
const setField = (field: string, value: unknown) => {
const command = new FormFieldChangeCommand(state, field, value);
history.execute(command);
};
const undo = () => history.undo();
const redo = () => history.redo();
return { state, setField, undo, redo, canUndo: history.canUndo, canRedo: history.canRedo };
}
4. 快捷键绑定
class ShortcutManager {
private shortcuts = new Map<string, Command>();
private history: HistoryManager;
constructor() {
this.history = new HistoryManager();
this.setupGlobalListener();
}
register(shortcut: string, command: Command): void {
// 标准化快捷键格式:Ctrl+Z、Ctrl+Shift+Z
const normalized = this.normalizeShortcut(shortcut);
this.shortcuts.set(normalized, command);
}
private setupGlobalListener(): void {
document.addEventListener('keydown', (e: KeyboardEvent) => {
const key = this.getShortcutFromEvent(e);
// 特殊处理:Ctrl+Z / Ctrl+Shift+Z
if (key === 'Ctrl+Z') {
e.preventDefault();
this.history.undo();
return;
}
if (key === 'Ctrl+Shift+Z') {
e.preventDefault();
this.history.redo();
return;
}
const command = this.shortcuts.get(key);
if (command) {
e.preventDefault();
this.history.execute(command);
}
});
}
private getShortcutFromEvent(e: KeyboardEvent): string {
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
if (e.shiftKey) parts.push('Shift');
if (e.altKey) parts.push('Alt');
if (e.key !== 'Control' && e.key !== 'Shift' && e.key !== 'Alt' && e.key !== 'Meta') {
parts.push(e.key.toUpperCase());
}
return parts.join('+');
}
private normalizeShortcut(shortcut: string): string {
return shortcut
.split('+')
.map((s) => s.trim())
.map((s) => {
if (s.toLowerCase() === 'cmd') return 'Ctrl';
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
})
.join('+');
}
}
在 macOS 上,通常使用 Cmd 键(e.metaKey)代替 Ctrl。实际项目中应统一处理 Ctrl 和 Cmd,或根据平台自动切换。
命令序列化
将命令序列化为 JSON 后,可以用于协同编辑(将操作发送给其他用户)或操作回放(记录用户操作用于调试、教程回放)。
// 可序列化命令接口
interface SerializableCommand extends Command {
serialize(): CommandData;
}
interface CommandData {
type: string;
payload: Record<string, unknown>;
timestamp: number;
}
// 命令注册表 —— 用于反序列化
class CommandRegistry {
private factories = new Map<string, (payload: Record<string, unknown>) => SerializableCommand>();
register(type: string, factory: (payload: Record<string, unknown>) => SerializableCommand): void {
this.factories.set(type, factory);
}
deserialize(data: CommandData): SerializableCommand | null {
const factory = this.factories.get(data.type);
if (!factory) {
console.warn(`未知命令类型: ${data.type}`);
return null;
}
return factory(data.payload);
}
}
// 可序列化的插入文本命令
class SerializableInsertCommand implements SerializableCommand {
constructor(
private doc: TextDocument,
private position: number,
private text: string
) {}
execute(): void {
this.doc.insert(this.position, this.text);
}
undo(): void {
this.doc.delete(this.position, this.text.length);
}
serialize(): CommandData {
return {
type: 'INSERT_TEXT',
payload: { position: this.position, text: this.text },
timestamp: Date.now(),
};
}
}
// 注册与使用
const registry = new CommandRegistry();
// 注册命令工厂
registry.register('INSERT_TEXT', (payload) => {
return new SerializableInsertCommand(
doc,
payload.position as number,
payload.text as string
);
});
// 序列化 → 传输 → 反序列化 → 执行
const command = new SerializableInsertCommand(doc, 0, 'Hello');
const json = JSON.stringify(command.serialize());
// 远端接收后
const data: CommandData = JSON.parse(json);
const remoteCommand = registry.deserialize(data);
remoteCommand?.execute();
在协同编辑场景中,命令序列化通常与 OT(Operational Transformation) 或 CRDT 算法结合使用,以解决并发冲突。详见 设计在线协同编辑系统。
与其他模式的对比
| 对比维度 | 命令模式 | 策略模式 | 观察者模式 |
|---|---|---|---|
| 意图 | 将操作封装为对象 | 封装算法族 | 一对多通知 |
| 关注点 | 做什么(操作) | 怎么做(算法) | 谁关心(订阅) |
| 撤销支持 | 天然支持 | 不支持 | 不支持 |
| 延迟执行 | 命令可排队 | 立即执行 | 事件触发 |
| 典型场景 | 撤销/重做、队列 | 表单验证、排序 | 事件系统 |
常见面试问题
Q1: 命令模式的核心思想是什么?它解决了什么问题?
答案:
命令模式的核心思想是将请求(操作)封装为对象。它解决的核心问题是调用者与执行者之间的耦合:
- 解耦调用者和执行者:调用者只需调用
command.execute(),不需要知道具体逻辑 - 支持撤销/重做:每个命令对象知道如何执行自己,也知道如何撤销自己
- 支持操作记录:命令可以被存储、排队、序列化
- 支持宏命令:多个命令组合为一个复合操作
// 没有命令模式 —— 调用者直接依赖执行者
button.onClick = () => {
editor.bold(); // 按钮直接知道编辑器细节
editor.setFontSize(16);
};
// 使用命令模式 —— 调用者只依赖命令接口
button.onClick = () => {
historyManager.execute(new BoldCommand(editor));
};
Q2: 如何实现撤销/重做?为什么执行新命令要清空 redoStack?
答案:
撤销/重做通过两个栈实现:
- undoStack:存储已执行的命令,
undo()时从中弹出 - redoStack:存储已撤销的命令,
redo()时从中弹出
执行新命令必须清空 redoStack,因为新操作产生了新的时间线,旧的"未来操作"已经不再有效。这与 Git 的分叉类似——你在历史中回退后做了新修改,之前被撤销的操作就无法再重做了。
class History {
private undoStack: Command[] = [];
private redoStack: Command[] = [];
execute(cmd: Command): void {
cmd.execute();
this.undoStack.push(cmd);
this.redoStack = []; // 关键:新操作清空重做栈
}
undo(): void {
const cmd = this.undoStack.pop();
if (cmd) {
cmd.undo();
this.redoStack.push(cmd);
}
}
redo(): void {
const cmd = this.redoStack.pop();
if (cmd) {
cmd.execute();
this.undoStack.push(cmd);
}
}
}
Q3: 命令模式与策略模式有什么区别?
答案:
两者虽然都封装了行为,但意图完全不同:
| 维度 | 命令模式 | 策略模式 |
|---|---|---|
| 目的 | 封装"操作"(做什么) | 封装"算法"(怎么做) |
| 生命周期 | 命令对象会被存储、排队、撤销 | 策略对象通常即用即弃 |
| 可撤销 | 核心特性(undo()) | 不涉及 |
| 持有状态 | 持有执行所需的全部上下文 | 通常无状态或只有配置 |
| 数量 | 每次操作创建一个新命令实例 | 通常复用同一个策略实例 |
简单记忆:策略是"选哪条路",命令是"走一步并能退回来"。
Q4: 什么是宏命令?它在前端有什么应用?
答案:
宏命令(MacroCommand)是将多个命令组合为一个复合命令的模式(也叫组合命令),它本身也实现了 Command 接口,这是组合模式的应用。
前端典型应用:
- 富文本格式化:一键应用"标题样式"(同时设置字号 + 加粗 + 颜色)
- 批量操作:选中多个图层后同时移动、缩放
- 事务操作:表单提交涉及多个 API 调用,失败时全部回滚
- 录制宏:用户录制一系列操作,之后一键回放
// 一键应用标题样式 = 字号24 + 加粗 + 颜色#333
const headingStyle = new MacroCommand()
.add(new FontSizeCommand(editor, 24))
.add(new BoldCommand(editor))
.add(new ColorCommand(editor, '#333'));
// 执行是一个整体操作,Ctrl+Z 一次撤销全部
history.execute(headingStyle);
Q5: Canvas 画板中如何用命令模式实现撤销?
答案:
Canvas 画板的撤销有两种常见策略:
策略一:快照恢复(简单但内存开销大)
每次操作前保存整个画布的 ImageData,撤销时直接恢复快照。
class DrawCommand implements Command {
private snapshot: ImageData | null = null;
constructor(private ctx: CanvasRenderingContext2D) {}
execute(): void {
// 保存执行前的画布快照
this.snapshot = this.ctx.getImageData(
0, 0, this.ctx.canvas.width, this.ctx.canvas.height
);
// 执行绘制...
}
undo(): void {
if (this.snapshot) {
this.ctx.putImageData(this.snapshot, 0, 0);
}
}
}
策略二:命令重放(内存少但性能随命令增多下降)
撤销时清空画布,重新执行除最后一条之外的所有命令。
class CanvasHistory {
private commands: Command[] = [];
execute(cmd: Command): void {
cmd.execute();
this.commands.push(cmd);
}
undo(): void {
this.commands.pop();
// 清空画布后重新执行所有剩余命令
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
for (const cmd of this.commands) {
cmd.execute();
}
}
}
| 策略 | 优点 | 缺点 |
|---|---|---|
| 快照恢复 | 撤销 O(1),实现简单 | 内存占用大(每步一个 ImageData) |
| 命令重放 | 内存占用小 | 撤销 O(n),命令多时性能下降 |
实际项目中常用混合策略:每隔 N 步保存一个快照作为检查点(checkpoint),撤销时从最近的检查点开始重放。详见 设计在线图片编辑器。
Q6: 命令模式如何支持异步操作?
答案:
将 Command 接口的 execute() 和 undo() 改为返回 Promise,配合异步调度器使用:
interface AsyncCommand {
execute(): Promise<void>;
undo(): Promise<void>;
}
// 异步 API 调用命令
class UpdateUserCommand implements AsyncCommand {
private previousData: UserData | null = null;
constructor(
private userId: string,
private newData: Partial<UserData>
) {}
async execute(): Promise<void> {
// 1. 先获取旧数据
this.previousData = await api.getUser(this.userId);
// 2. 执行更新
await api.updateUser(this.userId, this.newData);
}
async undo(): Promise<void> {
if (this.previousData) {
await api.updateUser(this.userId, this.previousData);
}
}
}
// 异步历史管理器
class AsyncHistoryManager {
private undoStack: AsyncCommand[] = [];
private redoStack: AsyncCommand[] = [];
async execute(cmd: AsyncCommand): Promise<void> {
await cmd.execute();
this.undoStack.push(cmd);
this.redoStack = [];
}
async undo(): Promise<boolean> {
const cmd = this.undoStack.pop();
if (!cmd) return false;
await cmd.undo();
this.redoStack.push(cmd);
return true;
}
async redo(): Promise<boolean> {
const cmd = this.redoStack.pop();
if (!cmd) return false;
await cmd.execute();
this.undoStack.push(cmd);
return true;
}
}
- 异步命令的
undo可能也需要网络请求,必须处理失败情况 - 需要加锁防止并发执行(前一个命令未完成时不应执行下一个)
- 对于不可逆的操作(如发送邮件),
undo()应标记为无法撤销
Q7: 如何将命令序列化以实现协同编辑或操作回放?
答案:
命令序列化的关键是将命令的类型和参数分离存储,接收端通过命令注册表反序列化:
// 命令数据格式
interface CommandData {
type: string; // 命令类型标识
payload: Record<string, unknown>; // 命令参数
timestamp: number; // 时间戳
userId?: string; // 操作者(协同场景)
}
// 注册表模式
const registry = new Map<string, (p: Record<string, unknown>) => Command>();
// 注册
registry.set('INSERT', (p) => new InsertCommand(doc, p.pos as number, p.text as string));
registry.set('DELETE', (p) => new DeleteCommand(doc, p.pos as number, p.len as number));
registry.set('FORMAT', (p) => new FormatCommand(doc, p.format as string, p.value));
// 序列化 → 通过 WebSocket 传输 → 反序列化执行
ws.onmessage = (event) => {
const data: CommandData = JSON.parse(event.data);
const factory = registry.get(data.type);
if (factory) {
const cmd = factory(data.payload);
cmd.execute(); // 远端执行
}
};
应用场景:
- 协同编辑:将本地命令序列化后广播给其他用户
- 操作回放:记录所有命令,用于 Bug 重现或用户行为分析
- 持久化:将命令历史保存到数据库,下次打开时恢复状态
Q8: 命令模式在 React 项目中如何落地?
答案:
在 React 中,命令模式通常封装为自定义 Hook:
import { useCallback, useRef, useState } from 'react';
interface Command<TState> {
execute(state: TState): TState;
undo(state: TState): TState;
description?: string;
}
function useCommandHistory<TState>(initialState: TState) {
const [state, setState] = useState<TState>(initialState);
const undoStackRef = useRef<Command<TState>[]>([]);
const redoStackRef = useRef<Command<TState>[]>([]);
const [, forceUpdate] = useState(0); // 触发重渲染
const execute = useCallback((command: Command<TState>) => {
setState((prev) => {
const next = command.execute(prev);
undoStackRef.current.push(command);
redoStackRef.current = [];
forceUpdate((n) => n + 1);
return next;
});
}, []);
const undo = useCallback(() => {
const command = undoStackRef.current.pop();
if (!command) return;
setState((prev) => {
const next = command.undo(prev);
redoStackRef.current.push(command);
forceUpdate((n) => n + 1);
return next;
});
}, []);
const redo = useCallback(() => {
const command = redoStackRef.current.pop();
if (!command) return;
setState((prev) => {
const next = command.execute(prev);
undoStackRef.current.push(command);
forceUpdate((n) => n + 1);
return next;
});
}, []);
return {
state,
execute,
undo,
redo,
canUndo: undoStackRef.current.length > 0,
canRedo: redoStackRef.current.length > 0,
historySize: undoStackRef.current.length,
};
}
使用示例:
interface TodoState {
items: string[];
}
// 定义命令
const addTodoCommand = (text: string): Command<TodoState> => ({
execute: (state) => ({ items: [...state.items, text] }),
undo: (state) => ({ items: state.items.slice(0, -1) }),
description: `添加: ${text}`,
});
const removeTodoCommand = (index: number, item: string): Command<TodoState> => ({
execute: (state) => ({
items: state.items.filter((_, i) => i !== index),
}),
undo: (state) => ({
items: [...state.items.slice(0, index), item, ...state.items.slice(index)],
}),
description: `删除: ${item}`,
});
// 在组件中使用
function TodoApp() {
const { state, execute, undo, redo, canUndo, canRedo } =
useCommandHistory<TodoState>({ items: [] });
const handleAdd = (text: string) => execute(addTodoCommand(text));
const handleRemove = (index: number) =>
execute(removeTodoCommand(index, state.items[index]));
// Ctrl+Z / Ctrl+Shift+Z
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
e.shiftKey ? redo() : undo();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [undo, redo]);
return (
<div>
<button onClick={undo} disabled={!canUndo}>撤销</button>
<button onClick={redo} disabled={!canRedo}>重做</button>
{/* 渲染列表... */}
</div>
);
}
在 React 项目中,命令更适合用工厂函数返回普通对象(如上例),而不是用 class 定义。这符合 React 的函数式编程风格,也更容易做不可变状态更新。
Q9: 命令模式的历史栈应该设置多大?有哪些优化策略?
答案:
历史栈大小需要根据场景权衡:
| 场景 | 建议大小 | 原因 |
|---|---|---|
| 文本编辑器 | 100~500 | 文本命令体积小 |
| Canvas 画板 | 30~100 | 快照占内存大 |
| 表单 | 20~50 | 字段少,历史短 |
| 协同编辑 | 不限(持久化) | 需要全量记录 |
优化策略:
- 合并相似命令:连续输入的字符合并为一个命令(防止每敲一个字母都是一条命令)
- 快照 + 增量:Canvas 场景每隔 N 步存一个快照,中间存增量命令
- 惰性快照:Canvas 命令先不保存快照,只在需要撤销时才回溯计算
- LRU 淘汰:超过上限时丢弃最早的命令
- 命令压缩:对连续的同类型命令进行压缩(如连续移动 10 次 → 一次移动到最终位置)
// 合并相似命令的示例
class MergeableHistoryManager {
private undoStack: Command[] = [];
private mergeTimeout = 300; // 300ms 内的连续输入视为同一操作
private lastExecuteTime = 0;
execute(command: Command & { canMerge?(other: Command): boolean; merge?(other: Command): Command }): void {
const now = Date.now();
const lastCommand = this.undoStack[this.undoStack.length - 1] as typeof command;
// 尝试合并
if (
lastCommand &&
now - this.lastExecuteTime < this.mergeTimeout &&
command.canMerge?.(lastCommand)
) {
this.undoStack[this.undoStack.length - 1] = command.merge!(lastCommand);
} else {
command.execute();
this.undoStack.push(command);
}
this.lastExecuteTime = now;
}
}
Q10: 命令模式有哪些缺点?什么时候不应该使用?
答案:
缺点:
- 类爆炸:每个操作都需要一个命令类,操作种类多时类数量激增
- 复杂度增加:简单操作也要封装为命令对象,增加了代码量
- 状态管理难度:命令需要保存足够的上下文信息才能撤销,复杂操作的 undo 逻辑可能很复杂
- 内存占用:大量命令对象和历史记录会占用较多内存
不适用场景:
- 操作不需要撤销/重做、排队、记录
- 操作种类很少且逻辑简单
- 性能敏感且操作频率极高(如每帧都需要创建命令对象)
替代方案对比:
| 场景 | 使用命令模式 | 替代方案 |
|---|---|---|
| 不需要撤销的简单操作 | 过度设计 | 直接调用 |
| 全局状态管理 | 可以但复杂 | Redux/Zustand |
| 简单的状态快照恢复 | 可以但过度 | 备忘录模式 |
| 需要撤销 + 排队 + 序列化 | 最佳选择 | -- |
备忘录模式保存的是"状态快照",命令模式保存的是"操作本身"。对于小状态,备忘录更简单;对于大状态(如 Canvas),命令模式更节省内存。两者也可以结合使用(命令 + 检查点快照)。
相关链接
- Refactoring.Guru - 命令模式
- 设计在线图片编辑器 - Canvas 画板的撤销/重做架构
- 设计富文本编辑器 - 编辑器操作的命令化
- 设计在线协同编辑系统 - 命令序列化与协同
- MDN - Keyboard Event
- Wikipedia - Command Pattern