javaScript設計模式 : Memento(備忘錄)

 


如果你曾在編輯器裡按下 Undo、Redo,或想把某個狀態「先存起來以防萬一」,你其實已經遇過 Memento(備忘錄)模式的精神了。

這篇文章用 JavaScript 帶你從零認識它:為什麼需要「快照」、怎麼在不外洩內部細節的前提下保存狀態,以及如何穩定地還原。過程不只寫範例,還會談到效能與記憶體、常見翻車點(像是 redo 邏輯被搞亂、快照愈存愈肥),並準備一份實用檢查清單。

看完你就能替表單、圖形編輯或設定頁面加上好用的歷史回復,讓體驗更可靠。希望本篇文章能夠幫助到需要的您。


目錄

{tocify} $title={目錄} 


什麼是 Memento(備忘錄)模式?

Memento 模式的目標是在不破壞封裝的前提下,為物件的狀態建立可回復的「快照」。你可以把它想成「時光膠囊」:當下把重要狀態存起來,未來需要時還原。

        核心精神:Originator(原發者)負責建立與還原狀態;Memento(備忘錄)只保存資料,不暴露 Originator 的內部細節;Caretaker(照護者)只負責保存/管理 Memento,不干涉內容。

        典型應用:編輯器的 Undo/Redo、表單回復、繪圖歷史、工作流程回滾、設定切換與 AB 測試回復等。


為何在前端/全端開發中需要它?

改善使用者體驗:Undo/Redo 幾乎是生產力工具的標配。

降低耦合:把「如何保存與還原狀態」封裝在 Originator 裡,外部(Caretaker)只管存取,不必知道細節。

可測試:快照是時間點上的純資料,更容易做對比與回歸測試。

可擴充:從單純字串,到複雜 JSON 狀態、游標位置、選取範圍、滾動位置,都能逐步納入。


三個核心角色:Originator、Memento、Caretaker

Originator:真正擁有業務狀態的物件,提供 createMemento() 產生快照,以及 restore(memento) 從快照還原。

Memento:一份不可變(或視為不可變)的狀態資料快照,通常為純物件,必要時包含版本與時間戳。

Caretaker:管理快照的容器,負責 Push/Pop、Undo/Redo,不讀取快照內容。


最小可用範例:文字編輯器的 Undo/Redo

1.    基本類別結構(ES6)

// Originator
class Editor {
  #content = '';
  #selectionStart = 0;
  #selectionEnd = 0;

  setContent(text) {
    this.#content = text;
  }
  setSelection(start, end) {
    this.#selectionStart = start;
    this.#selectionEnd = end;
  }
  getContent() {
    return this.#content;
  }

  // 產生快照(Memento)
  createMemento() {
    // 回傳純資料,不外露私有欄位實作
    return Object.freeze({
      type: 'EditorMemento',
      version: 1,
      timestamp: Date.now(),
      data: {
        content: this.#content,
        selectionStart: this.#selectionStart,
        selectionEnd: this.#selectionEnd
      }
    });
  }

  // 從快照還原
  restore(memento) {
    if (!memento || memento.type !== 'EditorMemento') {
      throw new Error('Invalid memento');
    }
    const { content, selectionStart, selectionEnd } = memento.data;
    this.#content = content;
    this.#selectionStart = selectionStart;
    this.#selectionEnd = selectionEnd;
  }
}

// Caretaker:管理歷史與重做堆疊
class History {
  #undoStack = [];
  #redoStack = [];

  push(memento) {
    this.#undoStack.push(memento);
    this.#redoStack.length = 0; // 新分支,清空重做
  }

