做撤销功能的时候,很多人第一反应是“把修改之前的状态存下来”。听起来很简单,真正动手才发现坑一个接一个:存下的引用被后续操作改掉了、恢复出来的结果和预期不符、快照太多又把内存撑爆。备忘录模式(Memento Pattern)正是为解决这类“状态快照与恢复”问题而生的经典设计模式。
我是在给某可视化表单设计器实现撤销/重做时,把备忘录模式从“课本上见过”变成“工程里真用”的。第一次实现时我在业务对象上写了个简单的 clone(),每次操作前把对象复制一份丢进一个 List。跑通 demo 感觉良好,直到测试同学发现:撤销后文字回来了,字体设置却还是修改后的。排查了半天,原来是浅拷贝——样式对象根本是同一个引用。这之后我把备忘录模式重新捡起来,把发起人、备忘录、管理者这三件事从头到尾理清楚,才有了这篇文章。
这篇文章不打算做概念罗列,尽量写成一份可以照抄的工程笔记:它解决什么问题、三个角色分别怎么设计、撤销/重做怎么落地、真实项目里有哪些变形,以及我实际踩过的坑和完整排查思路。适合正在做编辑类功能、表单回退、草稿箱、游戏存档这类需求的开发者参考。
1. 为什么撤销功能需要“快照”而不是“操作日志”
1.1 两种直觉方案,一个绕不开的坑
做撤销功能,最直觉的第一版方案是“把对象保存下来”:
List<TextEditor> history = new ArrayList<>(); history.add(editor); // 保存当前状态 editor.insertText(10, "新的内容");这个方案为什么必废?因为 history 里存的是同一个对象的引用。编辑器后来被修改,history 里那个“备份”也会跟着变。撤销时拿出来和当前状态一模一样,等于没保存。这是新手最容易踩的坑,本质是混淆了“引用”和“快照”这两个概念。引用是一条指向某个内存地址的线索,快照则是把那一时刻的数据完整复制一份,两者互不影响。
第二个直觉方案是记录操作本身,撤销时逆向执行:
record EditOp(int start, String inserted) {}听上去很优雅,但工程实现极其痛苦。删除和插入可以互逆,格式化、拖拽、批量操作、带副作用的操作每一项都要单独写逆向逻辑;如果操作之间有依赖关系,比如先在第 10 位插入一段文字,再删除第 15 到 20 位,位置会随着前面操作的逆序执行发生偏移,你得引入复杂的坐标映射才能正确回放。我在某团队见到过富文本编辑器里维护了十几类操作的逆向方法,每次新增一个编辑能力都要同步改撤销模块,耦合得非常厉害。
于是就有了第三种方案:状态快照。核心思想很朴素——根本不关心用户做了什么操作,只记住“操作之前长什么样”,需要撤销时把整个状态还原回那个样子即可。快照方案把“如何撤销”和“具体业务操作”彻底解耦,业务侧随便新增功能,只要在操作前拍一张快照,撤销逻辑一行都不用改。
1.2 三个角色怎么分工
备忘录模式把这个过程拆成三个角色,职责边界非常清晰:
- 发起人(Originator):需要保存/恢复状态的对象,知道自己的内部数据如何打包成快照,也知道如何用快照还原自己;
- 备忘录(Memento):承载某一时刻完整状态的对象,设计上应当是不可变的,创建之后任何人都不能改动;
- 管理者(Caretaker):保存备忘录、决定何时保存何时恢复,但不接触发起人内部字段,也不读取备忘录内容。
用复印做类比:你在写一份文档(发起人),每隔一段时间把当前文稿复印一份(备忘录),复印件放进抽屉(管理者)。要还原时,从抽屉里取出一份复印件照着抄回来。关键前提是复印件和正在写的原稿完全独立,原稿后续再怎么涂改,复印件都保持原样。
这里最容易被忽视的设计点是:管理者只负责保管“复印件”,不读复印件内容。你的历史栈只需要知道第几层是谁保存的,不需要知道里面存了什么。一旦管理者依赖备忘录的具体内部结构,备忘录内部的字段调整就会波及历史栈,破坏模式的意义。
1.3 封装性的真正含义
教科书喜欢说备忘录模式能“在不破坏封装性的前提下保存和恢复对象内部状态”。这句话落到代码里,指的是一件事:你的私有字段不能因为要支持撤销就一股脑暴露给外部。假设编辑器内部用数组存文本,后来升级成链表,如果历史栈直接操作这些内部结构,升级时历史栈也逃不掉;而快照是实现细节的隔离层,外部只接触 EditorState 这种稳定的抽象,内部怎么改都不影响历史栈。
Java 里经典的写法是内部类加私有构造器,外部只能拿到对象引用,读不到字段。工程上我也理解,完全密封的代价是不方便调试,所以很多团队干脆用 record 或 DTO,getter 全部公开。这个妥协可以接受,但底线是:备忘录里的字段必须不可变,任何 setter 都不能有。后面我会专门讲一个因为管理者偷读快照字段引发的连锁问题。
2. 从零手写一个编辑器撤销/重做:三角色的代码骨架
2.1 发起人:save/restore 成对出现
直接用代码说话。这是一个简化版文本编辑器,状态包括正文文本、光标坐标、选区宽度和字体样式:
public class TextEditor { private StringBuilder content = new StringBuilder(); private int cursorX; private int cursorY; private int selectionWidth; private String fontFamily; private int fontSize; public TextEditor(String initialContent) { content.append(initialContent); } public void insertText(int pos, String text) { content.insert(pos, text); } public void deleteText(int start, int end) { content.delete(start, end); } public void moveCursor(int x, int y) { this.cursorX = x; this.cursorY = y; } public void applyFormat(String fontFamily, int fontSize) { this.fontFamily = fontFamily; this.fontSize = fontSize; } public EditorState createSnapshot() { return new EditorState( content.toString(), cursorX, cursorY, selectionWidth, fontFamily, fontSize ); } public void restoreFromSnapshot(EditorState snapshot) { if (snapshot == null) { return; } this.content = new StringBuilder(snapshot.content()); this.cursorX = snapshot.cursorX(); this.cursorY = snapshot.cursorY(); this.selectionWidth = snapshot.selectionWidth(); this.fontFamily = snapshot.fontFamily(); this.fontSize = snapshot.fontSize(); } public record EditorState( String content, int cursorX, int cursorY, int selectionWidth, String fontFamily, int fontSize ) {} }发起人只有两个方法肩负模式职责:createSnapshot() 负责把当前内部状态打包成快照;restoreFromSnapshot() 负责用快照覆盖当前状态。这两个方法必须成对出现,只写 save 不写 restore,等于只复印不还原。
细看恢复方法里的几个关键动作。content 在快照里是 String,本身不可变,恢复时再用 new StringBuilder(snapshot.content()) 装一次,保证后续编辑不会波及快照;所有 int 和 String 都是值语义,直接赋值即可。真正要警惕的是那些可变引用类型字段,比如状态里有个 List ,恢复时只做 this.styles = snapshot.styles() 是远远不够的,必须新建集合并把元素逐个复制,否则快照和编辑器共享同一个 List,改了哪边都会互相污染。
2.2 快照必须“深拷贝”,判断标准就一条
深拷贝不是备忘录模式的新概念,但在这里尤其重要。判断需不需要深拷贝,我保底的标准就一条:恢复快照后,如果原对象继续被修改,快照里的数据会不会受影响?会,就是浅拷贝,必须处理。
处理办法无非三种:手动逐字段复制、用序列化框架复制、用拷贝构造器。手工逐字段写最可靠也最繁琐,适合状态字段不多且结构稳定的对象。以 List 为例:
public class StyleRange { private final int start; private final int end; private final TextStyle style; public StyleRange(StyleRange source) { this.start = source.start; this.end = source.end; this.style = new TextStyle(source.style); } } public class DocumentSnapshot { private final String content; private final List<StyleRange> styles; public DocumentSnapshot(String content, List<StyleRange> styles) { this.content = content; this.styles = styles.stream() .map(StyleRange::new) .toList(); } }这行 styles.stream().map(...).toList() 做了两层防御:toList() 返回不可变列表,管理者没法往快照里塞新元素;每个 StyleRange 又通过拷贝构造器复制,防止原对象改 StyleRange 时快照内部跟着变。很多同学只记得第一层,忘了第二层,结果就是“列表没变,列表里的对象变了”。
2.3 管理者:双向栈加深度上限
管理者的经典实现是两组栈:撤销栈保存“操作前的快照”,重做栈保存“撤销时被顶出来的快照”。我用 ArrayDeque 实现,栈满时从尾部丢弃最老的快照:
public class HistoryManager { private static final int DEFAULT_MAX_DEPTH = 100; private final ArrayDeque<TextEditor.EditorState> undoStack = new ArrayDeque<>(); private final ArrayDeque<TextEditor.EditorState> redoStack = new ArrayDeque<>(); private final int maxDepth; public HistoryManager(int maxDepth) { this.maxDepth = maxDepth > 0 ? maxDepth : DEFAULT_MAX_DEPTH; } public void record(TextEditor.EditorState snapshot) { undoStack.push(snapshot); if (undoStack.size() > maxDepth) { undoStack.removeLast(); } redoStack.clear(); } public TextEditor.EditorState undo() { if (undoStack.isEmpty()) { return null; } TextEditor.EditorState snapshot = undoStack.pop(); redoStack.push(snapshot); return snapshot; } public TextEditor.EditorState redo() { if (redoStack.isEmpty()) { return null; } TextEditor.EditorState snapshot = redoStack.pop(); undoStack.push(snapshot); return snapshot; } }谁调用 record、在什么时机调用,是整个方案的命门。标准用法是:
history.record(editor.createSnapshot()); // 操作前先拍快照 editor.insertText(10, "hello"); // 再执行操作撤销流程则是:
TextEditor.EditorState snapshot = history.undo(); if (snapshot != null) { editor.restoreFromSnapshot(snapshot); }三条核心规则建议直接背下来:
第一,记录时机必须是“操作之前”。快照拍晚了,你存进去的就是改完之后的状态,撤销时等于没撤。
第二,任何新操作产生后要清空 redo 栈。用户在撤销之后做了新编辑,原来的“重做分支”就彻底失效了,留着它只会让状态恢复变得混乱。
第三,深度不是越大越好。100 份文本快照可能没什么感觉,但如果是游戏存档级别的对象,一份几十 MB,100 份直接内存告急。深度的选择要和快照体积联合评估。
双向栈这套骨架非常通用,文本编辑器、表单设计器、画板、流程图工具全部适用,后面真实案例里我会反复用到。
3. 真实项目里的三种变体:游戏存档、表单回退与通用快照
3.1 游戏存档:多槽位与自动存档
游戏状态天然适合备忘录模式。玩家位置、血量、背包、任务进度、技能冷却,这些东西拼成一个复杂状态对象,而存档就是要“把某一时刻的完整状态留到以后用”。
举一个简化的例子:
public class GameState { private final Position position; private final int hp; private final int mp; private final List<String> inventory; private final Map<String, Integer> questProgress; public GameStateSnapshot save() { return new GameStateSnapshot( position, hp, mp, List.copyOf(inventory), Map.copyOf(questProgress) ); } // 恢复快照的逻辑省略 } public record GameStateSnapshot( Position position, int hp, int mp, List<String> inventory, Map<String, Integer> questProgress ) {}注意存档和撤销的区别:撤销要求“只能回到最近的历史”,存档则要求“任意时间点都能读”。所以游戏里通常是一个存档管理器,维护多个槽位:
public class SaveManager { private final Map<String, GameStateSnapshot> slots = new HashMap<>(); public void saveToSlot(String slotName, GameState state) { slots.put(slotName, state.save()); } public void loadFromSlot(String slotName, GameState state) { GameStateSnapshot snapshot = slots.get(slotName); if (snapshot != null) { state.restore(snapshot); } } }自动存档往往也走同一套通道:到达检查点时自动 saveToSlot("autosave", state),玩家死亡时 loadFromSlot("autosave", ...)。这里的管理者比历史栈更“无脑”——它只负责 key 到快照的映射,完全不关心快照内容,这也正是备忘录模式想要的隔离效果。深拷贝的坑在游戏里特别明显:背包 List 如果不做 List.copyOf,存档里装的是同一个 List 引用,玩家捡了装备存档也跟着变,读档读出来的是“新包”,属于经典事故。
3.2 长流程表单:步骤回退不离场
很多后台系统有“多步骤表单”:第一步填基础信息,第二步传附件,第三步做确认,中途允许“上一步”回来改。注意,这跟撤销不是一回事——用户回退时,已经填好的其他字段要保留,不能整个表单回到白纸状态。
我建议的做法是:每一步完成时,把整个表单模型打一个快照。回到上一步时,从历史栈弹一个快照恢复;再点下一步,当前表单模型作为新快照入栈。这样即使用户在某一步来回走了十几次,数据也始终不会丢。
public class WizardController { private final ArrayDeque<FormModel> history = new ArrayDeque<>(); private final FormModel model; public WizardController(FormModel model) { this.model = model; } public void stepCompleted() { history.push(model.createSnapshot()); } public boolean goBack() { if (history.isEmpty()) { return false; } model.restoreFromSnapshot(history.pop()); return true; } }这里有一个容易忽略的细节:表单模型往往是很多字段的聚合体,包含用户填过的内容、校验状态、甚至当前步骤的 UI 元素开关。快照时要把“非持久化的 UI 状态”排除掉,否则这些瞬态字段会让快照体积翻倍,还会导致恢复时触发一堆不该有的校验。成熟做法是把表单模型拆成“业务数据”和“展示状态”两部分,快照只管业务数据。
3.3 通用快照:用序列化框架偷懒的代价
如果状态对象字段非常多、结构经常变,手写深拷贝维护成本太高。实务里很多人直接用 JSON 序列化做快照:
public class GenericSnapshot { private final String data; private final String typeName; private final long createdAt; private GenericSnapshot(String data, String typeName) { this.data = data; this.typeName = typeName; this.createdAt = System.currentTimeMillis(); } public static GenericSnapshot capture(Object source, Gson gson) { return new GenericSnapshot(gson.toJson(source), source.getClass().getName()); } public <T> T restore(Class<T> type, Gson gson) { return gson.fromJson(data, type); } }这种方案我在生产环境用过,也说一下它的优缺点。好处是通用、快照不可变、天然深拷贝,新增字段不用维护复制逻辑。代价一箩筐:序列化框架要求目标类能实例化,有的需要无参构造器;LocalDateTime、Duration 这类 Java 时间类型默认序列化格式反序列化时容易踩坑,需要注册自定义适配器;对象图里有循环引用时会直接炸栈;更深一层,如果你的模型是 ORM 实体,反序列化还可能触发懒加载查询,快照恢复瞬间数据库压力飙升。
我的结论是:通用快照适合“结构稳定的值对象”和“跨进程/跨存储的历史状态”;同一个 JVM 内的编辑器、设计器类状态,手写深拷贝更可控。两种方案的对比我放在下面:
| 维度 | 手写深拷贝 | JSON 序列化快照 |
|---|---|---|
| 字段变化维护 | 每次都要改 | 基本不用改 |
| 性能 | 快 | 中等,GC 压力大 |
| 循环引用 | 可控 | 需要特殊处理 |
| 类型信息 | 编译期保证 | 运行时丢失,要包 typeName |
| 调试直观性 | 直接看字段 | 看 JSON 字符串 |
我自己在跨进程场景或需要落盘持久化时选 JSON 方案,在内存编辑器里坚持手写。两种都被坑过,各有各的解法,后面统一讲。
4. 我踩过的六个坑:从浅拷贝翻车到历史栈爆炸的完整排查
4.1 撤销“还了一半”:浅拷贝引发的字段神游
回到开头那个真实案例。某可视化表单设计器,整块画布对象支持撤销。测试同学报了一个诡异 bug:填了一段文本,把字体改成粗体,按撤销,文本内容回来了,但粗体效果还在。
第一反应是 restore 没被调用。加了日志,每次 Ctrl+Z 都进入了 restoreFromSnapshot,排除。
第二反应是快照里漏了字体字段。打开快照对象看,发现字体相关字段赫然在列,而且快照里显示的是“非粗体”,原对象却是“粗体”——这说明快照存在,但恢复时被无视了。
真正定位到根因是在查对象引用关系时发现的:编辑器持有的是一个 FontStyle 对象,而快照里保存的也是同一个 FontStyle 对象。用户点“粗体”按钮时,代码直接改了 FontStyle 内部字段,原对象和快照共享同一个引用,两边同时变成了粗体。快照“记得”的所谓非粗体状态,其实在用户按下按钮的瞬间就被篡改了。
修复方案很明确:createSnapshot() 里必须深拷贝 FontStyle,restoreFromSnapshot() 里也要新建对象,不能直接赋值引用。这个坑的教训是:不要以为“字段在快照里”就安全了,要看字段指向的对象是不是可变引用。
4.2 引用当快照:撤销撤到最后一层还是最新状态
另一个我见过不止一次的翻车写法:
history.add(editor); // 直接把发起人自身存进去这比浅拷贝更彻底——连快照都不是,只是存了一个引用。症状是:无论撤销多少次,界面都停留在最新状态,因为 history 里每一层都指向同一个被反复修改的对象。排查方法也很简单:在保存和恢复两个时机分别打印对象 hashCode,如果相等,说明存的就是它自己。
这类 bug 通常发生在从“复用已有对象”到“引入快照机制”的过渡期:脑子里知道要做快照,手一快把需要快照的对象传进了栈。我的建议是:管理者栈的元素类型必须写成具体的快照类型(EditorState、GameStateSnapshot),而不写发起人类型(TextEditor、GameState)。类型约束能拦住大部分顺手写错的情况。
4.3 快照撑爆内存:一份 50MB 的文档历史
做过含图片的富文本文档处理的同学应该有共鸣。最初我的历史深度设成 100,每份快照包含整个文档的 JSON,其中内嵌图片以 Base64 存储,一份最普通的带图文档快照就能到几十 MB。100 份就是几个 GB,界面直接卡死,内存监控报表一路飘红。
排查链路是这样走的:先看内存 dump,发现历史栈的字节数组占了绝大多数;把深度从 100 调到 30,问题缓解但不治本;最后定位到快照内容里 Base64 图片占比超过 90%。解决方案做了三层:
第一层,减小快照体积。图片这类大对象拆出来,不保存完整的二进制,只记录一个“图片 ID + 修改时间”,内容本身独自从共享存储读取。
第二层,限制历史深度。编辑器这类场景默认 100 层足够,有特殊需求再放宽,并做成可配置项。
第三层,如果前两层还不够,只能上增量快照:每次只记录和上一版本不同的部分,撤销时从基准快照逐步叠加增量重建。增量方案实现成本高,和操作日志方案的复杂度差不多,建议只在大对象、低频更新的场景使用。
4.4 高频输入让历史栈变成性能瓶颈
键盘输入场景的特殊性在于:用户连续打字时,每秒可能触发十几次内容变更,如果每次按键都拍一次快照,历史栈瞬间被塞满,而且绝大多数快照之间只差一个字符,价值极低。
我的做法是合并空闲期。定义 500 毫秒的窗口:用户开始输入后,第一个字符前只拍一次快照,之后 500 毫秒内的连续按键不再入栈;当用户停顿超过 500 毫秒,才认为一段“连续输入”结束,下一次输入重新拍快照。这样撤销时按“词块”回退,既减少了快照数量,也符合用户直觉——没人希望撤销一次只回退一个字符。
public class DebouncedRecorder { private final Timer timer = new Timer(); private boolean armed = true; public void keyTyped(Runnable snapshotAction, Runnable mutationAction) { timer.cancel(); if (armed) { snapshotAction.run(); // 只拍一次 armed = false; } mutationAction.run(); timer.schedule(new TimerTask() { @Override public void run() { armed = true; } }, 500); } }类似的节流策略也适用于画板拖拽、滑块拖动这类高频操作。要注意的是:合并窗口要和业务语义对齐,不能为了省快照让撤销行为变得反直觉。
4.5 字段升级后,老快照恢复出一堆 null
用 JSON 串做通用快照时遇到的老问题:今天保存的快照,三个月后系统升级,状态类新增了几个字段。老快照反序列化时,新字段没有对应 JSON 数据,得到 null 或默认值,而业务逻辑直接读取新字段做渲染,界面就崩了。
我给出的防线是三层:
第一层,快照里加版本号,恢复时检查版本,低版本走迁移逻辑。
第二层,序列化配置里设定缺省字段的兜底值,比如出现 null 时填一个安全默认值,避免后续逻辑 NPE。
第三层,也是最容易被忽视的:升级字段时,给新增字段设计“缺省值语义”。如果缺省值是“无样式”,老数据也能正常展示;如果缺省值会造成业务含义错误,那必须在迁移阶段就补齐数据,而不是等恢复时才补救。
4.6 管理者不该知道快照的内部细节
最后一个坑比较隐蔽:项目越做越大,有人开始为了做“操作预览”功能,直接读快照里某个字段。这个功能本身没问题,问题是快照内部结构从此变成了半公开接口。后来状态结构调整,快照字段名一变,操作预览也跟着坏,两个模块被迫绑定在一起改。
备忘录模式的边界本来就是为了隔离这种耦合。管理者只应该持有快照、按照自己的规则存取快照,快照内部长什么样与它无关。如果确实需要预览,正确的做法是另开一个只读投影接口,让发起人提供“另存为预览数据”的方法,而不是把快照内部字段直接铺出去。
5. 备忘录与命令、原型、事件溯源的边界怎么划
5.1 和命令模式的分工:记录“意图”还是记录“状态”
命令模式把一次操作封装成对象,带 execute() 和 undo() 两个方法。备忘录模式不记录操作,只记录状态。两者最舒服的组合是:命令对象内部持有操作前后的两个备忘录,历史栈存命令对象,撤销时调用 command.undo()。
举个例子:一个“给某段落应用粗体”的命令:
public class ApplyBoldCommand { private final TextEditor editor; private final TextEditor.EditorState before; private final TextEditor.EditorState after; public ApplyBoldCommand(TextEditor editor) { this.editor = editor; this.before = editor.createSnapshot(); // 执行粗体操作…… this.after = editor.createSnapshot(); } public void undo() { editor.restoreFromSnapshot(before); } public void redo() { editor.restoreFromSnapshot(after); } }对比一下问自己的问题:如果撤销只需要“回到过去某个状态”,且业务操作本身不需要动态可替换,用备忘录 + 历史栈就够了;如果同一个操作需要在多个上下文中变化行为、需要组合、需要支持宏命令,那就用命令模式,把备忘录塞进命令内部。很多编辑器框架两个一起上:命令定义行为,备忘录承载状态,历史管理器只认命令对象。
5.2 和原型模式的对比:复制自己 vs 提取快照
原型模式的核心是“用一个已有对象作为原型,复制出一个新的独立实例”。它和备忘录有个共同的交集——都要做深拷贝,但意图完全不同。原型模式想要的是“新对象可以独立演变”,备忘录要的是“旧状态可以被恢复”。
最常见的误用是拿原型模式做撤销:每次操作前,复制一个编辑器对象作为“之前的编辑器”。抛开对象的生命周期和类型转换不谈,光是“整个编辑器对象”作为历史单元就太重了,撤销时还要小心翼翼地把新实例的字段搬回旧实例。备忘录的做法是把“状态”和“行为”分离,历史单元是轻量级的纯数据结构,而不是一个完整对象。
5.3 状态大、操作又频繁时,事件溯源是更好的出路
备忘录模式的软肋在两种场景下暴露得很明显:状态对象非常大,或者操作频率非常高。你不可能在每次鼠标移动时都拍一张几十 MB 的快照。这时候该跳出“保存状态”的思路,考虑事件溯源:不存状态,存事件序列;需要某个时刻的状态时,把从开始到那一刻的事件全部回放一遍。
事务日志、操作审计、协同编辑、跨服务状态同步,这类场景基本都是事件溯源的天下。代价是回放成本:状态恢复的时间从快照的 O(1) 变成事件的 O(n)。所以现实系统普遍采用折中方案——定期落一个基准快照,之后的变更用增量操作日志补齐。这里其实又回到了备忘录的“基准快照”概念:事件溯源系统里的周期性快照,往往就是用一个不可变的序列化对象来承载的。前端状态管理工具提供的“时间旅行调试”也是类似思路:把所有状态变更历史存下来,回到任意历史版本,本质上是快照与事件回放的结合。
理解了这几个边界,你就能在需求出现时快速判断:纯撤销用备忘录,复杂操作行为用命令,大对象高频率要增量或事件溯源。这几种方案不是互斥的,组合起来反而能解决绝大多数状态管理问题。
实际做下来我的体会是:备忘录模式的代码量不大,难点全在快照粒度和历史深度这两个参数上,以及深拷贝的边界处理。真写生产代码时,有几件事我会一上来就定死:快照必须不可变;管理者只认快照类型;记录时机永远在操作之前;回滚失败要有兜底提示而不是静默吞掉。另外强烈建议补一组“往返测试”——随机生成一批操作序列,执行若干步后连续撤销再重做,每一步都断言状态与预期完全相等。这种测试能把你将来所有类型的快照坑提前炸出来。
最后再顺手分享一个小技巧:快照对象不要只存业务状态,务必加上 version 和 createdAt 两个字段。版本号帮你处理数据兼容,时间戳帮你在排查问题时确认“这份快照到底是什么时候存的”。就这几个功夫钱,等你在生产环境查了三个小时的撤销 bug 就会知道有多值。