备忘录模式
定义
备忘录模式(Memento Pattern) ,在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。
Git 提交 (Commit):每一次 git commit 都是当前代码库的一个“备忘录/快照”。无论你后来把代码改得多乱,只要 git checkout 到那个 commit,代码就能瞬间复原。
核心角色:
- Originator(发起人/原发器):需要被保存状态的业务对象(比如游戏角色)。它负责生成存档(Create Memento)和读取存档(Restore from Memento)。
- Memento(备忘录/快照):负责存储状态的对象(比如那个存档文件)。重点:它应该是不可变的(Immutable),且除了发起人之外,其他对象不应该能读取或修改里面的细节。
- Caretaker(管理者):负责保管备忘录(比如游戏主菜单的存档列表)。它只负责把备忘录存进数组或从数组里拿出来,绝对不能去偷看或者修改备忘录里的内容。
对比
备忘录模式 vs 命令模式 (处理 Undo/Redo 的两派)
面试中常问:实现撤销功能,用命令模式好还是备忘录模式好?
命令模式 记录的是“动作/增量(Delta)” 。比如:原来是10,命令是“加5”。撤销的逻辑是“减5”。
优点:极其节省内存(只存动作指令)。
缺点:需要为每一个动作写逆向逻辑(Undo),有时候某些动作不可逆。
备忘录模式 记录的是“快照(Snapshot)” 。比如:不管你怎么变的,反正现在状态是 15。撤销的逻辑是“把状态强制替换回之前的快照 10”。
优点:逻辑极其简单粗暴,无需写任何逆向逻辑。
缺点:极其消耗内存。如果对象特别庞大,存100步快照可能会导致浏览器内存溢出(OOM)。
💡 现实开发中,复杂系统(如协同文档、编辑器)往往是把两者结合:用命令模式记录动作,每隔一定步数打一个备忘录快照。
与设计原则关系
封装原则(信息隐藏):这是备忘录模式的核心精髓。状态被安全地打包在 Memento 里,管理者(Caretaker)只管拿着这个包,却打不开它。外界无法破坏对象的历史状态。
单一职责原则:原发器只管自己的核心业务逻辑;管理者只管历史记录的维护(前进、后退栈),分工明确。
实现
1. 传统 OOP 实现
在经典实现中,我们需要严格定义这三个角色。我们以一个“文本编辑器”为例:
// 1. 备忘录 (Memento) —— 极其简单,通常就是一个只读的数据载体
class EditorMemento {
constructor(content, cursorPosition) {
this.content = content;
this.cursorPosition = cursorPosition;
// 在现代 JS 中,可以使用 Object.freeze 冻结对象,防止管理者乱改
Object.freeze(this);
}
}
// 2. 发起人 (Originator) —— 真正的业务对象
class TextEditor {
constructor() {
this.content = '';
this.cursorPosition = 0;
}
// 业务方法
type(text) {
this.content += text;
this.cursorPosition += text.length;
}
// 核心1:生成快照(打存档)
save() {
console.log(`[存档] 当前内容: "${this.content}"`);
return new EditorMemento(this.content, this.cursorPosition);
}
// 核心2:恢复快照(读档)
restore(memento) {
this.content = memento.content;
this.cursorPosition = memento.cursorPosition;
console.log(`[读档] 恢复为: "${this.content}"`);
}
}
// 3. 管理者 (Caretaker) —— 负责维护存档历史记录
class HistoryManager {
constructor() {
this.history =[]; // 历史栈
}
push(memento) {
this.history.push(memento);
}
pop() {
return this.history.pop(); // 弹出上一次的存档
}
}
// ================= 使用 =================
const editor = new TextEditor();
const history = new HistoryManager();
editor.type('Hello');
history.push(editor.save()); // 存第 1 档
editor.type(' World');
history.push(editor.save()); // 存第 2 档
editor.type('! Oops..'); // 写错字了!
console.log(`[当前状态] ${editor.content}`);
// 撤销读档
editor.restore(history.pop()); // 丢弃错字,回到第二档
editor.restore(history.pop()); // 再次撤销,回到第一档
2. 现代 JS (深拷贝)
在前端由于对象引用的问题(如果只是简单赋值,历史记录里的状态会被后续操作同步篡改),备忘录模式的 核心技术点往往是“深拷贝(Deep Clone)” 。
在现代 JS 中,我们经常不需要写单独的 Memento 类,而是直接利用 JSON 序列化或原生的 structuredClone 来实现快照:
class CanvasEditor {
constructor() {
this.shapes =[]; // 画布上的图形数组
this.history =[]; // 直接把管理者融合进组件内部
}
// 画图
draw(shape) {
this.shapes.push(shape);
this.saveSnapshot(); // 每次变动自动打快照
}
saveSnapshot() {
// 现代浏览器原生深拷贝API(这就相当于创建了不可变的 Memento)
const snapshot = structuredClone(this.shapes);
this.history.push(snapshot);
}
undo() {
if (this.history.length > 1) {
this.history.pop(); // 丢弃当前状态
// 将上一个状态深拷贝回来
this.shapes = structuredClone(this.history[this.history.length - 1]);
}
}
}
前端应用
虽然名字听起来像后端的词汇,但备忘录模式是前端交互领域的大红人。
1. 全局状态管理的“时间旅行调试” (Redux DevTools)
前面提到 Redux 派发动作是命令模式,但 Redux DevTools 能够让你在历史状态里随意穿梭(Time Travel)的底层原理,正是备忘录模式。
由于 Redux 强制要求状态树(State Tree)是不可变的(Immutable)。每一次 Reducer 执行完毕,返回的都是一个全新的 State 对象引用。Redux 开发者工具只需要把这一个个全新的 State 引用存进一个数组(Caretaker)。你想退回到哪一步,工具就把哪个状态快照重新注入到视图层中。
2. 复杂表单的“草稿箱”与页面状态恢复
你在填一个极其复杂的多步骤简历表单(包含各种上传、下拉、联动),突然手滑刷新了页面。为了防止用户崩溃,前端通常会做“草稿箱”。
- 实现:每当表单数据发生变化(或者每隔 30 秒),就将表单的完整 JSON 数据
JSON.stringify()后存入localStorage(生成备忘录)。 - 当用户重新打开页面时,检查本地存储有没有快照,如果有,就
JSON.parse()出来重新赋给表单绑定的数据源对象(读取备忘录恢复现场)。
3. WebGL / Canvas 图形编辑软件
像 Figma、Photoshop 的 Web 版、在线白板(Excalidraw)。当你框选了一堆图形,改变了它们的颜色。这背后可能会涉及成百上千个像素点或坐标的数据变更。如果用命令模式去记录“颜色色值如何增减”会异常复杂,通常的做法是在关键交互节点(如鼠标松开时 onMouseUp),对当前画布的核心 JSON 数据打一个 Snapshot。
4. 路由缓存机制 (如 Vue 的 <keep-alive>)
Vue 中的 <keep-alive> 组件能够在组件切换时,不将其销毁,而是将组件实例的状态缓存到内存中。当你重新切回该路由时,直接从内存中恢复之前的 DOM 状态和数据。这其实也是备忘录思想的一种宏观应用,只不过框架在底层帮你把整个组件实例当做了“快照”。
总结
当你遇到以下场景时,请果断使用备忘录模式:
- 业务要求实现 “撤销(Undo)/重做(Redo)”,且通过计算逆向动作极其困难时。
- 需要提供 “保存草稿/暂存”、“一键恢复默认设置” 功能。
⚠️ 最大的性能陷阱:在前端使用备忘录模式,一定要警惕内存暴涨。如果每次打快照的数据有几 MB,存 100 步历史记录就会吃掉几百 MB 内存导致浏览器卡顿。实际工程中,通常需要限制历史栈的长度(比如最多只存 20 步撤销),或者结合使用 Immutable.js 这样的库(利用结构共享技术极大降低快照的内存消耗)。