  canUndo() { return this.#undoStack.length > 0; }
  canRedo() { return this.#redoStack.length > 0; }

  undo(currentMemento) {
    if (!this.canUndo()) return null;
    const prev = this.#undoStack.pop();
    this.#redoStack.push(currentMemento);
    return prev;
  }

  redo(currentMemento) {
    if (!this.canRedo()) return null;
    const next = this.#redoStack.pop();
    this.#undoStack.push(currentMemento);
    return next;
  }
}


2.    使用方式

const editor = new Editor();
const history = new History();

function commit() {
  history.push(editor.createMemento());
}

// 初始輸入
editor.setContent('Hello');
editor.setSelection(0, 5);
commit();

editor.setContent('Hello, world!');
editor.setSelection(7, 12);
commit();

// Undo
if (history.canUndo()) {
  const snapshot = history.undo(editor.createMemento());
  if (snapshot) editor.restore(snapshot);
}

// Redo
if (history.canRedo()) {
  const snapshot = history.redo(editor.createMemento());
  if (snapshot) editor.restore(snapshot);
}


要點

        每次重要變更後 commit():把「當前狀態」存成快照。

        Undo 時把「當前狀態」也先入 redo stack,確保可以來回切換。

        快照是純資料物件,外部不可修改(Object.freeze)。


進階實作:保密內部狀態與可擴充快照

1.    使用閉包保護內部狀態(無 class 版本)

function createEditor() {
  let content = '';
  let selection = { start: 0, end: 0 };

  return {
    setContent(text) { content = text; },
    setSelection(start, end) { selection = { start, end }; },
    getContent() { return content; },

    createMemento() {
      return Object.freeze({
        type: 'EditorMemento',
        version: 2,
        timestamp: Date.now(),
        data: {
          // 可擴充:加入 scrollTop、字體大小、主題等
          content,
          selection: { ...selection }
        }
      });
    },
    restore(m) {
      if (!m || m.type !== 'EditorMemento') throw new Error('Invalid memento');
      const d = m.data;
      content = d.content;
      selection = { ...d.selection };
    }
  };
}


優點

使用閉包形成天然私有性,不需私有欄位語法。

version 便於將來升級快照格式(向後相容)。

2.    快照擴充的通用策略

加入 version:未來調整結構時,保留解析能力。

加入 timestamp/author:多人協作或審計需求。

加入 meta:描述此快照的原因(例如「貼上」、「格式變更」),秀在歷史面板上。


效能與記憶體優化:差分、壓縮與節流

當狀態變大(例如畫布、複雜 JSON),「整份快照」會吃記憶體與效能。可考慮:

1.     差分(diff)快照

只記錄相對上一次快照的差異。還原時從基準快照一路套用差分。

function shallowDiff(prev, next) {
  const patch = {};
  const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
  for (const k of keys) {
    if (JSON.stringify(prev[k]) !== JSON.stringify(next[k])) {
      patch[k] = next[k]; // Demo: 簡化處理
    }
  }
  return patch;
}

function applyPatch(base, patch) {
  return { ...base, ...patch };
}


注意:真實生產環境可考慮更高效的 diff(例如結構共用/持久化資料結構),或引入專用套件。上面只是概念示例。


2.    壓縮

將快照序列化為字串後再進行壓縮(如 LZ 概念)。在 Web 端可用 CompressionStream(支援度需評估)或第三方方案。

對於巨量影像/二進位資料,可考慮以檔案形式暫存(例如 IndexedDB)。


3.    節流/防抖

編輯過程可能非常頻繁,建議「每隔 X 毫秒」或「在使用者停頓 Y 毫秒後」才 commit。

也可根據事件種類做策略:打字過程不每字 commit,粘貼/格式化/批量替換時才 commit。

function debounce(fn, wait = 200) {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), wait);
  };
}


與常見工具整合:Redux/狀態機/本地儲存

1.    Redux/集中式狀態

以 Redux 的 state 作為 Originator 的狀態來源,createMemento() 取當前 store.getState() 的「可序列化快照」。

Time Travel:Memento 與中介軟體(middleware)配合,達成開發工具的狀態回放。


2.    狀態機(State Machine)

每個狀態轉移後 commit 一份快照;Undo 即是回退到前一節點。

