zhangdizhangdi

备忘录模式

定义

备忘录模式(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 实现

在经典实现中,我们需要严格定义这三个角色。我们以一个“文本编辑器”为例:

javascript
// 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 来实现快照:

javascript
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 状态和数据。这其实也是备忘录思想的一种宏观应用,只不过框架在底层帮你把整个组件实例当做了“快照”。

总结

当你遇到以下场景时,请果断使用备忘录模式

  1. 业务要求实现 “撤销(Undo)/重做(Redo)”,且通过计算逆向动作极其困难时。
  2. 需要提供 “保存草稿/暂存”、“一键恢复默认设置” 功能。

⚠️ 最大的性能陷阱:在前端使用备忘录模式,一定要警惕内存暴涨。如果每次打快照的数据有几 MB,存 100 步历史记录就会吃掉几百 MB 内存导致浏览器卡顿。实际工程中,通常需要限制历史栈的长度(比如最多只存 20 步撤销),或者结合使用 Immutable.js 这样的库(利用结构共享技术极大降低快照的内存消耗)。