手写撤销管理器:命令模式与状态快照的实战指南

发布时间:2026/9/26 19:10:30
手写撤销管理器:命令模式与状态快照的实战指南 简介撤销/重做Undo/Redo管理是文本编辑器等桌面应用的关键功能这套代码包即针对这一场景面向需要自行实现操作历史栈与动作回放的C/MFC开发者。它提供了从核心撤销管理器到编辑视图动作捕获、命令菜单集成等完整模块覆盖普通文本输入、增强编辑、替换及拖放操作等不同层级的撤销/重做逻辑。压缩包总共61个文件其中31个.h头文件声明接口28个.cpp源文件实现细节另含1个文本变更记录和1个界面资源脚本整体约70KB结构紧凑便于按模块阅读。代码通过自定义Action对象封装每次编辑操作配合文本范围等数据结构定位修改范围同时提供多步撤销/重做菜单方便用户灵活回退。目前已有154人学习浏览若希望深入理解操作栈、命令模式、动作对象化等设计思路或快速复用一套现成的Undo/Redo框架可直接参考其整体结构设计与各动作类实现。1. undo_manager 到底在解决什么问题undo_manager 这名字就说明白了管撤销的。我第一次见到 undo_manager.zip 的时候第一反应是又有哪个编辑器要把 CtrlZ 补上了。绝大多数团队里撤销不是一开始就设计的往往是需求方突然发现“误删的图层找不回来”“连续改了十几个配置想回到改之前”才回头补一套可撤销体系。undo_manager 做的事情就是把用户的每一步操作记成可回放、可反转的命令让应用在任何一步都能退回去、再走回来同时处理撤销多少步、合并连续操作、跨会话恢复这些衍生问题。它适合富文本编辑器、低代码表单设计器、Canvas 绘图工具、音视频非编台本这类状态高频变化的应用。如果你在做这类产品下面的内容就是给你准备的实战笔记目标是让你看完能自己写一个可用的撤销管理器而不是去赌某个黑盒 zip 的接口好不好使。2. 先选方案再写代码快照、命令与混合策略撤销管理器第一步不是写代码而是决定“撤销以什么粒度存”。这个决定直接决定后续内存怎么控制、合并怎么做、序列化怎么写。常见方案有三类全量状态快照、命令模式、以及两者的混合。用错方案后面每一个功能都会别扭。2.1 状态快照实现最直白但内存账要算清状态快照的思路非常直接每次操作完成后把整个应用状态深拷贝一份压进历史栈。撤销时把栈顶的旧状态回填到当前状态。实现只有几行代码type State unknown; const history: State[] []; function snapshot(state: State) { history.push(clone(state)); } function undo(): State | undefined { return history.pop(); }逻辑很简单但坑都在 clone 上。浅拷贝会把对象引用共享给历史版本后续修改当前状态时历史记录里存的“旧状态”也跟着变了等于撤销历史被污染。所以这里必须是深拷贝或者使用 Immutable.js、Immer 这类不可变数据结构让结构共享承担拷贝成本。不可变数据结构是状态快照能规模化使用的真正前提不然每次操作都是全量深拷贝性能很快撑不住。参数上要关心两个快照频率和栈长度上限。快照频率控制“每步都存还是隔几步存”栈长度决定最多能撤销多少步。内存账很好算假设一个文档状态序列化成 JSON 是 1MB保留 100 步就是 100MB这在桌面应用里已经是不可忽略的数字了。所以状态快照只适合文档短、操作低频、状态体积小的场景。长会话、大文档、频繁操作这三样占全了还硬用快照最后肯定是从内存上翻车。2.2 命令模式把每一步操作变成可逆对象命令模式是 undo_manager 里最主流的做法思想是每个操作封装成一个对象对象里有正向执行的 execute() 和反向执行的 undo()。撤销时调 undo()重做时再调 execute()。它只存增量不存全量内存压力比快照小很多而且每个操作的反向语义写在自己类里新增操作类型时不需要改管理器核心。举一个移动图层的命令class MoveCommand { constructor( private target: Layer, private dx: number, private dy: number ) {} execute(): void { this.target.x this.dx; this.target.y this.dy; } undo(): void { this.target.x - this.dx; this.target.y - this.dy; } }代码说明move 的反向语义就是减回去。注意 target 这个对象引用在命令创建时就被固化了如果后续业务流程把图层对象替换成新实例命令的 undo() 会改一个没人看的旧对象。这个问题很常见后面避坑章节会专门展开。命令模式还有一种常用组合技巧用 CompositeCommand 把多个原子命令打包成一个事务命令。典型场景是表单设计器里“拖入新组件并绑定事件”一次操作改了两处状态如果只记一个原子命令用户按一次撤销会发现事件没解绑。事务命令让整批操作要么全部撤销、要么全部保留体验上才符合直觉。2.3 混合策略编辑器为什么两边都要真实产品里快照和命令不是二选一。文本编辑器通常用命令栈处理打字、删除这类高频轻量操作但遇到“全文格式化”“导入十几兆的外部文档”这类重量级变更时会额外压入一个全量状态作为新基线。基线之前的历史全部丢弃基线之后继续用命令记录。这个思路解决了命令栈无限增长的问题也让撤销到某个“关键节点”变得非常快。两种方案的取舍可以看这张表维度状态快照命令模式混合基线命令内存随状态大小线性增长只存增量增量为主按基线压缩实现复杂度低中高撤销粒度一次一个快照原子命令事务基线边界适合场景短文档、低频操作编辑器、画布工具长会话、超大文档选型时我问自己三个问题用户一次操作会产生多少数据操作能不能写出明确的反向逻辑状态能不能整体序列化数据量小就用快照撑住第一批用户操作天然可逆就上命令状态里带着 DOM 节点、Canvas 原生对象这类没法序列化的东西就只能用命令别硬做快照。我一般会定这样一个原则状态体积在几百 KB 以下且操作频率低的模块先用快照进入第二个迭代再做命令化改造。不要一上来就堆复杂度撤销管理器是可以渐进演进的。3. 手写一个 undo_manager命令栈与游标的完整实现方案定了落到代码。这里用 TypeScript 写一个最小可用的 undo_manager核心是一个命令栈加一个游标。我把完整实现拆成三段讲数据结构、execute、undo/redo每段都能直接抄走改到自己的项目里。3.1 核心数据结构命令接口、单栈与游标先定义命令接口所有操作类型都要实现它// undo-manager.ts export interface UndoableCommand { readonly id: string; // 命令类型标识用于合并判断 execute(): void; undo(): void; canMerge?(next: UndoableCommand): boolean; merge?(next: UndoableCommand): void; }id 是后面做合并和序列化的钥匙一定要稳定不要用中文名或临时生成的自增 ID。canMerge 和 merge 是可选方法只有需要合并连续操作时才算实现后面第 4 章会详细讲。管理器的骨架用单栈加游标export class UndoManager { private stack: UndoableCommand[] []; private index 0; // 游标指向下一个可 redo 的位置 get canUndo(): boolean { return this.index 0; } get canRedo(): boolean { return this.index this.stack.length; } clear(): void { this.stack []; this.index 0; } }参数说明index 的语义是“当前状态处于栈中的第几步”。栈被分成两段0 到 index-1 是已执行命令index 到 length-1 是撤销掉、等待重做的命令。为什么不用两个栈双栈法在“新操作发生后清空 redo 栈”时要额外 splice 或重建单栈只要把 length 截到 index 就行更直观。3.2 execute合并判断、入栈与 redo 分支清理execute 是管理器里逻辑最重的方法它要处理合并窗口和 redo 分支失效export class UndoManager { private mergeWindowMs 500; execute(cmd: UndoableCommand): void { const prev this.stack[this.index - 1]; const now Date.now(); if (prev prev.canMerge prev.canMerge(cmd)) { const prevTime (prev as any).executedAt ?? 0; if (now - prevTime this.mergeWindowMs) { prev.merge!(cmd); (prev as any).executedAt now; this.emitChange(); return; } } this.stack.length this.index; // 新操作让 redo 分支整体失效 this.stack.push(cmd); this.index; cmd.execute(); (cmd as any).executedAt now; this.emitChange(); } }逻辑说明先看栈顶命令是否支持合并。如果支持且它上一次执行时间在当前时间窗口内就把新命令 merge 进栈顶命令不增加栈深度然后直接返回。这样连续输入 20 个字符在历史里只是一条命令。如果窗口已经过期或栈顶不支持合并就走正常入栈分支。两个细节要注意。第一this.stack.length this.index这行是 redo 语义正确性的关键。用户在撤销两步之后执行了新操作原来的 redo 分支全部作废如果不截断用户会从旧分支里重做出错误状态。第二我把cmd.execute()放在入栈之后执行。如果 execute 抛异常命令已经从栈里推入状态可能半改。生产环境建议在外面包一层 try/catch捕获异常后立即执行一次 undo() 回滚再通知上层。这里为了讲清主流程先不把异常处理塞进来。合并分支里为什么也要把 executedAt 更新因为合并后的命令是栈顶命令它的时间戳如果还停留在第一次执行时下一次操作可能因为时间差超过窗口而拒绝合并导致类型相同、应该合并的操作被拆开。时间戳跟随执行频度滚动合并窗口才有意义。3.3 undo 与 redo先执行再移动游标undo 和 redo 是对称的两个方法export class UndoManager { undo(): void { if (!this.canUndo) return; const cmd this.stack[this.index - 1]; cmd.undo(); // 先执行反向操作 this.index--; // 游标再往下移 this.emitChange(); } redo(): void { if (!this.canRedo) return; const cmd this.stack[this.index]; cmd.execute(); // 先正向执行 this.index; // 游标再往上移 this.emitChange(); } }逻辑说明顺序不能反。以 undo 为例如果先 index-- 再 cmd.undo()undo 抛异常时游标已经落到错误位置后续状态全部错位。先执行再移动游标才能维持那个不变量游标左侧是已执行的命令游标右侧是待执行的命令。还有一个容易忽略的问题undo() 执行 cmd.undo() 时这条命令仍然留在栈里。它的意义是如果用户不执行任何新操作直接按 redo 能原路返回。一旦中途 execute 了新命令第 3.2 节的截断逻辑会把这条命令连同后面的 redo 分支一起清掉。这是标准的撤销语义很多初版实现没做到用户会看到“撤销之后再改一点旧的重做路径还在”的诡异现象。3.4 跑通一个最小流程连续移动图层把上面的类落到一个简单场景里验证const manager new UndoManager(); const layer { id: layer-1, x: 0, y: 0, visible: true }; class MoveLayerCommand implements UndoableCommand { readonly id layer.move; constructor( private target: typeof layer, private dx: number, private dy: number ) {} execute(): void { this.target.x this.dx; this.target.y this.dy; } undo(): void { this.target.x - this.dx; this.target.y - this.dy; } canMerge(next: UndoableCommand): boolean { return next.id this.id (next as MoveLayerCommand).target this.target; } merge(next: UndoableCommand): void { const n next as MoveLayerCommand; this.dx n.dx; this.dy n.dy; this.target.x n.dx; this.target.y n.dy; // 合并时要把新变更也执行掉 } } manager.execute(new MoveLayerCommand(layer, 10, 0)); manager.execute(new MoveLayerCommand(layer, 10, 0)); manager.execute(new MoveLayerCommand(layer, 0, 20)); console.log(manager.canUndo); // true但栈里只有一条命令 manager.undo(); console.log(layer); // { x: 0, y: 0 }一次撤销全部退回注意 merge 的实现里我同步更新了 dx、dy 和 target 坐标。因为入栈时第一条命令的 execute 已经执行过一次后续合并的新命令如果不当场执行界面就不会变化。很多初版实现只更新 dx/dy 不更新 target结果合并后只撤销了最开始的 10 像素后面的操作像没发生过一样这是命令合并里最典型的一处翻车点。4. 把撤销粒度调到符合直觉合并连续操作、基线压栈与内存裁剪命令栈能跑起来只是第一步。真实用户不会按一次操作按一次撤销他们按住键盘输入半分钟期望一次 CtrlZ 删除一整段。这就要把撤销粒度从“命令级”提升到“用户直觉级”。4.1 连续打字为什么必须合成一条命令文本输入是合并需求最强烈的场景。用户按住键盘 3 秒输入 15 个字符如果每个字符都独立入栈撤销 15 次才能删干净。合并的做法是同一命令 id、同一作用对象、时间窗口内、位置连续的命令合成一条。插入文本的命令可以这样设计class InsertTextCommand implements UndoableCommand { readonly id insert-text; private text: string; constructor( private target: EditorState, private pos: number, text: string ) { this.text text; } canMerge(next: UndoableCommand): boolean { if (next.id ! this.id) return false; const n next as InsertTextCommand; return n.pos this.pos this.text.length; // 位置连续才合并 } merge(next: UndoableCommand): void { const n next as InsertTextCommand; // 合并时把新文本真正塞进去并累加长度 this.target.insert(this.pos this.text.length, n.text); this.text n.text; } execute(): void { this.target.insert(this.pos, this.text); } undo(): void { this.target.delete(this.pos, this.text.length); } }canMerge 的条件是 next 的插入位置等于当前命令已积累文本的末尾这保证了命令之间不交叉、不重叠。merge 里先真正执行插入再累加 text这样当用户按撤销时undo() 一次性删除累积的全部文本。如果漏掉this.target.insert(...)状态更新和命令记录就对不上了。时间窗口参数 mergeWindowMs 我一般调 500 到 800 毫秒。低于 500 会让慢速输入的连续字符被拆开高于 1000 会让用户明显停顿之后的操作也被吞进前一条撤销时多删掉不该删的内容。实际调参要带上输入法一起测中文输入法的 composition 阶段会频繁触发 insert 事件这个窗口要能覆盖住整段 composition。4.2 输入法与焦点切换合并窗口必须强制封口合并窗口不能全靠时间。焦点切换、输入法 composition 结束、回车、鼠标点击工具栏这些事件发生时正在累积的命令必须被“封口”后续操作单独成一条。否则用户输入完中文再点一下加粗按钮撤销一次把加粗和刚才输入的内容一起全撤了。常见做法是在 UndoManager 里暴露一个封口方法flushMergeWindow(): void { const top this.stack[this.index - 1]; if (top) { (top as any).executedAt 0; // 下一次合并判断必然失败 } }逻辑说明把栈顶命令的 executedAt 置成 0下一次 execute 走到合并判断时now - 0一定大于 mergeWindowMs合并自然被拒绝。这个方法要在 input 失焦、compositionend、mousedown 这几个时机调用。前端项目里最容易漏的是 compositionend很多输入法在拼音选字阶段就会触发多次 insert如果不封口用户删选中的一个字会把整句拼音字母全删掉。4.3 基线压栈与内存裁剪长会话不再失控即使做了合并一个重度使用两小时的应用命令栈还是可能堆到几千条。处理办法是引入基线压栈和栈深度裁剪。基线压栈的思路每隔 N 步或每隔几分钟压入一条全量状态命令。这条命令的 undo() 不是反向执行而是直接把整个状态回填成基线时的样子。回退到基线之后基线之后的历史执行过的命令全部丢弃用户可以一次性回到一个“整齐的存档点”。基线间隔我一般按操作步数走每 50 到 100 步压一个如果按时间5 分钟比较合理。基线太密内存吃不消太疏则失去“快速回到关键节点”的意义。栈深度裁剪是兜底手段。给定 limit当栈超过这个深度从栈底裁掉最老的命令trim(limit: number): void { if (this.stack.length limit) return; const dropCount this.stack.length - limit; this.stack.splice(0, dropCount); this.index Math.max(0, this.index - dropCount); }参数说明limit 是栈里保留的最大命令数我一般设 100。dropCount 是裁掉的条数index 是相对栈底的游标所以裁掉多少就减多少如果裁剪超过了当前 index说明撤销位置已经在被裁掉的范围内游标直接归零。这个 version 是简化版如果接入了基线裁剪时要先定位最近的基线尽量让基线留在栈里否则用户会失去把整个会话回退到初始状态的路径。内存上还有一个隐性开销命令对象自身持有的引用。一个删除命令的 undo 可能需要被删节点的完整数据这个数据埋在命令里不释放栈就永远拖着它。trim 时可以对重量级命令调用 release() 方法把不需要的数据置空但要注意 redo 还需要它所以 release 只能在命令被真正丢弃时调用。5. 避坑指南undo_manager 接入真实项目的 5 个高频事故把 undo_manager 接进真实项目最容易出问题的不是管理器本身而是管理器与业务代码之间的边界。这里写 5 个我自己踩过、也在同事代码里反复见到的坑全部按“现象、原因、解决”的套路记录。5.1 撤销后界面不刷新视图与数据脱节现象执行 undo() 之后devtools 里数据结构确实回到了旧值界面却纹丝不动。原因UndoManager 修改的是命令内部维护的对象但视图层组件没有订阅状态变化。很多初版实现只把 undo_manager 当作“数据黑匣子”没有任何事件通知机制UI 自然不知道要重新渲染。解决给 UndoManager 加订阅通道每次 execute、undo、redo、flush 时都触发一次 emit。视图层注册订阅后再刷新对应组件。前端框架里可以用 useSyncExternalStore 或者直接在组件里订阅核心是“状态何时变化”必须由管理器明确广播不能依赖调用方自己记得刷新。5.2 命令对象捕获了过期引用现象连续撤销几次后部分命令像在操作另一个对象界面该回退的节点没反应其他节点反而动了。原因命令构造时把 target 对象引用存了进去但后续业务流程里对象引用被替换。比如从数据库重新加载后按 id 重建了新的 Node 实例命令里存的还是旧实例undo 时改的是没人使用的孤岛对象。解决命令里不存对象引用只存 id、路径这类稳定标识。执行和撤销时通过 id 从当前容器里取实时对象再施加变更。代价是每次 undo/redo 多一次查找开销换来的是命令与业务对象生命周期解耦。数据量大时可以先在构造时把引用存在 WeakRef 里取不到再回退到 id 查找但实现复杂度会高出不少一般项目直接按 id 查就够了。5.3 合并判断用了过期状态现象两个同类型操作被错误合并撤销时多撤回了一步或者反过来明显连续的操作被拆开。原因canMerge 里做了过多业务判断读取了某个可变字段而这个字段在两次 execute 之间已经被其他逻辑修改。比如用“当前选区位置”判断是否合并但失焦事件已经把选区清了判断自然出错。解决canMerge 只做“可重放”的纯判断基于命令自身携带的参数做运算比如插入位置是否等于累积末尾、移动目标是否为同一 id。不要读操作之外的全局状态。如果确实要读先通过 flushMergeWindow 把上一个合并窗口封口避免在状态漂移期间做合并决策。5.4 事务命令没按反序回滚现象一次操作改了多个字段撤销时只回来一部分界面残留一半状态。原因CompositeCommand 的 undo() 按正序遍历子命令逐个 undo子命令之间存在依赖顺序比如先移动节点再更新样式回退时得先还原样式再移动节点正序就把依赖关系破坏了。解决事务命令的 undo() 必须按子命令的反序调用并用 try/catch 包住整体。class CompositeCommand implements UndoableCommand { readonly id composite; constructor(private children: UndoableCommand[]) {} execute(): void { for (const child of this.children) child.execute(); } undo(): void { for (let i this.children.length - 1; i 0; i--) { this.children[i].undo(); } } }如果中途某个子命令抛异常当前状态已经回滚了一半这时最好把异常上抛同时让上层记录“未完成回滚”的提示不要静默吞掉。用户宁可看到一次报错也不希望状态停在灰色地带。5.5 内存只涨不降历史栈把大对象长期锁住现象应用运行几个小时后内存曲线一路往上切到其他页面再回来也不下降。原因历史栈里的命令持有大对象的引用比如 Canvas 位图数据、整棵文档树。即使栈长度限制设了 100每一条命令都带着几百 KB 数据100 条就是几十 MB加上基线快照更容易失控。解决栈上限不能只防条数要防“总字节数”。给重量级命令加一个估算大小的方法trim 时按累计大小裁剪而不是单纯按条数。另一个可行做法是定期压基线并清掉基线之前的所有命令让历史真正“清零”一次。内存问题用 devtools 的 Heap snapshot 对比“执行 10 次操作”和“执行 200 次操作”的对象数量能直观看到是不是命令栈在堆积。6. 命令日志与跨会话恢复把撤销栈做成可持久化的最后这个进阶技巧解决的是“用户关掉应用再打开还想恢复昨天的撤销路径”的问题。实现路径是把命令序列化成日志落盘后按日志重放。6.1 命令日志长什么样适合落盘的命令必须能完整用 JSON 描述。我常写的格式是一个版本化对象{ version: 1, sessionId: 20260621-003, startStateHash: a3f2c1e9b7d4, commands: [ { type: layer.move, targetId: layer-12, dx: 12, dy: 0, at: 1710000001000 }, { type: text.insert, path: doc.body, index: 34, text: hello, at: 1710000002500 } ] }version 是命令协议的版本号命令结构升级后旧日志可以通过迁移函数转换。startStateHash 是会话开始时全量状态的哈希重放前先校验它避免从错误基线开始回放。at 字段用于调试时定位“某条命令到底在哪个时间点执行”它不是合并判定的依据合并判定还是走管理器里的时间窗口。6.2 回放校验与 registry 机制重放时不能直接执行 JSON 里的命令要先通过 registry 把 type 映射到命令构造函数async function replay(log: CommandLog, store: StateStore): Promisevoid { const currentHash await store.hash(); if (log.startStateHash currentHash ! log.startStateHash) { throw new Error( 基线不匹配期望 ${log.startStateHash}实际 ${currentHash} ); } for (const entry of log.commands) { const cmd registry.create(entry.type, entry); // 找不到 type 时抛错 cmd.execute(); await store.persistProgress(entry); // 每执行一条记录进度 } }逻辑说明registry 的作用是让“字符串 type”与“命令类”一一对应。遇到未注册的 type直接抛错比静默跳过安全因为跳过会让后续命令全部错位。per 함수是持久化进度的关键日志长的时候崩溃恢复可以从最后一条进度继续而不用从头重放。跨会话恢复还需要一个原则不要把日志里的命令自动执行到当前文档上必须弹窗让用户确认。因为用户可能在重启后已经手动改过文档自动重放会把新数据和旧操作混在一起产生难以排查的脏状态。做法是恢复前先对比 startStateHash不一致就只恢复命令列表到侧边栏让用户选择从哪个节点开始回放。这个方向值得投入。我把 undo_manager 当作独立模块塞进项目的第一天就为它留好了订阅、合并、序列化的接口后面两个月里几乎没再动过这块代码。上个月同事在另一个编辑器里要补撤销翻转半年前写的命令模型当天就接完。真正让一个后悔药活下来的不是一开始写得有多炫而是它从一开始就没被散落到按钮的回调里。希望这个方案也能帮你避开我当年踩过的坑。本文还有配套的精品资源点击获取