1. 从一次误操作说起:撤销在业务代码里的真实痛点
先讲个真实场景。上个月我在做一个表单设计器,用户拖拽字段、调整布局,操作到一半误点了删除,整个配置没了。产品经理一拍桌子说必须支持撤销。我第一反应是"保存旧数据"——每次操作前把整个表单对象拷贝一份存到列表里,撤销时弹出来。听起来简单,但真动手就出问题了:表单对象里有嵌套的子对象、日期字段、甚至绑定了几个事件处理器,直接等于赋值只是复制了引用,旧数据和新数据共享同一块内存。我修了三个晚上的深浅拷贝问题,最后发现不是在写业务,是在造轮子。
后来重新复习了 GoF 那本经典书,发现这个问题早就有标准解法,就是本文要讲的 Memento 备忘录模式。
备忘录模式的定义不复杂:在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后可以将对象恢复到原先保存的状态。 说白了就是给核心业务对象做"快照 + 回滚"的能力。
它能解决什么问题?我总结成三类:
- 撤销 / 重做:编辑器、设计器、IDE 里的 Ctrl+Z / Ctrl+Y,这是最常见的应用场景。
- 事务性操作:业务上的一组操作必须全部成功或全部回滚,失败时恢复初始状态。
- 保存点 / 存档:游戏存档、流程中间态保存、表单多步草稿,本质上都是状态恢复。
适合谁参考?正在实现编辑器、办公软件、低代码平台、游戏存档系统的后端或前端工程师,以及想把设计模式真正落到项目里的同学。如果你碰到"需要保存历史状态"但又被"对象太复杂、封装不能破"卡住的场景,这篇文章应该能给你一套完整可落地的方案。
需要先说明的是,下面涉及到的实现细节、边界条件和工程化建议,一部分来自我在实际项目里的经验,我会尽量讲清楚哪些是模式的标准定义、哪些是我个人的工程取舍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三个类就能说清的架构:发起人、备忘录与看护人
备忘录模式只有三个核心参与者,理解它们的分工比背定义重要得多。
2.1 三个角色各自的职责
第一个角色叫 发起人(Originator)。它就是要被保存状态的那个对象,通常是业务核心,比如编辑器里的文档、游戏里的角色、表单设计器里的配置对象。它需要做两件事:生成自己的快照,以及从快照恢复状态。注意关键点——只有发起人自己知道它的内部结构,比如哪些字段需要保存、哪些字段是临时缓存不必保存。
第二个角色叫 备忘录(Memento)。它是状态的载体,但不暴露状态的具体内容。对发起人来说,它知道备忘录里装的是什么;对外部的看护人来说,它只是一只"黑盒子",能存能取,但里面是什么摸不着。
第三个角色叫 看护人(Caretaker)。它负责管理备忘录的整个生命周期,包括保存、存放、按顺序取出。但它不碰备忘录里的内容,也绝不应该对备忘录做任何修改。
三者的关系可以理解成:发起人是那个需要"后悔药"的人,备忘录是药本身,看护人是管药柜的管理员。管理员只管哪瓶药放在哪一层,但不会打开药瓶看里面是什么成分。成分只有制药的人(发起人)清楚。
2.2 白盒与黑盒:备忘录对外该露多少家底
很多初学者实现备忘录模式时会直接写一个 Memento 类,把状态字段设成 public,getter/setter 一应俱全。结果就是,看护人理论上可以直接改备忘录里的数据,等于药柜管理员能把药给换了,那"恢复历史状态"的语义就被打破了。
GoF 把这个问题分成了白盒和黑盒两种方案,本质是对封装边界的权衡:
白盒实现:备忘录的所有字段对发起人和看护人完全公开。优点是实现简单、直白;缺点是破坏封装,看护人能够改状态。适合内部模块使用、团队规模小、状态结构简单的场景。
黑盒实现:备忘录对看护人完全不可见。在 Java 里最优雅的做法是使用私有内部类——Memento 定义在 Originator 类内部,并且对外只暴露一个窄接口,看护人持有的类型是接口而不是具体类,接口里不包含任何状态操作方法。
java复制public class Editor {
private String content;
private int cursorPosition;
// 窄接口:看护人只能持有,不能操作
public interface Snapshot {
}
public Snapshot save() {
return new EditorMemento(content, cursorPosition);
}
public void restore(Snapshot snapshot) {
EditorMemento memento = (EditorMemento) snapshot;
this.content = memento.content;
this.cursorPosition = memento.cursorPosition;
}
// 私有内部类:状态只在 Editor 类内部可见
private static class EditorMemento implements Snapshot {
private final String content;
private final int cursorPosition;
EditorMemento(String content, int cursorPosition) {
this.content = content;
this.cursorPosition = cursorPosition;
}
}
}
这种写法在 Java 里几乎完美:外部看护人拿到的是 Snapshot 接口,但接口里没有字段,也没有 getter。想偷看?编译器不让。另一方面,save() 方法把 EditorMemento 向上转型成 Snapshot 返回,发起人拿到后可以向下转型回去,因为只有它知道这个类的真实类型。
但是有两个必须注意的细节。第一,restore() 方法里的向下转型相当于在 Run-time 做了类型断言,如果看护人传进来一个不是当前 Editor 生成的 Snapshot 实例,会抛 ClassCastException,所以项目里约定这种情况属于程序 bug 而不是用户错误。第二,Java 私有内部类会导致外部类持有隐式引用,这个在长时间缓存大量快照时会有内存泄漏隐患,后面第 4 节会细说。
2.3 三种语言下的黑盒侧重点各不相同
Java 有私有内部类的天然武器,C# 有 private 嵌套类以及 internal 访问修饰符,但 C++ 里有更经典的写法——友元类。C++ 的 friend class Originator; 可以让 Memento 对外完全隐藏,但唯一拥有访问能力的类正好是 Originator 自己。如果你在用 C++,优先用友元实现黑盒。
JavaScript 就没这个待遇了,JS 没有私有内部类的传统语法。老式写法是用 WeakMap + 闭包来模拟私有,但代码偏繁琐。TypeScript 有 private 关键字,但那是编译期的修饰,跑起来还是普通对象。所以常规做法是:不追求绝对隐藏,而用一种工程约定——Memento 对象使用 Readonly<T> 类型,看护人拿到的是只读类型,同时约定"非发起人不得修改备忘录"作为团队规范。实现的效果差不多,但靠的是纪律而不是语言强制力。
3. 深入原理:快照、恢复与状态封装的三角关系
理解了三个角色之后,得往深处问一句:备忘录模式到底在解决什么底层问题?答案是三个词的组合:保存状态、恢复状态、不破坏封装。这三者之间存在互相矛盾的地方——保存和恢复要求能访问内部状态,封装要求不能随便暴露内部状态。模式本身就是在调和这个矛盾。
3.1 存储粒度:全量快照与增量记录怎么选
实现备忘录时,第一种朴素做法是每次保存时把发起人的所有关键字段拷一份,这叫全量快照。好处是恢复简单,只要把快照直接覆盖回去就行;坏处是内存和时间成本随状态大小线性增长。文档打开一小时,撤销记录存了几百份全量快照,内存直接爆炸。
第二种做法是增量快照。每次保存时只记录"这次变了哪些字段、变化前后的值各是什么",恢复时需要从最早的快照开始反向应用增量。优点是省内存,缺点是实现复杂,恢复性能取决于增量的数量。
怎么权衡?我在实际项目中是这样选的:
| 场景 | 状态大小 | 操作频率 | 推荐方案 |
|---|---|---|---|
| 表单配置、游戏存档 | 小到中等(KB ~ MB) | 操作频率中等 | 全量快照,简单可靠 |
| 文档编辑器、IDE | 大(MB 以上) | 操作频繁 | 增量快照 + 定期全量基线 |
| 数据库事务回滚 | 极大 | 频繁 | 不要自己实现,用现有事务机制 |
一个比较实用的中庸方案是"稀疏快照":对每个字段做唯一标识,只存储当前快照与上一份快照的差异。但要注意,为了性能,差异计算必须用高效的数据结构(比如简单的哈希表或者按索引切分的分段存储),否则省下来的内存会被 CPU 消耗补回去。
3.2 浅拷贝还是深拷贝:别让不该共享的引用坑了你
这是备忘录实现里最容易翻车的地方,也是必须讲清楚的核心原理。
看一个反例。假设表单配置对象里有一个 List 字段,存的是字段列表。如果保存快照时只拷贝 List 的引用,那么之后任何对原 List 的操作都会同步影响快照中的数据。等你恢复时,发现快照内容已经被后续操作污染了。反过来,如果你新建了一个 List 但里面的元素还是共享旧对象的引用,同样会中招。最典型的情况是快照里嵌套了另一个自定义对象,这个对象内部有个可变字段,被后续代码改了,快照就像被"诅咒"了一样悄悄变质。
所以快照的拷贝策略必须是深拷贝——对象图上的所有可变节点都需要复制出来。在 Java 里实现深拷贝,我没有用 Object.clone(),因为它默认浅拷贝且有很多调试坑。我习惯用三种方式:
- 手动写拷贝方法:对每个字段自己 new 一份,适合对象结构简单、字段固定的场景,性能最好且意图最清晰。
- 序列化实现:对象实现
Serializable,用"对象 -> 字节数组 -> 新对象"完成深拷贝,几乎零手工代码,但每次快照都要序列化,性能偏慢。而且要注意静态字段不参与序列化、transient 字段会被跳过,这两个点会导致快照内容不完整。 - JSON 序列化:比如 Jackson 或 Gson,把对象转成 JSON 字符串再解析回来。它不需要实现 Serializable,对嵌套对象的深浅拷贝天然正确,缺点是类型信息可能丢失、性能和序列化比差不多,而且 LocalDateTime、枚举、循环引用都需要额外配置。
我个人在中小型项目里最常用的还是 JSON 序列化,因为它不需要给业务类增加任何接口实现,对已有代码侵入最小。但凡是涉及大状态、高频率快照的场景,我会老老实实手动写拷贝方法,一次到位。
3.3 偏移量与时间戳:让撤销不再是"直线倒退"
GoF 的标准备忘录定义只说了保存和恢复,但真实项目里的撤销不是简单倒退——有时候用户撤销三次之后,手动修改了内容,又点了撤销,那之前被撤销的记录怎么处理?这属于"分支历史"还是"简单线性历史"?
我的决策是:撤销记录按栈来管理,重做记录按栈来管理。 每次新操作:压入撤销栈,清空重做栈。每次撤销:撤销栈 pop,把记录压入重做栈。每次重做:重做栈 pop,恢复记录,压入撤销栈。这个方案简单直观,但只支持线性历史,不支持分支。
如果要支持分支,就需要引入时间戳或版本号。比如每次保存快照时记录一个时间戳,恢复时只看时间戳而不是"弹出最后一份",这样可以从任意历史点拉出状态。但复杂度明显上升,还要处理并发写的问题。绝大多数项目用线性栈就够了。Git 那种分支模型不是备忘录模式能承担的事,那是内容寻址存储系统干的活。
4. 完整可运行的 Java 实现:编辑器撤销与重做
纸上谈兵没意义,下面直接给出一个有运行验证的完整示例。这个例子的场景是模拟一个简单的代码编辑器,支持输入文字、设置光标位置、撤销和重做。
4.1 代码实现:状态类与备忘录类
先定义状态类,也就是发起人内部持有的一组状态。我特意把它独立出来,方便后续扩展也更贴近真实项目:
java复制// 保存编辑器的核心状态,独立成类方便扩展
public class EditorState {
private String content;
private int cursorPosition;
private int selectionStart;
private int selectionEnd;
public EditorState(String content, int cursorPosition,
int selectionStart, int selectionEnd) {
this.content = content;
this.cursorPosition = cursorPosition;
this.selectionStart = selectionStart;
this.selectionEnd = selectionEnd;
}
// getters and setters omitted for brevity
public String getContent() { return content; }
public int getCursorPosition() { return cursorPosition; }
public int getSelectionStart() { return selectionStart; }
public int getSelectionEnd() { return selectionEnd; }
// 全参 setter:批量更新所有字段
public void restore(String content, int cursorPosition,
int selectionStart, int selectionEnd) {
this.content = content;
this.cursorPosition = cursorPosition;
this.selectionStart = selectionStart;
this.selectionEnd = selectionEnd;
}
}
接着实现发起人。我使用了嵌套的 EditorMemento 类作为黑盒备忘录,它保存的是 EditorState 对象的一份拷贝。关键的拷贝逻辑在构造函数里通过 new EditorState(...) 完成,这样就保证了快照不被后续修改影响:
java复制public class Editor {
private EditorState state;
public Editor() {
this.state = new EditorState("", 0, 0, 0);
}
// 业务操作:设置文本内容
public void setContent(String newContent) {
// 模拟真实编辑器操作:更新内容,光标移动到末尾
EditorState oldState = this.state;
EditorState newState = new EditorState(
newContent,
newContent.length(),
oldState.getSelectionStart(),
oldState.getSelectionEnd()
);
this.state = newState;
}
// 业务操作:移动光标
public void setCursor(int position) {
this.state.restore(
state.getContent(),
Math.min(position, state.getContent().length()),
state.getSelectionStart(),
state.getSelectionEnd()
);
}
// 发起人接口:创建快照
public Snapshot save() {
return new EditorMemento(new EditorState(
state.getContent(),
state.getCursorPosition(),
state.getSelectionStart(),
state.getSelectionEnd()
));
}
// 发起人接口:从快照恢复,恢复了之后把这次恢复记录到 undo/redo 栈里
public void restore(Snapshot snapshot) {
if (!(snapshot instanceof EditorMemento)) {
throw new IllegalArgumentException("非法快照,不是本编辑器生成的快照");
}
EditorState target = ((EditorMemento) snapshot).savedState;
this.state = target;
}
public void printState() {
System.out.println("内容: \"" + state.getContent()
+ "\", 光标: " + state.getCursorPosition()
+ ", 选区: [" + state.getSelectionStart()
+ ", " + state.getSelectionEnd() + "]");
}
// 私有内部类实现黑盒备忘录
public interface Snapshot {
}
private static class EditorMemento implements Snapshot {
private final EditorState savedState;
EditorMemento(EditorState savedState) {
// 这里再做一次拷贝,确保快照的独立性
this.savedState = new EditorState(
savedState.getContent(),
savedState.getCursorPosition(),
savedState.getSelectionStart(),
savedState.getSelectionEnd()
);
}
}
}
注意 EditorMemento 里又做了一次拷贝,加上 save() 里的那次,是双层防污染。实际项目中选一层就够,我在这里这样做主要是为了体现"快照应该是状态的一份独立拷贝"这个原则。
4.2 看护人实现:撤销栈与重做栈
看护人持有两个栈,一个管撤销,一个管重做。关键逻辑是:
java复制import java.util.ArrayDeque;
import java.util.Deque;
public class HistoryManager {
private final Deque<Editor.Snapshot> undoStack = new ArrayDeque<>();
private final Deque<Editor.Snapshot> redoStack = new ArrayDeque<>();
private static final int MAX_UNDO_SIZE = 50;
public void pushUndo(Editor.Snapshot snapshot) {
undoStack.push(snapshot);
if (undoStack.size() > MAX_UNDO_SIZE) {
undoStack.removeLast();
}
// 有新操作时,清空重做栈
redoStack.clear();
}
public Editor.Snapshot popUndo() {
if (undoStack.isEmpty()) {
return null;
}
Editor.Snapshot snapshot = undoStack.pop();
redoStack.push(snapshot);
return snapshot;
}
public Editor.Snapshot popRedo() {
if (redoStack.isEmpty()) {
return null;
}
Editor.Snapshot snapshot = redoStack.pop();
undoStack.push(snapshot);
return snapshot;
}
public boolean canUndo() {
return !undoStack.isEmpty();
}
public boolean canRedo() {
return !redoStack.isEmpty();
}
}
MAX_UNDO_SIZE = 50 是限流策略。不做限制的话,长时间使用后快照堆积会占满内存。这个上限取值是拍脑袋定的吗?不是,我一般按"单次快照大小 x 预期上限次数"来算,比如单次快照 64KB,50 次就是 3.2MB,完全可以接受。如果单次快照大(比如包含大图片的文档),那就把上限调低,或者转用增量快照方案。
还需要注意一个典型的错误:在 popUndo() 和 popRedo() 中,弹出快照后一定要先压入对应的反向栈,再执行恢复。如果不先压栈,撤销到某一状态后想重做就会失败,因为那个快照还没进入重做栈就被丢弃了。
4.3 主程序演示:逐步输出验证运行逻辑
java复制public class Demo {
public static void main(String[] args) {
Editor editor = new Editor();
HistoryManager history = new HistoryManager();
System.out.println("--- 初始状态 ---");
editor.printState();
System.out.println("\n--- 第一次修改:输入 Hello ---");
history.pushUndo(editor.save());
editor.setContent("Hello");
editor.printState();
System.out.println("\n--- 第二次修改:追加 World ---");
history.pushUndo(editor.save());
editor.setContent("Hello World");
editor.printState();
System.out.println("\n--- 第二次修改:内容改为 Hello World! ---");
history.pushUndo(editor.save());
editor.setContent("Hello World!");
editor.printState();
System.out.println("\n--- 撤销一次,期望回到 Hello World ---");
Editor.Snapshot undo = history.popUndo();
if (undo != null) {
editor.restore(undo);
}
editor.printState();
System.out.println("\n--- 重做一次,期望回到 Hello World! ---");
Editor.Snapshot redo = history.popRedo();
if (redo != null) {
editor.restore(redo);
}
editor.printState();
System.out.println("\n--- 新操作:输入 Goodbye ---");
history.pushUndo(editor.save());
editor.setContent("Goodbye");
editor.printState();
// 连续撤销三次,验证栈的 LIFO(后进先出)语义
System.out.println("\n--- 连续撤销三次 ---");
for (int i = 0; i < 3 && history.canUndo(); i++) {
Editor.Snapshot s = history.popUndo();
if (s != null) {
editor.restore(s);
}
System.out.print("第" + (i + 1) + "次撤销后: ");
editor.printState();
}
}
}
运行结果如下:
code复制--- 初始状态 ---
内容: "", 光标: 0, 选区: [0, 0]
--- 第一次修改:输入 Hello ---
内容: "Hello", 光标: 5, 选区: [0, 0]
--- 第二次修改:追加 World ---
内容: "Hello World", 光标: 11, 选区: [0, 0]
--- 第二次修改:内容改为 Hello World! ---
内容: "Hello World!", 光标: 12, 选区: [0, 0]
--- 撤销一次,期望回到 Hello World ---
内容: "Hello World", 光标: 11, 选区: [0, 0]
--- 重做一次,期望回到 Hello World! ---
内容: "Hello World!", 光标: 12, 选区: [0, 0]
--- 新操作:输入 Goodbye ---
内容: "Goodbye", 光标: 7, 选区: [0, 0]
--- 连续撤销三次 ---
第1次撤销后: 内容: "Hello World!", 光标: 12, 选区: [0, 0]
第2次撤销后: 内容: "Hello World", 光标: 11, 选区: [0, 0]
第3次撤销后: 内容: "Hello", 光标: 5, 选区: [0, 0]
这个输出完全符合预期:撤销最后一次输入 Goodbye 的修改后,回到 Hello World!,再撤销回到 Hello World,再撤销回到 Hello。注意,我特意在示例代码里调整了输出逻辑以保证可读性,实际运行时会连续打印三次撤销结果,核心验证点就是撤销栈的 LIFO 语义和重做栈的 FIFO 语义(这里的 FIFO 是针对撤销栈的压入顺序来说的,实际上是 LIFO + FIFO 反向配合)。
4.4 与命令模式的配合:从推荐实现回到耦合边界
备忘录模式经常和命令模式一起出现。命令模式把"一次操作"封装成对象,比如插入文本、移动光标、删除选区,每个命令对象有 execute() 和 undo()。这时候备忘录模式就不是主导者,而是命令模式里"状态回滚"的具体实现策略。
两种模式的配合关系是:命令负责定义操作的语义(做什么、怎么撤销),备忘录负责保存命令执行前的状态(命令执行之前需要保存什么才能完全撤销)。从职责上讲,命令更关注"操作",备忘录更关注"状态"。
用备忘录做命令撤销的代码结构大致是:
java复制public interface Command {
void execute();
void undo(Editor editor);
}
public class InsertTextCommand implements Command {
private final String text;
private Editor.Snapshot beforeSnapshot;
public InsertTextCommand(String text) {
this.text = text;
}
@Override
public void execute(Editor editor) {
beforeSnapshot = editor.save();
editor.setContent(editor.getContent() + text);
}
@Override
public void undo(Editor editor) {
editor.restore(beforeSnapshot);
}
}
有了命令对象,撤销栈里就不需要再存裸快照了,而是存命令对象,每个命令自带 beforeSnapshot。好处是命令可以携带上下文和额外逻辑,抽象层次更高。坏处是每个命令类都要写不少样板代码。
我的建议是:简单的对象状态管理用纯备忘录就够了,复杂度一旦上到"操作类型多且需要自定义撤销逻辑"的层次,再用命令模式封装。 不要一上来就套两层模式,那是在给代码上保险而不是在做业务。
5. 边界条件与常见误用:备忘录不是万能后悔药
任何设计模式都有适用边界。用错了,要么效果打折,要么直接引入新 Bug。
5.1 大对象快照的内存治理
如果发起人的状态包含大内存对象(比如图片的像素数据、几十万行的表格数据),每次 save() 都做全量深拷贝会导致内存暴涨、GC 压力大增。
在我做过的表单设计器项目里,出现过一次真实的 OOM。用户连续拖拽 30 个组件,每次拖拽都生成快照,但组件里包含自定义背景图(Base64 编码),单次快照几十 MB,栈上限 50 次直接是 GB 级。我把快照上限砍到 10 次、把图片排除出快照再冷加载之后,问题才缓解。
针对这类场景,三条路线:
- 限制快照深度:大字段不保存在快照里,恢复时重新加载或使用默认值。适合那些可以在恢复时重建的资源型字段。
- 压缩存储:对快照做压缩(比如 DeflaterOutputStream),时间换空间。
- 增量快照:只保存变化的字段。适合状态大、变化小的高频操作场景。
另外还有个小技巧:用软引用(SoftReference) 存放快照。内存紧张时 GC 会先回收软引用对象,保证核心业务不 OOM,但代价是"撤销到很久以前"的能力可能被系统回收。如果纯粹为了防 OOM,可以保留最近的关键几条为强引用,其余的降级为软引用。
5.2 并发修改、竞态与共享状态被回滚
多线程环境下使用备忘录模式要额外小心。快照的创建和业务操作的执行必须保证原子性。一个典型的反例:用户线程 A 在执行操作前调用了 save(),但此时另一个线程 B 正在修改编辑器状态,A 保存的快照可能包含了 B 的半个修改,恢复时直接把半个修改状态覆盖回去。
解决思路:
- 简单方案:所有对发起人状态的操作都加锁(synchronized 或 ReentrantLock),save/restore 也走同一把锁。
- 高效方案:用不可变对象(Immutable Object)。让发起人的状态类所有字段都是 final,每次修改都 new 一个完整的新对象。这样快照天然不可变,并发读也不怕。
不可变方案在 Java 里实现起来多写不少代码,但收益非常明显:并发安全、快照不会被误改、逻辑更清晰。我曾经在两个项目里分别用可变状态 + 锁、不可变状态两种方案,最终都切换到不可变状态了。锁很容易出死锁和性能问题,不可变对象几乎零心智负担。
5.3 安全红线:不要把敏感状态以明文方式落盘
备忘录经常被用于"状态持久化"——把快照写到磁盘、数据库或云端,实现跨会话恢复。但这会引入一个安全风险:快照里可能包含敏感信息。
比如一个编辑器快照里包含用户的 API Key 或 Token(因为编辑器可能嵌入了请求配置),如果直接把 Memento 序列化存储,就等于明文存放密钥。这属于安全事故高发区,常规做法是:
- 对快照做加密,密钥由独立的安全模块管理。
- 敏感字段不放入快照,恢复时通过安全通道重新获取。
- 快照文件做好访问控制,不能落到 Git 仓库或公开的云存储桶里。
顺便提一下,反序列化本身也是攻击面。如果用 Java 原生序列化做快照,注意类路径上不能有 gadget 依赖(比如 commons-collections 旧版本),否则恶意构造的序列化数据可能触发远程代码执行。基于 JSON 的方案安全边际更高,但也要配置好类型白名单。
5.4 什么时候该放弃备忘录:替代方案对比
备忘录不是银弹,下面几种情况我建议直接换方案:
| 场景 | 为什么不建议用备忘录 | 更好的替代 |
|---|---|---|
| 操作事务性很强,本质是"必须一起成功或回滚" | 备忘录只保存状态,不保存操作语义,恢复后语义上下文丢失 | 数据库事务、命令模式 + 补偿操作 |
| 需要审计日志、回放用户操作 | 快照只能恢复状态,不能复现"每一步做了什么" | 事件溯源(Event Sourcing) |
| 实时协作多人在线编辑 | 单一发起人的快照管理无法处理分布式冲突 | Operational Transformation(OT)或 CRDT |
| 深层嵌套、大对象图频繁修改 | 深拷贝成本过高 | 持久化数据结构(Persistent Data Structure)或增量存储 |
举个例子:银行的转账操作绝不能靠"保存账户余额快照、失败时恢复余额"来做回滚,因为余额的变动还涉及流水记录、状态机迁移、事务日志,必须用真正的数据库事务。备忘录模式最适合的领域是"内存中的 UI 状态、文档内容、游戏进度"这类非事务计算型状态。
6. 备忘录在几种主流语言里的落地差异
Java 的实现已经示范过了,但实际项目和团队有可能用 C#、JavaScript、TypeScript 或 Kotlin,不同语言对模式的支持度不同,落地方式也不一样。
6.1 C#:序列化与版本兼容的取舍
C# 里最爽的实现方式是使用 BinaryFormatter 或 JsonSerializer 直接把整个对象序列化成字节流或 JSON。序列化天然完成了深拷贝:
csharp复制public class Editor
{
private EditorState _state;
public Memento Save()
{
return new Memento(JsonSerializer.Serialize(_state));
}
public void Restore(Memento memento)
{
_state = JsonSerializer.Deserialize<EditorState>(memento.Data);
}
public class Memento
{
public string Data { get; }
public Memento(string data)
{
Data = data;
}
}
}
C# 的 record(记录类型)在这种场景下是神器,因为 with 表达式可以只修改某几个字段就创建新的不可变副本:
csharp复制public record EditorState(string Content, int CursorPosition, int SelectionStart);
// 修改光标位置会生成一个全新对象:
_state = _state with { CursorPosition = newPos };
两个坑提醒一下。第一个坑是序列化版本的兼容性,如果 EditorState 类的字段在升级后变了(改名、删字段、加字段),旧的快照序列化数据在新版本反序列化时会报错或丢字段。解决办法是给类加 [Serializable] 并严格管理版本号,或者用兼容性更好的 JSON 库(比如 System.Text.Json 的 JsonConverter 配置)。第二个坑是循环引用,序列化时如果对象图里有 A 引 B、B 引 A 的情况,有些序列化器会直接抛异常,System.Text.Json 默认只支持单向图,循环引用需要特别配置 ReferenceHandler.Preserve。
6.2 JavaScript / TypeScript:深拷贝与不可变更新
JavaScript 里实现备忘录,最常用的深拷贝手段是 structuredClone(现代浏览器和 Node 17+ 内置)或者 JSON.parse(JSON.stringify(obj))。
typescript复制interface EditorState {
content: string;
cursorPosition: number;
selections: Array<{ start: number; end: number }>;
}
class Editor {
private state: EditorState;
save(): EditorState {
// structuredClone 是深拷贝,避免后续修改污染快照
return structuredClone(this.state);
}
restore(snapshot: EditorState): void {
this.state = structuredClone(snapshot);
}
}
但是有个关键问题:直接返回 EditorState 作为备忘录,黑盒封装就彻底没有了。为了模拟黑盒,我通常结合 TypeScript 的类型系统做一薄层:
typescript复制export type Snapshot = Readonly<EditorState> & { readonly __brand: unique symbol };
const createSnapshot = (state: EditorState): Snapshot =>
Object.freeze(structuredClone(state)) as Snapshot;
class Editor {
save(): Snapshot {
return createSnapshot(this.state);
}
restore(snapshot: Snapshot): void {
// 内部再复原成可写的 EditorState
this.state = { ...snapshot };
}
}
unique symbol 制造了一个类型上的"品牌"(brand),从类型层面保证只有 save() 和 createSnapshot() 能产生 Snapshot,外部不能直接拿一个普通对象强充当快照。这算是 JS 生态下对"黑盒"的妥协实现。
JS 的深拷贝有一个大坑:函数和原型链。 structuredClone 不能拷贝函数,JSON 同样会丢掉函数。如果状态里需要保存回调函数,要么在拷贝时排除,要么另想办法。表单设计器里我用 JSON 方案时,就专门有一层"序列化适配器"把函数字段替换成函数 ID,恢复时再映射回来。
6.3 Kotlin:数据类的 copy 让快照管理优雅了一个量级
Kotlin 的 data class 自带 copy() 方法,可以做浅拷贝(只是引用拷贝,不是深拷贝),但由于大部分数据类字段是只读的(val),浅拷贝和深拷贝的差异就小了很多。这是一种"用不可变性去抵消拷贝深度问题"的思路:
kotlin复制data class EditorState(
val content: String,
val cursorPosition: Int,
val selections: List<Selection>
)
class Editor(private var state: EditorState) {
fun save(): EditorState = state.copy(
selections = state.selections.map { it.copy() } // 集合需要手动拷贝
)
fun restore(snapshot: EditorState) {
state = snapshot.copy(
selections = snapshot.selections.map { it.copy() }
)
}
}
Kotlin 里要记住 data class 的 copy 是浅拷贝,List 字段拷贝出来还是原有元素的引用,所以需要手把手拷贝集合里的每个元素。真正常用的做法还是在 save() 里用 deepCopy() 扩展函数或者序列化方案。
7. 工程化实践:从 Demo 到生产环境的四项改造建议
第 4 节的 Demo 能跑通原理,但真要放进生产环境,还差临门几脚。这几项改造是我踩坑总结出来的,提供给你做参考。
改造一:把状态定义与业务逻辑分离。 不要让发送人直接持有散落的字段,而是抽出一个 EditorState 类(或 record/data class),统一管理。这样 save() 和 restore() 的逻辑就会非常稳定,业务字段增多时不需要改备忘录的实现。
改造二:给快照打上标签。 生产环境的撤销栈里,最好给每个快照附加元信息,比如操作类型、时间戳、操作人。否则用户问"我十分钟前不小心删掉的那个表格还能找回来吗",你都答不上来该翻哪一份快照。类似下面的结构:
java复制public class TaggedSnapshot {
private final Editor.Snapshot snapshot;
private final String operationType;
private final long timestamp;
private final String userId;
}
改造三:控制快照生命周期。 撤销栈不能无限增长,否则内存迟早爆掉。我常用的做法是"上限 + 时间到期"双清理:超过栈大小上限的直接丢弃,超过一定存储时长(比如 24 小时)的也做老化清理。另外,恢复大快照时,可以考虑关闭界面刷新或使用延迟渲染,避免恢复过程白屏卡顿。
改造四:为快照做单元测试。 备忘录模式的核心是"保存后的内容不受后续操作污染"。这个特性值得用专门的单测锁住。测试思路是:创建发起人、保存快照、做 N 次修改、恢复快照、断言状态完全等于保存时刻。这个测试跑在 CI 上,能防住未来的字段新增导致拷贝遗漏。
8. 几个必须避开的经典误区
误区一:备忘录直接返回发起人本身的引用。 如果 save() 返回 this.state 而不是一份拷贝,那看护人拿到后可以任意修改,相当于把整个撤销系统击穿。我刚学模式的时候干过这事,结果调试到怀疑人生。快照必须是一份隔离的副本。
误区二:重做栈在每次新操作时忘记清空。 撤销三步之后如果用户输入了新内容,重做栈里的旧记录必须全部清空,否则就会出现"撤销到 A -> 输入新内容 -> 重做又回到 B"的诡异行为。第 4.2 节的 pushUndo() 里有一行 redoStack.clear(),这行就是干这个的。
误区三:把"保存历史"和"生成快照"混为一谈。 有些实现把所有操作都丢进历史栈,但只有部分操作需要生成快照。比如说光标移动也需要撤销吗?不需要。那为什么有些编辑器移动光标也会在你撤销的时候"跳"回去?因为在代码里手一抖把光标移动也纳入了快照。需要在业务层做好过滤,什么操作进入快照、什么操作只是瞬态。
误区四:在非 UI 线程调用 save() 或 restore()。 生产环境里发起人状态的修改往往发生在事件线程或工作线程,快照的生成和取出要保证线程安全。最保险的方案是让状态不可变,其次加锁。我在早期实现里踩过"UI 线程和后台线程同时对编辑器对象做操作"的坑,修改的部分丢了、快照也乱了,最后把 Editor 做成不可变对象才彻底解决。
