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

 


專案做大後,最惱人的不是 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設計模式 : Facade(外觀模式)

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

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

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

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

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

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

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

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

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

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

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

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


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