首页 / 资讯中心 / 文章详情

命令模式:让操作变成可撤销、可排队、可序列化的对象

命令模式:让操作变成可撤销、可排队、可序列化的对象 ★ FEATURED ARTICLE
1. 命令模式不是“发号施令”而是把“动作”变成可存储、可撤销、可排队的对象你有没有遇到过这样的场景写一个图形编辑器用户拖拽一个矩形后想按 CtrlZ 撤销或者开发一个智能家居控制面板需要把“打开空调→调至26℃→开启除湿”这一连串操作打包成一个宏命令下次一键触发又或者在后台任务系统里要把“生成日报PDF→邮件发送→归档到S3”这三个步骤解耦让调度器只管“执行任务”不关心具体干了什么——这些需求背后其实都藏着同一个设计模式的影子命令模式Command Pattern。很多人第一反应是“不就是封装个函数调用吗Java里写个RunnablePython里传个lambdaJavaScript里塞个callback有啥好讲的”这恰恰是命令模式最容易被低估的地方。它远不止是“把方法包起来”。真正的命令模式核心在于将请求的发起者Invoker与请求的执行者Receiver彻底解耦并赋予请求本身完整的生命周期管理能力。换句话说它让“做一件事”这个行为从一段瞬时执行的代码变成了一个可以被创建、保存、传递、重放、撤销、排队、甚至序列化的第一类对象First-Class Object。我最早在某跨平台图像处理Demo中踩过一次典型坑当时用一个全局事件总线监听“旋转画布”“缩放图层”“切换滤镜”等操作所有处理逻辑直接写在事件回调里。结果很快发现三件事根本没法做一是无法实现多级撤销因为回调里没有“反向操作”的定义二是无法做操作历史回放比如用户说“把刚才那三步再走一遍”但回调里只有执行逻辑没有记录“是什么操作”三是UI按钮状态同步混乱比如“撤销”按钮该不该亮得靠一堆散落的布尔变量手动维护。后来重构时引入命令模式把每个操作抽象成RotateCanvasCommand、ZoomLayerCommand、ApplyFilterCommand三个类每个类内部同时持有execute()和undo()方法并通过一个统一的CommandHistory栈来管理。改完之后撤销/重做逻辑从200行状态判断代码压缩成不到30行的栈操作操作历史回放变成遍历命令列表调用execute()按钮状态则由CommandHistory.canUndo()直接驱动。这种转变不是语法糖而是架构层面的降维打击。命令模式的关键词从来不是“封装”而是可组合性、可追溯性、可控制性。它解决的不是“怎么调用函数”而是“如何让系统对‘行为’本身拥有主权”。当你需要回答“这个操作是谁发起的它依赖哪些参数它是否成功它能否被取消它是否已被执行过它能否被记录到日志它能否被异步执行”这些问题时命令模式就不再是可选项而是必选项。它天然适配的场景非常明确需要撤销/重做、需要宏命令/批处理、需要任务队列/延迟执行、需要操作审计与日志追踪、需要GUI控件与业务逻辑解耦。接下来我们就一层层拆开它的骨架看看这个看似简单的模式到底在哪些关键节点上做了精妙的设计取舍。2. 四要素缺一不可命令接口、具体命令、接收者、调用者——它们如何各司其职又彼此制衡命令模式的结构看似简单常被概括为“四个角色”但若只停留在名词解释层面很容易写出一堆“假命令”——表面符合UML图实则丧失了模式的核心价值。真正理解它必须看清这四个要素之间精确的职责边界与严格的依赖流向。我们以一个真实的智能家居控制场景为例逐步构建并验证这个结构。2.1 命令接口Command Interface契约而非容器定义的是“能力协议”首先命令接口绝不是为了“统一类型”而存在的空壳。它的核心作用是强制约定所有命令对象必须对外暴露的一组标准能力。最常见的接口定义如下以TypeScript为例interface Command { execute(): void; undo(): void; // 可选用于日志或UI显示的描述 getDescription(): string; }注意这里的关键点execute()和undo()是对称且强制的。很多初学者只实现execute()把undo()留空或抛异常这直接废掉了命令模式一半的价值。undo()不是“锦上添花”而是“撤销能力”的契约体现。如果某个操作天然不可逆比如“发送短信”那它就不应该被建模为一个标准命令而应单独处理——这是模式的边界意识。更深层的设计意图在于接口定义的是“命令能做什么”而不是“命令知道怎么做”。它不包含任何接收者Receiver的引用也不持有执行所需的参数。它只是一个纯净的、无状态的协议声明。这保证了命令接口的极度稳定——未来增加新命令无需修改接口调用者Invoker也只需依赖这个接口完全不知道背后是哪个具体类在干活。提示在强类型语言中务必使用接口Interface而非抽象类Abstract Class定义命令。抽象类容易诱使开发者在其中塞入共用逻辑比如日志打印这会污染命令的纯粹性违背“命令只负责定义行为契约”的原则。2.2 具体命令ConcreteCommand参数绑定器与行为翻译器真正的“胶水层”具体命令类才是命令模式的“心脏”。它的唯一且核心职责是在构造时接收所有必要的参数包括接收者实例和执行所需数据并在execute()和undo()方法中精准地将这些参数“翻译”成对接收者Receiver的具体方法调用。继续智能家居的例子。假设有一个LightReceiver类负责实际控制灯泡class LightReceiver { private isOn: boolean false; private brightness: number 50; turnOn() { console.log(灯已打开); this.isOn true; } turnOff() { console.log(灯已关闭); this.isOn false; } setBrightness(level: number) { console.log(亮度设为 ${level}%); this.brightness level; } }那么一个“打开灯并调至80%亮度”的具体命令应这样实现class TurnOnAndBrightenCommand implements Command { // 构造时注入接收者和参数——这是关键 constructor( private receiver: LightReceiver, private targetBrightness: number 80 ) {} execute(): void { // 将“打开灯并调亮”这个高层意图 // 翻译成对receiver的两个具体方法调用 this.receiver.turnOn(); this.receiver.setBrightness(this.targetBrightness); } undo(): void { // 精确对应execute的逆操作 this.receiver.turnOff(); // 注意undo不需要恢复到之前的亮度值 // 因为turnOff已经隐含了“关灯亮度0”的语义 } getDescription(): string { return 打开灯并设置亮度为${this.targetBrightness}%; } }这里最易错的点在于具体命令必须持有接收者实例的引用但绝不能持有调用者Invoker的引用。前者是它完成工作的必要条件没有接收者它不知道找谁干活后者则是严重的设计污染会让命令和调用者产生循环依赖破坏解耦。参数如targetBrightness必须在构造时传入并固化而不是在execute()时动态获取——这保证了命令的可重现性同一个命令实例无论何时何地调用execute()结果都一致。2.3 接收者Receiver真正的干活者与命令模式“零感知”接收者是系统中实际执行业务逻辑的类比如上面的LightReceiver或者一个FileService、DatabaseConnection。它的最大特点是它完全不知道命令模式的存在。它没有实现Command接口也不引用任何命令相关类。它只提供清晰、稳定的、面向业务的方法turnOn(),saveToFile(),queryUser()。这种“无知”是命令模式威力的源泉。正因为接收者不依赖命令所以你可以为同一个接收者创建无数种命令TurnOnCommand,TurnOffCommand,ToggleCommand,DimBy10Command你可以将同一个命令应用到不同的接收者上一个SetTemperatureCommand既能控制空调也能控制地暖只要它们都有setTemperature()方法你可以独立地测试接收者Mock掉所有外部依赖只测其方法逻辑而不必牵扯命令和调用者。注意接收者的方法设计本身会影响命令的健壮性。例如LightReceiver.turnOn()如果内部还调用了网络API那TurnOnCommand.execute()就可能失败。此时应在命令的execute()中捕获异常并提供合理的错误处理如try...catch后调用onError()回调而不是让接收者自己处理——这是职责分离的体现。2.4 调用者Invoker行为的“调度中心”只认接口不认实现调用者是命令模式的“指挥官”但它指挥的不是人而是命令对象。它的典型形态是一个类内部持有一个或多个Command类型的引用并提供invoke()、execute()或类似方法来触发命令。一个最简化的调用者class SimpleInvoker { private command: Command | null null; setCommand(cmd: Command): void { this.command cmd; } executeCommand(): void { if (this.command) { this.command.execute(); } } }高级调用者则更强大比如支持队列和撤销的CommandManagerclass CommandManager { private history: Command[] []; private redoStack: Command[] []; execute(command: Command): void { command.execute(); this.history.push(command); // 清空redo栈因为新操作后无法重做之前的 this.redoStack []; } undo(): void { if (this.history.length 0) { const lastCommand this.history.pop()!; lastCommand.undo(); this.redoStack.push(lastCommand); } } redo(): void { if (this.redoStack.length 0) { const command this.redoStack.pop()!; command.execute(); this.history.push(command); } } }调用者的核心约束是它只能依赖Command接口绝不能依赖任何具体命令类如TurnOnAndBrightenCommand。这意味着调用者代码在编译期就与所有具体业务逻辑完全隔离。你可以随时替换掉TurnOnAndBrightenCommand的实现甚至换成一个网络请求命令只要它实现了Command接口调用者就无需改动一行代码。这就是“开闭原则”对扩展开放对修改关闭的完美实践。3. 为什么命令模式能天然支撑撤销/重做——深入execute()与undo()的对称性设计撤销/重做功能是命令模式最耀眼的应用但很多人只知其然不知其所以然。它并非魔法而是源于execute()与undo()这一对方法在设计哲学、实现逻辑和状态管理上的精密对称。理解这种对称性是避免写出“伪撤销”比如只清空UI状态却不恢复数据的关键。3.1 对称性的根基命令对象必须是“有状态的快照”一个能正确撤销的命令其本质是一个捕获了执行前完整上下文的快照Snapshot。这个快照不仅包含要执行的动作更关键的是包含执行前接收者的关键状态。我们来看一个极易出错的反例// ❌ 错误示范没有保存状态undo无法还原 class BadVolumeUpCommand implements Command { constructor(private receiver: AudioReceiver) {} execute(): void { this.receiver.increaseVolume(); // 假设此方法内部读取当前音量1 } undo(): void { this.receiver.decreaseVolume(); // 同样只是-1但可能不是回到原值 } }问题在哪increaseVolume()和decreaseVolume()是“相对操作”它们依赖于执行时的实时状态。如果在execute()和undo()之间有其他命令比如另一个VolumeUpCommand修改了音量那么undo()就会把音量降到错误的值。真正的撤销必须是绝对操作undo()必须确保音量被设置回execute()开始前的那个精确数值。因此正确的做法是// ✅ 正确示范保存执行前状态undo执行绝对赋值 class VolumeUpCommand implements Command { private previousVolume: number 0; private newVolume: number 0; constructor(private receiver: AudioReceiver) {} execute(): void { // 在执行任何操作前先捕获当前状态 this.previousVolume this.receiver.getVolume(); this.newVolume Math.min(this.previousVolume 1, 100); this.receiver.setVolume(this.newVolume); } undo(): void { // 绝对赋值回之前的状态 this.receiver.setVolume(this.previousVolume); } }这个previousVolume字段就是命令对象作为“快照”的核心证据。它证明了该命令实例在创建和执行时已经将“世界状态”的一个切片牢牢锁定了。undo()的工作就是把这个切片“贴”回去。3.2 复杂场景多步操作与状态依赖的原子性保障现实中的操作往往不是单步的。比如“保存文档”命令可能包含1) 序列化内容2) 写入临时文件3) 原子性地替换主文件4) 更新UI状态。它的undo()需要精确逆转每一步且必须保证整个execute()过程的原子性——要么全部成功要么全部回滚不能留下半成品。一个健壮的SaveDocumentCommand实现会这样设计class SaveDocumentCommand implements Command { private originalContent: string ; private originalFilePath: string ; private tempFilePath: string ; constructor( private document: Document, private fileService: FileService ) {} execute(): void { try { // 1. 记录原始状态快照 this.originalContent this.document.getContent(); this.originalFilePath this.document.getFilePath(); // 2. 执行第一步序列化并写入临时文件 const content this.document.serialize(); this.tempFilePath this.fileService.generateTempPath(); this.fileService.writeToFile(this.tempFilePath, content); // 3. 执行第二步原子性替换关键 this.fileService.atomicReplace(this.originalFilePath, this.tempFilePath); // 4. 执行第三步更新文档内部状态 this.document.markAsSaved(); // 5. 成功后清理临时路径可选 this.tempFilePath ; } catch (error) { // ⚠️ 关键任何一步失败立即执行局部回滚 this.rollback(); throw error; } } undo(): void { // 仅当execute完全成功后undo才被调用 // 此时originalContent和originalFilePath是可靠的 this.document.setContent(this.originalContent); this.document.setFilePath(this.originalFilePath); this.document.markAsUnsaved(); } private rollback(): void { // 清理可能残留的临时文件 if (this.tempFilePath this.fileService.exists(this.tempFilePath)) { this.fileService.deleteFile(this.tempFilePath); } } }这里体现了两个重要原则前置快照所有undo()所需的数据必须在execute()的最开始就捕获完毕。后续任何中间状态都不应被undo()依赖。异常即回滚execute()内部的try...catch不是为了静默吞掉错误而是为了在失败时主动调用rollback()清理副作用保证系统不会进入脏状态。这使得undo()方法可以放心地假设它只会在execute()完全成功后被调用。3.3 撤销栈的工程实践内存、性能与用户体验的三角平衡理论很美但工程落地要考虑现实约束。一个无限增长的撤销栈会吃光内存。某次我在优化一个大型CAD软件的撤销功能时就遇到了这个问题用户连续绘制1000个点每个点生成一个DrawPointCommand每个命令都保存了坐标、图层ID、样式等数据最终撤销栈占用了近200MB内存。解决方案不是简单地限制栈大小比如只存最后50个而是分层设计撤销层级存储内容生命周期适用场景精细撤销完整命令对象含所有参数和快照短期最近10步用户高频使用的CtrlZ粗粒度合并命令摘要如“绘制了100个点” 指向原始命令的弱引用中期最近100步支持“撤销上一组操作”磁盘持久化命令序列化JSON不含二进制数据长期会话级应用崩溃后恢复操作历史具体实现上CommandManager会维护一个CommandHistory类它内部是一个双向链表。当新命令加入时如果是普通命令直接推入链表尾部如果是“宏命令”如批量绘制则将其包装为一个MacroCommand内部聚合子命令但对外仍是一个Command当链表长度超过阈值如100则将最老的Command对象的引用置为null促使其被GC回收但保留其getDescription()生成的摘要字符串。实操心得永远不要在undo()里做耗时操作如网络请求、大文件IO。如果必须应将undo()设计为异步并提供isUndoing()状态供UI禁用按钮。否则用户按CtrlZ时界面卡死体验极差。4. 命令模式的高阶变体宏命令、队列命令与参数化命令——从单点操作到系统级编排当命令模式走出单个按钮点击的范畴它就开始展现出系统级的编排能力。宏命令Macro Command、队列命令Queue Command和参数化命令Parameterized Command是三个最实用、也最容易被误解的变体。它们不是“新模式”而是对基础命令模式的自然延伸其力量来源于命令对象的组合性与可编程性。4.1 宏命令MacroCommand命令的“函数式组合”实现原子化批处理宏命令的本质是一个命令其内部封装了多个其他命令的有序执行。它让“执行一组操作”这件事本身变成了一个可以被撤销、被重做、被放入队列的单一单元。一个典型的宏命令实现class MacroCommand implements Command { private commands: Command[]; constructor(commands: Command[]) { this.commands [...commands]; // 浅拷贝避免外部修改 } execute(): void { // 顺序执行所有子命令 for (const cmd of this.commands) { cmd.execute(); } } undo(): void { // 逆序撤销所有子命令LIFO for (let i this.commands.length - 1; i 0; i--) { this.commands[i].undo(); } } getDescription(): string { return 宏命令${this.commands.map(c c.getDescription()).join( → )}; } }关键点在于undo()的逆序执行。这保证了“撤销一个宏命令”等价于“撤销其内部所有命令的逆序操作”从而维持了操作的原子性。例如一个“创建新项目”宏命令包含1) 创建目录2) 初始化Git仓库3) 生成配置文件。撤销时必须先删除配置文件再销毁Git仓库最后删除目录——顺序错了就会出错。宏命令的威力在于可嵌套性。一个宏命令的子命令可以是另一个宏命令。这形成了树状结构让复杂工作流的建模变得直观。某次我参与一个自动化测试平台开发测试用例的执行流程被建模为RunTestCaseCommand根宏命令SetupEnvironmentCommandExecuteTestStepsCommand它本身也是一个宏命令LoginCommandNavigateToPageCommandFillFormCommandTeardownEnvironmentCommand整个流程的撤销只需要调用最外层RunTestCaseCommand.undo()它会自动递归地、逆序地撤销所有子步骤。这种结构让测试脚本的调试和复现变得极其简单。注意宏命令的execute()和undo()必须是幂等的。即多次调用execute()不应导致重复效果比如重复创建目录多次调用undo()也不应报错比如重复删除已不存在的目录。这通常需要在子命令内部做存在性检查。4.2 队列命令QueueCommand为命令添加“时间维度”实现异步、延迟与重试基础命令模式是同步的execute()调用后立刻执行。但在分布式系统、消息队列或后台任务中我们需要命令具备“延后执行”、“失败重试”、“优先级调度”等能力。这时就需要一个QueueCommand来包装原始命令。QueueCommand的核心不是改变命令的逻辑而是为其附加执行策略Execution Policyinterface ExecutionPolicy { delayMs?: number; // 延迟执行毫秒数 maxRetries?: number; // 最大重试次数 retryDelayMs?: number; // 重试间隔 priority?: number; // 优先级数字越小越高 } class QueueCommand implements Command { private policy: ExecutionPolicy; private wrappedCommand: Command; private id: string; constructor( command: Command, policy: ExecutionPolicy {} ) { this.wrappedCommand command; this.policy policy; this.id generateUniqueId(); } execute(): void { // 将命令及其策略提交给一个中央任务队列 TaskQueue.submit({ id: this.id, command: this.wrappedCommand, ...this.policy }); } undo(): void { // 从队列中取消该任务如果尚未执行 TaskQueue.cancel(this.id); } getDescription(): string { return 队列命令(${this.id}): ${this.wrappedCommand.getDescription()}; } }这里TaskQueue是一个独立的服务它负责按delayMs延迟投递在execute()抛出异常时根据maxRetries和retryDelayMs自动重试按priority对待执行的命令进行排序。QueueCommand的精妙之处在于它没有侵入原始命令的逻辑只是给它加了一层“执行外壳”。原始的SendEmailCommand依然只关心“怎么发邮件”而QueueCommand则负责“什么时候发、发几次、失败了怎么办”。这种关注点分离让系统各部分高度内聚。4.3 参数化命令ParameterizedCommand用“模板”代替“硬编码”实现命令的泛化复用很多时候我们面对的不是“打开灯”或“关闭灯”这种固定操作而是“对任意设备执行任意操作”。如果为每个设备-操作组合都写一个具体命令类代码会爆炸式增长。参数化命令通过将接收者和操作名作为运行时参数传入解决了这个问题。一个通用的参数化命令class ParameterizedCommand implements Command { private receiver: any; // 接收者实例 private methodName: string; // 要调用的方法名 private args: any[]; // 方法参数 private backupState: any; // 用于undo的备份 constructor(receiver: any, methodName: string, ...args: any[]) { this.receiver receiver; this.methodName methodName; this.args args; } execute(): void { // 使用反射或代理调用 if (typeof this.receiver[this.methodName] function) { // 备份执行前状态如果该方法有副作用 this.backupState this.captureStateForUndo(); this.receiver[this.methodName](...this.args); } } undo(): void { if (this.backupState) { this.restoreStateFromBackup(); } } private captureStateForUndo(): any { // 根据methodName智能选择备份策略 // 例如对setVolume备份当前音量对turnOn备份isOn状态 switch (this.methodName) { case setVolume: return this.receiver.getVolume(); case turnOn: return this.receiver.isOn(); default: return null; } } private restoreStateFromBackup(): void { if (this.backupState null) return; switch (this.methodName) { case setVolume: this.receiver.setVolume(this.backupState); break; case turnOn: this.receiver.turnOn(); break; case turnOff: this.receiver.turnOff(); break; } } }这个ParameterizedCommand可以这样使用const light new LightReceiver(); const ac new AirConditionerReceiver(); // 一行代码创建任意命令 const openLight new ParameterizedCommand(light, turnOn); const setAcTemp new ParameterizedCommand(ac, setTemperature, 26); const dimLight new ParameterizedCommand(light, setBrightness, 30);它牺牲了一点类型安全需要运行时检查但换来了极致的灵活性。在配置驱动的系统中比如低代码平台用户在界面上选择“设备”、“动作”、“参数”后台就动态生成一个ParameterizedCommand并执行整个过程无需编写任何新代码。实操避坑参数化命令的undo()逻辑必须足够智能。不能简单地“调用一个反向方法”比如turnOn的反向是turnOff而要基于methodName和backupState做精确恢复。否则在复杂状态机中会失效。5. 命令模式的实战陷阱与避坑指南那些文档里不会写的血泪教训命令模式的理论很清晰但真正在大型项目中落地时会遇到一堆文档里绝不会提及的“灰色地带”问题。这些问题往往不会导致编译失败却会让系统在特定条件下悄然崩溃或者让维护者陷入无尽的调试深渊。以下是我在多个项目中踩过的、代价最高的五个坑每一个都附带了可直接复用的解决方案。5.1 陷阱一命令对象的“内存泄漏”——忘记清除对大型对象的引用命令对象一旦被创建就可能长期驻留在撤销栈中。如果它在构造时不小心持有了对一个大型数据结构比如一个包含10万条记录的DataTable的引用那么即使这个DataTable在业务逻辑中已经不再需要它也无法被垃圾回收器GC释放因为它被命令对象“钉住”了。真实案例在一个报表生成系统中ExportToExcelCommand的构造函数接收了一个ReportData对象该对象内部包含一个巨大的rows: Recordstring, any[]数组。用户导出一次报表就有一个ExportToExcelCommand实例进入撤销栈。当用户连续导出10次不同报表后内存占用飙升GC频繁触发UI卡顿。根本原因ExportToExcelCommand不需要ReportData的全部数据来执行undo()它只需要知道“导出了哪个报表ID”即可。undo()的工作只是从服务器删除那个Excel文件与原始数据无关。解决方案严格遵循“最小必要原则”注入参数。重构ExportToExcelCommand// ❌ 错误注入了整个大数据对象 class ExportToExcelCommand { constructor(private reportData: ReportData) {} // 危险 } // ✅ 正确只注入undo所需的关键ID class ExportToExcelCommand { constructor( private reportId: string, private fileService: FileService ) {} execute(): void { const data this.fileService.fetchReportData(this.reportId); // 按需加载 this.fileService.exportToExcel(data, ${this.reportId}.xlsx); } undo(): void { this.fileService.deleteExportedFile(${this.reportId}.xlsx); } }提示在代码审查时对所有具体命令类的构造函数参数进行“用途审计”这个参数在execute()中用到了吗在undo()中用到了吗如果两者都没用立刻移除如果只在execute()中用考虑是否能在execute()内部按需获取而非在构造时注入。5.2 陷阱二撤销栈的“状态漂移”——命令执行与UI更新不同步这是GUI应用中最隐蔽的Bug。现象是用户点击“撤销”UI看起来回退了但底层数据没变或者反之。根源在于execute()和undo()修改了接收者Receiver的状态但UI组件View并不知道这个变化它还在监听旧的、未更新的数据源。典型场景一个UpdateUserProfileCommand执行后它调用了userApi.update(profile)API返回成功execute()结束。但UI的ProfileView组件是通过一个ObservableUser来订阅用户数据的。如果userApi.update()没有触发这个Observable发出新值那么ProfileView就不会刷新用户看到的还是旧资料。解决方案命令模式必须与响应式数据流深度集成。有三种可靠方式接收者即数据源让UserReceiver本身就是一个Observable或Store。UpdateUserProfileCommand.execute()在调用API后必须显式地this.userStore.next(updatedProfile)。这样所有订阅者包括UI自动更新。命令发布事件在execute()和undo()的末尾发布一个CommandExecutedEvent或CommandUndoneEvent携带变更后的数据。UI组件监听这些事件并更新自身。调用者统一通知CommandManager在execute()成功后触发一个onCommandExecuted(command: Command, result: any)回调由上层框架如React的Context或Vue的Pinia来处理状态同步。关键经验永远不要假设“调用了方法UI就一定会更新”。在命令模式中UI更新必须是显式、可控、可测试的环节它应该被当作命令执行流程的一个正式步骤而不是一个“顺便发生”的副作用。5.3 陷阱三多线程环境下的“撤销竞态”——并发执行导致撤销错乱在桌面应用或Node.js服务中如果多个线程/进程可以同时调用CommandManager.execute()和CommandManager.undo()撤销栈一个数组就可能成为竞态条件Race Condition的温床。问题复现线程A执行execute(cmd1)history.push(cmd1)正在进行线程B同时执行undo()history.pop()也在进行数组的push和pop操作不是原子的可能导致history数组损坏undo()返回undefined进而崩溃。解决方案在CommandManager内部实现一个轻量级的互斥锁Mutex。对于JavaScript可以使用一个isProcessing标志位配合Promise队列class ThreadSafeCommandManager { private history: Command[] []; private redoStack: Command[] []; private isProcessing: boolean false; private pendingTasks: Array() Promisevoid []; private async ensureSingleExecutionT(task: () PromiseT): PromiseT { if (!this.isProcessing) { this.isProcessing true; try { return await task(); } finally { this.isProcessing false; // 执行下一个等待的任务 if (this.pendingTasks.length 0) { const next this.pendingTasks.shift()!; await this.ensureSingleExecution(next); } } } else { return new Promise((resolve) { this.pendingTasks.push(async () { const result await task(); resolve(result); }); }); } } async execute(command: Command): Promisevoid { await this.ensureSingleExecution(async () { command.execute(); this.history.push(command); this.redoStack []; }); } async undo(): Promisevoid { await this.ensureSingleExecution(async () { if (this.history.length 0) { const lastCommand this.history.pop()!; lastCommand.undo(); this.redoStack.push(lastCommand); } }); } }这个方案不依赖外部库用纯JS实现了“串行化”执行成本极低且完全透明。5.4 陷阱四序列化与反序列化——命令对象的“跨时空旅行”难题当需要将命令历史持久化到磁盘或通过网络发送给另一个服务时命令对象必须能被序列化JSON.stringify和反序列化JSON.parse。但原始的命令类通常包含函数、闭包、对this的引用这些都无法被JSON处理。错误尝试// ❌ 这样序列化会丢失所有方法和私有字段 const cmd new TurnOnCommand(lightReceiver); const json JSON.stringify(cmd); // {receiver: {}, targetBrightness: 80} —— receiver被序列化为空对象正确方案采用“命令描述符Command Descriptor”模式。命令对象本身不序列化而是定义一个
阅读完成 · 觉得有帮助?
咨询建站