留意副作用:還原狀態時,別重放或重觸發副作用(如再次送出 API)。


3.    本地儲存(localStorage/IndexedDB)

可將快照序列化儲存,提供「關閉頁面後重開仍可復原」。

建議:只存檔案級別或節點級別的關鍵快照,避免把整個歷史堆疊都塞進 localStorage 造成塞車。


真實場景設計:表單、圖形、多人協作

1.    表單編輯(多欄位)

Originator 內部維護一個 formState(例如 { name, email, address, ... })。

只在欄位完成輸入或「欄位群組」變更完成時 commit。

提供「回到某次版本」功能,顯示快照 timestamp 與 meta(例如「匯入 CSV」、「套用範本」)。


2.    圖形編輯(Canvas/SVG)

快照可包含:圖層結構、選取工具狀態、縮放比例。

差分策略:只記錄變動圖層與變更參數。


3.    多人協作(雲端)

以伺服器端作為「權威時間軸」,Memento 附 author 與 serverTimestamp。

解衝突策略:先合併(Merge)後再 commit 快照;或以作業(operation)重放(CRDT/OT 模型,這已超出基本 Memento 範疇,但思路相容)。


常見錯誤與雷點

這段很關鍵,直接影響系統穩定度與可維護性。建議逐條比對你目前的設計。

1.    把內部狀態整個暴露給 Caretaker

症狀:Caretaker 會直接改快照、或讀取敏感欄位。

原因:Memento 不是不可變,或設計時未限制存取。

對策:快照使用 Object.freeze;只回傳純資料;必要時使用閉包/私有欄位;在 Type 層(若用 TS)明確標記為只讀。


2.    快照過於龐大,造成卡頓與記憶體爆炸

症狀:長時間編輯後,瀏覽器記憶體激增,UI 延遲。

原因:每次變更都完整存一份深拷貝。

對策:差分快照、節流/防抖、限制歷史長度、壓縮、只在重要操作 commit。


3.    淺拷貝導致快照被「同步污染」

症狀:還原舊版本卻看到新資料。

原因:快照只做淺拷貝,內含參考型別被後續修改。

對策:使用 structuredClone(現代瀏覽器)或深拷貝策略;確保快照與現行狀態無共享可變參考。

// 安全深拷貝(現代瀏覽器)
const snapshot = structuredClone(currentState);


4.    Redo 邏輯錯亂

症狀:Undo 後做了新操作,Redo 仍然可用但跳到奇怪狀態。

原因:新分支產生時,未清空 redoStack。

對策:每次 push 新快照時清空 redoStack。


5.    還原時重新觸發副作用(API、事件)

症狀:Undo 時又發送 HTTP、重設計時器、重播動畫。

原因:還原流程與「執行流程」耦合。

對策:把副作用抽離;還原只設狀態,不重放副作用;必要時加「恢復模式」旗標。


6.    序列化問題(循環參考、不可序列化類型)

症狀:存 localStorage 或傳到後端時失敗。

原因:狀態含函式、DOM 節點、Map/Set 或循環參考。

對策:快照層只存「可序列化純資料」;把不可序列化內容放回 Originator 內部,由邏輯層重建。


7.    缺少版本化

症狀:升級快照結構後,舊快照無法還原。

對策:在 Memento 加 version,並在 restore() 做兼容轉換。


8.    歷史壓縮策略缺失

症狀:長篇編輯造成數百個快照,切換緩慢。

對策:定期壓縮(如每 10 筆合併為 1 筆基準 + 累積 diff)、設最大長度、丟棄過舊分支。


9.    安全與隱私

症狀:快照中包含敏感資訊(Token、機密欄位),被外部擷取。

對策:快照前先過濾敏感欄位;傳輸時加密;權限控管。


總結

Memento 模式在 JavaScript 世界裡非常實用:它不是華麗的語法花招,而是「把狀態當成一條時間軸」的穩健思維。當你把「如何保存與還原」拉回 Originator 內部,外部就可以專心處理 UX(例如歷史列表、描述、縮圖)與持久化策略(localStorage、IndexedDB、雲端)。


