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 的引用,那么之后任何对原 Li
