☰
手写撤销管理器:命令模式与状态快照的实战指南
2026/9/26 19:10:19 网站建设 项目流程

简介:撤销/重做(Undo/Redo)管理是文本编辑器等桌面应用的关键功能,这套代码包即针对这一场景,面向需要自行实现操作历史栈与动作回放的C++/MFC开发者。它提供了从核心撤销管理器到编辑视图动作捕获、命令菜单集成等完整模块,覆盖普通文本输入、增强编辑、替换及拖放操作等不同层级的撤销/重做逻辑。压缩包总共61个文件,其中31个.h头文件声明接口,28个.cpp源文件实现细节,另含1个文本变更记录和1个界面资源脚本,整体约70KB,结构紧凑,便于按模块阅读。代码通过自定义Action对象封装每次编辑操作,配合文本范围等数据结构定位修改范围,同时提供多步撤销/重做菜单,方便用户灵活回退。目前已有154人学习浏览;若希望深入理解操作栈、命令模式、动作对象化等设计思路,或快速复用一套现成的Undo/Redo框架,可直接参考其整体结构设计与各动作类实现。

1. undo_manager 到底在解决什么问题

undo_manager 这名字就说明白了:管撤销的。我第一次见到 undo_manager.zip 的时候,第一反应是又有哪个编辑器要把 Ctrl+Z 补上了。绝大多数团队里,撤销不是一开始就设计的,往往是需求方突然发现“误删的图层找不回来”“连续改了十几个配置想回到改之前”,才回头补一套可撤销体系。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. 把撤销粒度调到符合直觉:合并连续操作、基线压栈与内存裁剪

命令栈能跑起来只是第一步。真实用户不会按一次操作按一次撤销,他们按住键盘输入半分钟,期望一次 Ctrl+Z 删除一整段。这就要把撤销粒度从“命令级”提升到“用户直觉级”。

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): Promise<void> { 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 当作独立模块塞进项目的第一天,就为它留好了订阅、合并、序列化的接口,后面两个月里几乎没再动过这块代码。上个月同事在另一个编辑器里要补撤销,翻转半年前写的命令模型,当天就接完。真正让一个后悔药活下来的,不是一开始写得有多炫,而是它从一开始就没被散落到按钮的回调里。希望这个方案也能帮你避开我当年踩过的坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询