附錄:更完整的可擴充程式碼片段

A. 支援 meta 與最大歷史長度

class History {
  #undoStack = [];
  #redoStack = [];
  #max = 100;

  constructor({ max = 100 } = {}) {
    this.#max = max;
  }

  push(memento) {
    this.#undoStack.push(memento);
    if (this.#undoStack.length > this.#max) {
      // 丟掉最舊的
      this.#undoStack.shift();
    }
    this.#redoStack.length = 0;
  }

  canUndo() { return this.#undoStack.length > 0; }
  canRedo() { return this.#redoStack.length > 0; }

  undo(current) {
    if (!this.canUndo()) return null;
    const prev = this.#undoStack.pop();
    this.#redoStack.push(current);
    return prev;
  }

  redo(current) {
    if (!this.canRedo()) return null;
    const next = this.#redoStack.pop();
    this.#undoStack.push(current);
    return next;
  }

  clear() {
    this.#undoStack.length = 0;
    this.#redoStack.length = 0;
  }
}

// 使用 meta 記錄操作描述
function commitWithMeta(originator, history, meta) {
  const m = originator.createMemento();
  const withMeta = Object.freeze({ ...m, meta });
  history.push(withMeta);
}


B. 以不可變資料方式組合(Functional 風格)

const Originator = (initial) => {
  let state = structuredClone(initial);

  return {
    getState() { return structuredClone(state); },
    update(updater) {
      // 將所有修改集中在一個 updater,避免四散
      const next = updater(structuredClone(state));
      state = structuredClone(next);
    },
    createMemento() {
      return Object.freeze({
        type: 'StateMemento',
        version: 1,
        timestamp: Date.now(),
        data: structuredClone(state)
      });
    },
    restore(m) {
      if (!m || m.type !== 'StateMemento') throw new Error('Invalid memento');
      state = structuredClone(m.data);
    }
  };
};

// Demo
const userForm = Originator({ name: '', email: '' });
const history = new History({ max: 50 });

userForm.update(s => { s.name = 'Lin'; return s; });
commitWithMeta(userForm, history, { action: 'edit-name' });

userForm.update(s => { s.email = 'lin@example.com'; return s; });
commitWithMeta(userForm, history, { action: 'edit-email' });


C. 節流提交

const commitThrottled = (() => {
  let last = 0;
  const interval = 300; // ms
  return (originator, history, meta) => {
    const now = Date.now();
    if (now - last >= interval) {
      commitWithMeta(originator, history, meta);
      last = now;
    }
  };
})();


延伸閱讀推薦:

javaScript設計模式 : Factory Method   (工廠方法)

javaScript設計模式 : Abstract Factory(抽象工廠)

javaScript設計模式 : Builder(建造者模式)

javaScript設計模式 : Prototype(原型)

javaScript設計模式 : Singleton (單例模式)

javaScript設計模式 : Adapter(轉接器模式)

javaScript設計模式 : Bridge( 橋接模式 )

javaScript設計模式 : Composite(組合模式)

 javaScript設計模式 : Decorator(裝飾者)

javaScript設計模式 : Facade(外觀模式)

javaScript設計模式 : Flyweight(享元模式)

javaScript設計模式 : Proxy(代理模式)

javaScript設計模式 : Chain of Responsibility(責任鏈)

javaScript設計模式 : Command Pattern(命令模式)

javaScript設計模式 : Interpreter(直譯器)

javaScript設計模式 : Iterator(迭代器)

javaScript設計模式 : Mediator(仲裁者)

javaScript設計模式 : Observer( 觀察者 )

javaScript設計模式 : State(狀態模式)

javaScript設計模式 : Strategy(策略模式)

javaScript設計模式 : Template Method (模板方法)

javaScript設計模式 : Visitor(訪問者模式)


張貼留言 (0)
較新的 較舊