JavaScript の Command パターン - 操作の記録と取り消し
ボタンを押したら何かが実行される。その「何か」を関数に直接書いてしまうのは簡単ですが、操作の取り消し(Undo)や再実行(Redo)、操作履歴の保存が求められた瞬間に破綻します。Command パターンは、操作そのものをオブジェクトとしてカプセル化し、実行・取り消し・記録を統一的に扱えるようにする振る舞いパターンです。
操作をオブジェクトにするという発想
通常、ボタンのクリックハンドラには処理を直接記述します。
button.addEventListener("click", () => {
document.body.style.backgroundColor = "red";
});これだと「何をしたか」の記録が残りません。元の色に戻す手段も、操作を再実行する手段もないまま、処理は実行されて消えていきます。Command パターンでは、この処理を execute メソッドを持つオブジェクトに包みます。
処理がイベントハンドラに埋め込まれ、実行と同時に消える。取り消しや記録の仕組みを後から追加するのが難しい。
操作が独立したオブジェクトになり、実行前に保存したり、実行後に取り消したり、キューに溜めてまとめて処理したりできる。
操作がデータとして存在するようになるため、操作に対して「いつ実行するか」「何回実行するか」「取り消せるか」といった制御を自由に追加できるようになります。
基本構造の実装
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 パターンの登場人物
パターンを構成する要素を整理します。
execute と undo を持つオブジェクトです。操作の実行方法と取り消し方法をカプセル化します。
実際の処理を担うオブジェクトです。上の例ではエディタがこれに該当します。Command はレシーバーに対して操作を委譲します。
Command の実行を要求する側です。ボタンやキーボードショートカット、メニュー項目などが該当し、Command の具体的な中身は知りません。
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)操作の種類が増えても、execute と undo を持つ 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 をイミュータブルなオブジェクトにすること
Command パターンは、操作を「実行して終わり」から「記録可能で取り消し可能なデータ」に昇格させるパターンです。テキストエディタ、お絵描きツール、フォームの操作履歴など、ユーザーの操作を丁寧に扱う必要があるアプリケーションでは、設計の早い段階で導入を検討する価値があります。
Undo の本質は「操作前の状態に戻す」ことです。そのためには、execute を呼ぶ前の状態を Command 自身が保持している必要があります。この保存がなければ、undo メソッドはどこに戻ればよいかを知ることができません。