專案做大後,最惱人的不是 bug,而是「變更」:每次點擊都牽動好幾層,想回復一步卻不知道該還原哪裡。
命令模式把這件事講清楚:一個操作就是一個命令,負責記住需要的上下文,能執行,也能撤銷;管理者負責堆疊與重做;接收者專心把事做好。
這種結構讓你自然長出記錄、排程、審計與重播能力。本文以 JavaScript 展開:先用小範例抓住形狀,再完成一個含插入/刪除、Undo/Redo 的輕量編輯器。希望本篇文章能夠幫助到需要的您。
目錄
{tocify} $title={目錄}
為什麼需要命令模式
命令模式將「發出請求的人(Invoker)」與「真正執行的人(Receiver)」解耦,把一個操作包裝成命令物件,對外只暴露統一介面(例如 execute() 與 undo())。這種封裝帶來幾個直接好處:
Undo/Redo 歷史:每個操作都能回滾或重做。
排程/佇列:命令可被排隊、緩存、延後執行,甚至跨執行緒/Worker。
記錄與重播:把命令序列記錄到本地或伺服器,之後重播=「事件溯源」雛型。
權限/審計:在執行前後統一做驗證、記錄、權限檢查。
可組合:多個命令合成宏命令,形成複雜流程但仍保持簡潔 API。
核心結構與名詞
Command:命令介面(execute()、可選 undo())。
ConcreteCommand:具體命令,內含對 Receiver 的引用。
Receiver:真正執行業務邏輯的對象(例如 Editor、Cart、Player)。
Invoker:發出命令並管理歷史(例如 CommandManager)。
Client:組裝上述物件、配置依賴。
簡化示意(非程式碼):
Client → [Invoker] → (Command) → [Receiver]
↑ history ↓
undo/redo execute
最小可行範例(電燈開關)
這是經典示例,用來看模式形狀,不用太複雜。
// Receiver
class Light {
constructor() { this.isOn = false; }
on() { this.isOn = true; console.log('💡 Light ON'); }
off() { this.isOn = false; console.log('💤 Light OFF'); }
}
// Command 介面(在 JS 以約定為主)
class LightOnCommand {
constructor(light) { this.light = light; }
execute() { this.light.on(); }
undo() { this.light.off(); }
}
class LightOffCommand {
constructor(light) { this.light = light; }
execute() { this.light.off(); }
undo() { this.light.on(); }
}
// Invoker
class Remote {
constructor() { this.undoStack = []; }
press(command) {
command.execute();
if (typeof command.undo === 'function') {
this.undoStack.push(command);
}
}
undo() {
const cmd = this.undoStack.pop();
if (cmd) cmd.undo();
}
}
// 使用
const light = new Light();
const remote = new Remote();
remote.press(new LightOnCommand(light)); // ON
remote.press(new LightOffCommand(light)); // OFF
remote.undo(); // 回到 ON
重點:命令=把一次操作封裝成物件,Invoker 完全不關心 Receiver 細節。
文字編輯器完整實作:含 Undo/Redo
這個版本比較貼近實務,會處理插入/刪除,以及可靠的回滾。
1. Receiver:極簡 Editor(以字串為底)
class Editor {
constructor(text = '') { this._text = text; }
get text() { return this._text; }
insert(index, content) {
if (index < 0 || index > this._text.length) throw new RangeError('index out of range');
this._text = this._text.slice(0, index) + content + this._text.slice(index);
}
delete(start, length) {
if (start < 0 || start >= this._text.length) throw new RangeError('start out of range');
if (length <= 0) return '';
const removed = this._text.slice(start, start + length);
this._text = this._text.slice(0, start) + this._text.slice(start + length);
return removed;
}
}
2. Command:插入與刪除(含 undo)
class InsertTextCommand {
constructor(editor, index, content) {
this.editor = editor;
this.index = index;
this.content = content;
}
execute() {
this.editor.insert(this.index, this.content);
}
undo() {
this.editor.delete(this.index, this.content.length);
}
}
class DeleteRangeCommand {
constructor(editor, start, length) {
this.editor = editor;
this.start = start;
this.length = length;
this._deleted = ''; // memento
}
execute() {
this._deleted = this.editor.delete(this.start, this.length);
}
undo() {
this.editor.insert(this.start, this._deleted);
}
}
3. Invoker:CommandManager(Undo/Redo)
class CommandManager {
constructor() {
this.undoStack = [];
this.redoStack = [];
}
execute(command) {
command.execute();
if (typeof command.undo === 'function') {
this.undoStack.push(command);
this.redoStack.length = 0; // 清空 redo
}
}
undo() {
const cmd = this.undoStack.pop();
if (cmd && typeof cmd.undo === 'function') {
cmd.undo();
this.redoStack.push(cmd);
}
}
redo() {
const cmd = this.redoStack.pop();
if (cmd) {
cmd.execute();
this.undoStack.push(cmd);
}
}
clear() {
this.undoStack.length = 0;
this.redoStack.length = 0;
}
}
4. 串起來
const ed = new Editor('Hello');
const cm = new CommandManager();
cm.execute(new InsertTextCommand(ed, 5, ' World'));
console.log(ed.text); // Hello World
cm.execute(new DeleteRangeCommand(ed, 5, 1)); // 刪空白
console.log(ed.text); // HelloWorld
cm.undo();
console.log(ed.text); // Hello World
cm.redo();
console.log(ed.text); // HelloWorld
關鍵心法
Undo 需要相同上下文才能回補,因此命令內部要儲存足夠的狀態(像 _deleted)。
Redo 不是「把 undo 倒回來」;Redo 是重新執行一次 execute,才符合歷史語義。
把命令接到 UI(按鈕/快捷鍵/事件)
在瀏覽器中,Invoker 通常是「事件處理器 + CommandManager」。
<button id="insert">插入 "JS"</button>
<button id="undo">Undo</button>
<button id="redo">Redo</button>
<pre id="view"></pre>
<script>
const ed = new Editor('Pattern');
const cm = new CommandManager();
const view = document.getElementById('view');
const refresh = () => view.textContent = ed.text;
document.getElementById('insert').addEventListener('click', () => {
cm.execute(new InsertTextCommand(ed, ed.text.length, ' JS'));
refresh();
});
document.getElementById('undo').addEventListener('click', () => { cm.undo(); refresh(); });
document.getElementById('redo').addEventListener('click', () => { cm.redo(); refresh(); });
refresh();
</script>
把「操作」抽成命令後,UI 就乾淨許多,事件只負責組裝命令。
非同步命令與「補償式回滾」
實務上常見 API 呼叫、I/O、動畫都是非同步。此時命令管理器需要支援 Promise,並且在失敗時「補償式回滾」(compensation),讓 UI 不殘留錯誤狀態。
1. Async CommandManager
class AsyncCommandManager {
constructor() {
this.undoStack = [];
this.redoStack = [];
this._busy = false;
}
get busy() { return this._busy; }
async execute(command) {
if (this._busy) throw new Error('CommandManager busy');
this._busy = true;
try {
await command.execute(); // 若成功才進歷史
if (typeof command.undo === 'function') {
this.undoStack.push(command);
this.redoStack.length = 0;
}
} finally {
this._busy = false;
}
}
async undo() {
if (this._busy) throw new Error('CommandManager busy');
const cmd = this.undoStack.pop();
if (!cmd || typeof cmd.undo !== 'function') return;
this._busy = true;
try {
await cmd.undo();
this.redoStack.push(cmd);
} finally {
this._busy = false;
}
}
async redo() {
if (this._busy) throw new Error('CommandManager busy');
const cmd = this.redoStack.pop();
if (!cmd) return;
this._busy = true;
try {
await cmd.execute();
this.undoStack.push(cmd);
} finally {
this._busy = false;
}
}
}
2. 非同步命令:更新個人資料(含補償)
// 模擬 API
const api = {
async updateProfile(patch) {
await new Promise(r => setTimeout(r, 200));
if (Math.random() < 0.2) throw new Error('Network glitch');
return { ok: true, patch };
}
};
class UpdateProfileCommand {
constructor(model, patch) {
this.model = model; // Receiver:本地狀態模型
this.patch = patch; // 變更
this._snapshot = { ...model };// 供 undo 用的快照(簡化示例)
}
async execute() {
Object.assign(this.model, this.patch); // 先樂觀更新
try {
await api.updateProfile(this.patch);
} catch (e) {
// 失敗補償(把本地狀態回到快照)
Object.assign(this.model, this._snapshot);
throw e;
}
}
async undo() {
const redoSnapshot = { ...this.model };
Object.assign(this.model, this._snapshot);
// 可選:呼叫 API 回退(視後端支援而定)
this._snapshot = redoSnapshot; // 方便 redo
}
}
// 使用
(async () => {
const cm = new AsyncCommandManager();
const user = { name: 'Alex', city: 'Taipei' };
try {
await cm.execute(new UpdateProfileCommand(user, { city: 'Kaohsiung' }));
// 成功 → user.city === 'Kaohsiung'
} catch (e) {
// 失敗已自動補償 → user.city 回到 'Taipei'
}
})();
重點
非同步命令成功後才進 undo 歷史,避免失敗命令汙染堆疊。
樂觀更新時,一定要準備補償。
若後端支援「反向操作 API」,可在 undo() 觸發,否則以本地回補為主。
巨集命令(Macro)與批次操作
巨集命令把多個命令合成為一個命令,execute() 逐一執行;undo() 則反向逐一回滾。
class MacroCommand {
constructor(commands) { this.commands = commands.slice(); }
execute() {
for (const c of this.commands) c.execute();
}
undo() {
for (let i = this.commands.length - 1; i >= 0; i--) {
const c = this.commands[i];
if (typeof c.undo === 'function') c.undo();
}
}
}
// 例:在 Editor 先插入、再刪除某段空格
const macro = new MacroCommand([
new InsertTextCommand(ed, ed.text.length, ' JS'),
new DeleteRangeCommand(ed, 5, 1),
]);
cm.execute(macro);
可序列化命令與事件溯源概念
若要把操作記錄到伺服器以便「重播」,命令需要可序列化(serialize/deserialize)。常見做法:
每個命令提供 toJSON() 以及對應的工廠方法。
用一個註冊表(registry)由 type 找回命令建構子。
簡例:
class Registry {
constructor() { this.map = new Map(); }
register(type, ctor) { this.map.set(type, ctor); }
create({ type, args }) { return new (this.map.get(type))(...args); }
}
const registry = new Registry();
registry.register('InsertText', InsertTextCommand);
registry.register('DeleteRange', DeleteRangeCommand);
// 命令提供序列化
InsertTextCommand.prototype.toJSON = function() {
return { type: 'InsertText', args: [this.editor, this.index, this.content] };
};
// 注意:實務上 editor 不能直接序列化;通常只存「目標 ID」再在重建時注入 Receiver。
實務提醒:序列化時不要直接保存 Receiver 實例,而是保存「資源識別(ID)」;重播時由 Client 重新注入 Receiver。
與其他模式的邊界
Strategy(策略):側重「可替換的演算法」,呼叫者仍直接執行方法;Command 則把「一次操作」封成物件,可排程、撤銷。
Memento(備忘錄):儲存狀態快照;在命令中常用於 Undo。
Mediator(仲介者):協調多對多溝通;命令也可透過 Mediator 下達,讓 Receiver 更鬆耦合。
常見錯誤與雷點
1. 過度設計:簡單一次性呼叫硬要封命令,反而增加維護成本。
建議:需要 Undo/Redo、排程、審計、巨集才導入。
2. Undo 內容不足:沒有保存足夠資訊導致回滾不準。
建議:在 execute() 前先取快照,或在 execute() 時保存變更差異(diff)。
3. Redo 語義錯誤:把 redo 寫成「呼叫 undo() 的反向」,卻沒重新跑邏輯。
建議:Redo=再執行一次 execute()。
4. 歷史堆疊無上限:長時間操作造成記憶體暴衝。
建議:設定上限、週期性壓縮(例如每 50 筆快照成單一大快照)。
5. 非同步錯誤處理不一致:失敗命令也被推入 undoStack。
建議:只在 execute() 成功後才記錄歷史。
6. Receiver 泄漏:把複雜的 Receiver 暴露到命令外部,導致認知負擔。
建議:命令內部處理 Receiver 細節,UI 只組裝命令。
7. this 綁定踩雷(特別在 class method 當 callback)。
建議:使用箭頭函式或在建構時 this.method = this.method.bind(this)。
8. 共享可變狀態:多命令同時改一個共享物件,順序不同結果不同。
建議:限制併發、引入鎖/佇列,或以不可變資料(immutable)降低風險。
9. 序列化 Receiver:把整個實例寫進 JSON。
建議:只存 ID,重建時注入。
10. 缺少權限檢查/審計:命令執行無審核,難追蹤。
建議:在 Invoker 增加中介層(middleware/hook)。
11. 命令過度臃腫:execute 同時做驗證、轉換、I/O,難測試。
建議:把驗證/轉換抽出,可測的純函式優先。
12. 歷史與 UI 耦合:在 UI 直接改 undoStack。
建議:所有變更一律走 Invoker API。
13. 事件處理漏掉防抖/節流:按鍵重覆觸發導致重覆命令。
建議:需要時加入節流或將重覆命令合併(coalesce)。
14. 命令不具冪等性:重播/重試產生副作用。
建議:在需要重播的場景設計為冪等,或加去重機制(例如命令 ID)。
延伸閱讀推薦:
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設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Observer( 觀察者 )
javaScript設計模式 : Strategy(策略模式)
