如果你曾在編輯器裡按下 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設計模式 : Flyweight(享元模式)
javaScript設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
