JavaScript の Command パターン - 操作の記録と取り消し

ボタンを押したら何かが実行される。その「何か」を関数に直接書いてしまうのは簡単ですが、操作の取り消し(Undo)や再実行(Redo)、操作履歴の保存が求められた瞬間に破綻します。Command パターンは、操作そのものをオブジェクトとしてカプセル化し、実行・取り消し・記録を統一的に扱えるようにする振る舞いパターンです。

操作をオブジェクトにするという発想

通常、ボタンのクリックハンドラには処理を直接記述します。

button.addEventListener("click", () => {
  document.body.style.backgroundColor = "red";
});

これだと「何をしたか」の記録が残りません。元の色に戻す手段も、操作を再実行する手段もないまま、処理は実行されて消えていきます。Command パターンでは、この処理を execute メソッドを持つオブジェクトに包みます。

直接実行

処理がイベントハンドラに埋め込まれ、実行と同時に消える。取り消しや記録の仕組みを後から追加するのが難しい。

Command オブジェクト

操作が独立したオブジェクトになり、実行前に保存したり、実行後に取り消したり、キューに溜めてまとめて処理したりできる。

操作がデータとして存在するようになるため、操作に対して「いつ実行するか」「何回実行するか」「取り消せるか」といった制御を自由に追加できるようになります。

基本構造の実装

Command パターンの最小構成を見てみましょう。テキストエディタの太字切り替えを題材にします。

class BoldCommand {
  constructor(editor) {
    this.editor = editor;
    this.previousState = null;
  }

  execute() {
    this.previousState = this.editor.isBold;
    this.editor.isBold = !this.editor.isBold;
  }

  undo() {
    this.editor.isBold = this.previousState;
  }
}

execute で操作を実行する前に、現在の状態を previousState に保存しています。undo ではその保存値を復元するだけです。操作の実行ロジックと取り消しロジックが 1 つのオブジェクトに同居しているのが Command パターンの核心にあたります。

Command パターンの登場人物

パターンを構成する要素を整理します。

Command(コマンド)

executeundo を持つオブジェクトです。操作の実行方法と取り消し方法をカプセル化します。

Receiver(レシーバー)

実際の処理を担うオブジェクトです。上の例ではエディタがこれに該当します。Command はレシーバーに対して操作を委譲します。

Invoker(インボーカー)

Command の実行を要求する側です。ボタンやキーボードショートカット、メニュー項目などが該当し、Command の具体的な中身は知りません。

Client(クライアント)

Command を生成し、Invoker に渡す役割を担います。どの Command をどの Receiver に対して使うかを決定する構成役です。

Undo/Redo の履歴管理

Command パターンの真価は、操作履歴を管理する仕組みと組み合わせたときに現れます。実行した Command をスタックに積み、Undo 時にはそこから取り出して undo を呼ぶだけです。

class CommandHistory {
  constructor() {
    this.undoStack = [];
    this.redoStack = [];
  }

  execute(command) {
    command.execute();
    this.undoStack.push(command);
    this.redoStack = [];
  }

  undo() {
    const command = this.undoStack.pop();
    if (!command) return;
    command.undo();
    this.redoStack.push(command);
  }

  redo() {
    const command = this.redoStack.pop();
    if (!command) return;
    command.execute();
    this.undoStack.push(command);
  }
}

新しい操作を実行すると Redo スタックがクリアされる点に注目してください。Undo で過去に戻った後に新しい操作をした場合、元の未来の操作は破棄されるのが一般的な Undo/Redo の挙動です。

Command を実行し undoStack に積む

Undo 時に undoStack から取り出し redoStack に移す

Redo 時に redoStack から取り出し undoStack に戻す

実践例:お絵描きアプリ

もう少し実践的な例として、キャンバスに図形を描くお絵描きアプリを考えます。各操作が Command になり、自由に取り消し・やり直しができる設計です。

class Canvas {
  constructor() {
    this.shapes = [];
  }

  addShape(shape) {
    this.shapes.push(shape);
  }

  removeShape(shape) {
    const index = this.shapes.indexOf(shape);
    if (index !== -1) this.shapes.splice(index, 1);
  }

  render() {
    console.log("Canvas:", this.shapes.map(s => s.toString()).join(", ") || "(empty)");
  }
}

class Shape {
  constructor(type, x, y) {
    this.type = type;
    this.x = x;
    this.y = y;
  }

  toString() {
    return `${this.type}(${this.x}, ${this.y})`;
  }
}

Canvas が Receiver、Shape がデータオブジェクトです。次に、図形の追加と移動に対応する Command を作ります。

class AddShapeCommand {
  constructor(canvas, shape) {
    this.canvas = canvas;
    this.shape = shape;
  }

  execute() {
    this.canvas.addShape(this.shape);
  }

  undo() {
    this.canvas.removeShape(this.shape);
  }
}

class MoveShapeCommand {
  constructor(shape, dx, dy) {
    this.shape = shape;
    this.dx = dx;
    this.dy = dy;
  }

