cv.cm

編碼 · 設計模式

備忘錄模式(Memento)詳解與範例

閱讀約 6 分鐘

簡答

備忘錄(Memento)是一種行為型設計模式,它把物件的內部狀態擷取成快照物件(稱為備忘錄),讓該物件之後可以還原到那個狀態。擁有狀態的物件(原發器)負責建立與還原備忘錄,另一個物件(管理者)只負責保存,從不查看內容。這是在不破壞封裝的前提下實作復原、檢查點與遊戲存檔的標準做法。

意圖與要解決的問題

意圖:在不違反封裝的前提下,擷取並外部化物件的內部狀態,讓該物件之後可以還原到這個狀態。備忘錄是《設計模式》(Gang of Four,簡稱 GoF)一書中的行為型模式之一。

想像一個繪圖應用程式,其中圖形有位置、大小、顏色,以及一串用於繪製的私有衍生值。你想要一個復原按鈕。復原管理器可以自己複製圖形的欄位,但這樣它就得知道每一個私有欄位,而且每次圖形類別改變,復原管理器就會壞掉。為了復原而把所有欄位都改成公開,則會讓任何程式碼都能把圖形弄成無效的狀態。

備忘錄模式的解法是讓圖形自己產生快照。只有圖形知道快照裡有什麼,以及如何從中還原。復原管理器把快照當作不透明的物件:把它們存在清單裡,並在使用者按下復原時交還其中一個。

結構與參與者

角色職責
原發器狀態被保存的物件;為目前狀態建立備忘錄,並從備忘錄還原自己
備忘錄原發器狀態的不可變快照;不向其他物件暴露任何有用的內容
管理者決定何時儲存、何時還原,並保管備忘錄(例如放在堆疊中),但從不讀取或更改其內容

關鍵概念是兩種介面:寬介面給原發器,它能讀取儲存的狀態;窄介面給其他所有人,他們只能持有備忘錄。

TypeScript 實作

備忘錄類別使用 JavaScript 私有欄位(#state),因此管理者無法從備忘錄物件讀取儲存的資料。讀取要透過靜態方法 EditorMemento.read,且只有編輯器會呼叫它。在實際專案中,請把備忘錄類別與編輯器放在同一個模組並且不要匯出,這樣其他模組可以持有備忘錄,卻永遠無法打開它。

type EditorState = { text: string; cursor: number };

class EditorMemento {
  readonly #state: EditorState;
  constructor(state: EditorState) {
    this.#state = { ...state }; // copy, so later edits do not change the snapshot
  }
  static read(m: EditorMemento): EditorState {
    return { ...m.#state };
  }
}

class Editor {
  private state: EditorState = { text: "", cursor: 0 };
  type(words: string) {
    const text = this.state.text + words;
    this.state = { text, cursor: text.length };
  }
  get text() { return this.state.text; }
  save(): EditorMemento { return new EditorMemento(this.state); }
  restore(m: EditorMemento) { this.state = EditorMemento.read(m); }
}

class History {
  private stack: EditorMemento[] = [];
  constructor(private editor: Editor) {}
  backup() { this.stack.push(this.editor.save()); }
  undo() { const m = this.stack.pop(); if (m) this.editor.restore(m); }
}

const editor = new Editor();
const history = new History(editor);
history.backup(); editor.type("Hello");
history.backup(); editor.type(" world");
console.log(editor.text); // Hello world
history.undo();
console.log(editor.text); // Hello
history.undo();
console.log(JSON.stringify(editor.text)); // ""

History 是管理者。它在每次變更前呼叫 backup(),要返回時呼叫 undo(),但從不接觸 text 或 cursor。如果之後在 EditorState 中加入選取範圍,History 不必更動。

備忘錄與命令的復原比較

實作復原有兩種常見方式,而且常常結合使用:

做法儲存的內容適用時機成本
備忘錄每次變更前的整個狀態快照變更難以反向,或狀態很小記憶體用量隨狀態大小乘以步數成長
命令操作,以及足以反向還原的資料每個操作都有簡單的反操作每個命令都需要正確的復原邏輯

一個務實的折衷:大多數步驟儲存命令,每 N 步儲存一次完整備忘錄;或只儲存命令所改變的那部分狀態。

何時使用、何時不用

  • 用於復原、重做、檢查點、遊戲存檔,以及表單上應該回復編輯內容的「取消」按鈕。
  • 用於記憶體內的交易:在有風險的操作前建立快照,失敗時還原。
  • 當管理者不應依賴原發器的內部欄位時使用。
  • 狀態非常龐大且經常改變時不要使用,除非你限制歷史長度或只儲存差異。
  • 當狀態本來就是單純的不可變值時,不必搞全套儀式。有了不可變資料,舊值本身就是快照,只要保留一份先前值的清單就夠了。

實際應用

  • 瀏覽器 History API:history.pushState(state, "", url) 為每個歷史項目儲存一個狀態物件,使用者導覽時,popstate 事件會把它交還。
  • Redux 及類似的 store 會保留不可變的狀態快照,工具藉此提供時間旅行除錯。
  • SQL 資料庫在交易中支援 SAVEPOINT,因此可以把工作回復到具名的時間點。
  • 文字編輯器、繪圖程式和遊戲會儲存快照,用於復原、自動儲存與存檔欄位。

相關模式

模式與備忘錄的關係
命令命令可以透過反向操作來復原;它也可以儲存執行前取得的備忘錄,並在 undo 中還原。
原型原型複製整個物件來建立新物件;備忘錄則儲存狀態,以便之後還原同一個物件。
迭代器迭代器可以把目前位置以備忘錄的形式公開,讓走訪之後能繼續。
狀態狀態模式會在狀態改變時改變物件的行為;備忘錄只記錄並還原資料。

常見問題

原發器與管理者有什麼不同?

原發器擁有狀態,是唯一能建立備忘錄並從中還原的物件。管理者決定何時儲存與還原,並保管備忘錄,但把它們當作不透明的物件。

備忘錄會破壞封裝嗎?

不會,這正是它的目的。原發器的內部欄位維持私有,備忘錄對管理者隱藏它們,因此只有原發器能讀取儲存的狀態。

如何限制備忘錄的記憶體用量?

保留快照的最大數量並丟棄最舊的、只儲存狀態中有變動的部分,或把備忘錄與記錄小幅變更的命令結合使用。

JSON 序列化算是備忘錄嗎?

把狀態序列化成 JSON 可以作為備忘錄的儲存格式,也很適合存到磁碟。這個模式本身關心的是誰建立、保管與還原快照,而不是格式。

全部編碼教學