  execute() {
    this.shape.x += this.dx;
    this.shape.y += this.dy;
  }

  undo() {
    this.shape.x -= this.dx;
    this.shape.y -= this.dy;
  }
}

AddShapeCommand の Undo は図形の削除、MoveShapeCommand の Undo は逆方向への移動です。操作の性質に応じて取り消し方が異なりますが、呼び出し側は undo() を呼ぶだけでよいという点は変わりません。

const canvas = new Canvas();
const history = new CommandHistory();

const circle = new Shape("Circle", 10, 20);
const rect = new Shape("Rect", 50, 50);

history.execute(new AddShapeCommand(canvas, circle));
canvas.render();
// Canvas: Circle(10, 20)

history.execute(new AddShapeCommand(canvas, rect));
canvas.render();
// Canvas: Circle(10, 20), Rect(50, 50)

history.execute(new MoveShapeCommand(circle, 5, -3));
canvas.render();
// Canvas: Circle(15, 17), Rect(50, 50)

history.undo();
canvas.render();
// Canvas: Circle(10, 20), Rect(50, 50)

history.undo();
canvas.render();
// Canvas: Circle(10, 20)

history.redo();
canvas.render();
// Canvas: Circle(10, 20), Rect(50, 50)

操作の種類が増えても、executeundo を持つ Command を追加するだけで履歴管理の仕組みはそのまま使えます。

マクロコマンド:複数操作をまとめる

複数の Command を 1 つにまとめて実行・取り消しする「マクロコマンド」も、Command パターンの応用として自然に実装できます。

class MacroCommand {
  constructor(commands) {
    this.commands = commands;
  }

  execute() {
    this.commands.forEach(cmd => cmd.execute());
  }

  undo() {
    [...this.commands].reverse().forEach(cmd => cmd.undo());
  }
}

undo で逆順に取り消している点が重要です。操作 A → B の順で実行したなら、取り消しは B → A の順でなければ整合性が保てません。

const star = new Shape("Star", 0, 0);

const macro = new MacroCommand([
  new AddShapeCommand(canvas, star),
  new MoveShapeCommand(star, 30, 40),
]);

history.execute(macro);
canvas.render();
// Canvas: Circle(10, 20), Rect(50, 50), Star(30, 40)

history.undo();
canvas.render();
// Canvas: Circle(10, 20), Rect(50, 50)

ユーザーから見れば 1 回の Undo で複数の操作がまとめて巻き戻されるため、「図形を追加して配置する」のような複合操作を 1 つの単位として扱えるようになります。

JavaScript ならではの軽量 Command

ここまではクラスベースで書いてきましたが、JavaScript では関数がファーストクラスオブジェクトなので、もっと軽い記法でも Command パターンを表現できます。

function createCommand(executeFn, undoFn) {
  return { execute: executeFn, undo: undoFn };
}

let count = 0;

const increment = createCommand(
  () => { count += 1; },
  () => { count -= 1; }
);

increment.execute();
console.log(count); // 1

increment.undo();
console.log(count); // 0
クラスベース

状態の保持や複雑な Undo ロジックが必要な場合に向いている。お絵描きアプリのように、操作ごとに固有のデータを持つケースではクラスが自然。

関数ベース

単純な操作や、状態の保持が不要な場合に適している。JavaScript のクロージャを活用すれば、クラスを定義するオーバーヘッドなく Command を量産できる。

どちらを選ぶかは操作の複雑さ次第です。単純なトグルや加減算なら関数ベースで十分ですし、操作前の状態スナップショットが必要なら、クラスベースのほうが見通しがよくなります。

使いどころの判断

Command パターンは強力ですが、あらゆる場面で使うべきというわけではありません。

有効な場面

Undo/Redo が必要なアプリケーション。操作のキューイングや遅延実行が求められるとき。操作ログの記録や監査証跡が必要なとき。マクロ機能のように複数操作を 1 つにまとめたいとき。

避けるべき場面

取り消しや記録が不要な単純な処理に適用すると、不必要な抽象化レイヤーが増えるだけです。コールバックや単純なイベントハンドラで十分な場面では、素直にそのまま書くほうが明快になります。

Command パターンで Undo を実現するとき、最も重要な設計上のポイントはどれですか?

  • Command の execute メソッドを非同期にすること
  • execute 実行前の状態を Command 内部に保存しておくこと
  • Receiver を Singleton にして状態を一元管理すること
  • Command をイミュータブルなオブジェクトにすること
__RESULT__

Undo の本質は「操作前の状態に戻す」ことです。そのためには、execute を呼ぶ前の状態を Command 自身が保持している必要があります。この保存がなければ、undo メソッドはどこに戻ればよいかを知ることができません。

Command パターンは、操作を「実行して終わり」から「記録可能で取り消し可能なデータ」に昇格させるパターンです。テキストエディタ、お絵描きツール、フォームの操作履歴など、ユーザーの操作を丁寧に扱う必要があるアプリケーションでは、設計の早い段階で導入を検討する価値があります。