rws8.tech
  • Home
  • Tutorials
  • About
Home > Tutorials > JavaScript > Command Pattern > Lesson 1

Mastering the Command Pattern

Encapsulate Requests as Objects

Learning Objectives

  • Understand the Command pattern concept
  • Encapsulate actions as objects
  • Implement undo/redo functionality
  • Create command queues
  • Apply command best practices

What is the Command Pattern?

Command turns requests into stand-alone objects containing all information about the request. This enables undo/redo, queuing, logging, and decoupling of sender and receiver.

Basic Example

class Command {
    execute() {
        throw new Error('execute() must be implemented');
    }
    
    undo() {
        throw new Error('undo() must be implemented');
    }
}

class AddCommand extends Command {
    constructor(receiver, value) {
        super();
        this.receiver = receiver;
        this.value = value;
    }
    
    execute() {
        this.receiver.add(this.value);
        console.log(`Added ${this.value}`);
    }
    
    undo() {
        this.receiver.subtract(this.value);
        console.log(`Undid add of ${this.value}`);
    }
}

class SubtractCommand extends Command {
    constructor(receiver, value) {
        super();
        this.receiver = receiver;
        this.value = value;
    }
    
    execute() {
        this.receiver.subtract(this.value);
        console.log(`Subtracted ${this.value}`);
    }
    
    undo() {
        this.receiver.add(this.value);
        console.log(`Undid subtract of ${this.value}`);
    }
}

class Calculator {
    constructor() {
        this.value = 0;
    }
    
    add(val) {
        this.value += val;
        console.log(`Current value: ${this.value}`);
    }
    
    subtract(val) {
        this.value -= val;
        console.log(`Current value: ${this.value}`);
    }
}

// Usage
const calc = new Calculator();
const addCmd = new AddCommand(calc, 10);
const subCmd = new SubtractCommand(calc, 5);

addCmd.execute(); // Added 10, Current value: 10
subCmd.execute(); // Subtracted 5, Current value: 5
subCmd.undo();    // Undid subtract of 5, Current value: 10
addCmd.undo();    // Undid add of 10, Current value: 0

Undo/Redo Manager

class CommandManager {
    constructor() {
        this.history = [];
        this.current = -1;
    }
    
    execute(command) {
        // Remove any commands after current position
        this.history = this.history.slice(0, this.current + 1);
        
        command.execute();
        this.history.push(command);
        this.current++;
    }
    
    undo() {
        if (this.current >= 0) {
            this.history[this.current].undo();
            this.current--;
        } else {
            console.log('Nothing to undo');
        }
    }
    
    redo() {
        if (this.current < this.history.length - 1) {
            this.current++;
            this.history[this.current].execute();
        } else {
            console.log('Nothing to redo');
        }
    }
    
    getHistory() {
        return this.history.map((cmd, i) => ({
            command: cmd.constructor.name,
            current: i === this.current
        }));
    }
}

// Usage
const manager = new CommandManager();
const calc = new Calculator();

manager.execute(new AddCommand(calc, 5));
manager.execute(new AddCommand(calc, 3));
manager.execute(new SubtractCommand(calc, 2));

console.log(manager.getHistory());
// [{ command: 'AddCommand', current: false },
//  { command: 'AddCommand', current: false },
//  { command: 'SubtractCommand', current: true }]

manager.undo(); // Undo subtract
manager.undo(); // Undo second add
manager.redo(); // Redo second add

Real-World Examples

Text Editor Commands

class TextEditor {
    constructor() {
        this.content = '';
    }
    
    insert(text, position) {
        this.content = 
            this.content.slice(0, position) + 
            text + 
            this.content.slice(position);
    }
    
    delete(position, length) {
        this.content = 
            this.content.slice(0, position) + 
            this.content.slice(position + length);
    }
    
    getContent() {
        return this.content;
    }
}

class InsertCommand extends Command {
    constructor(editor, text, position) {
        super();
        this.editor = editor;
        this.text = text;
        this.position = position;
    }
    
    execute() {
        this.editor.insert(this.text, this.position);
    }
    
    undo() {
        this.editor.delete(this.position, this.text.length);
    }
}

class DeleteCommand extends Command {
    constructor(editor, position, length) {
        super();
        this.editor = editor;
        this.position = position;
        this.length = length;
        this.deletedText = '';
    }
    
    execute() {
        this.deletedText = this.editor.getContent()
            .slice(this.position, this.position + this.length);
        this.editor.delete(this.position, this.length);
    }
    
    undo() {
        this.editor.insert(this.deletedText, this.position);
    }
}

// Usage
const editor = new TextEditor();
const manager = new CommandManager();

manager.execute(new InsertCommand(editor, 'Hello', 0));
manager.execute(new InsertCommand(editor, ' World', 5));
console.log(editor.getContent()); // "Hello World"

manager.undo();
console.log(editor.getContent()); // "Hello"

manager.redo();
console.log(editor.getContent()); // "Hello World"

Command Queue

class CommandQueue {
    constructor() {
        this.queue = [];
        this.isProcessing = false;
    }
    
    add(command) {
        this.queue.push(command);
        if (!this.isProcessing) {
            this.process();
        }
    }
    
    async process() {
        this.isProcessing = true;
        
        while (this.queue.length > 0) {
            const command = this.queue.shift();
            await command.execute();
        }
        
        this.isProcessing = false;
    }
}

class AsyncCommand extends Command {
    constructor(task, delay = 1000) {
        super();
        this.task = task;
        this.delay = delay;
    }
    
    async execute() {
        console.log(`Starting: ${this.task}`);
        await new Promise(resolve => setTimeout(resolve, this.delay));
        console.log(`Completed: ${this.task}`);
    }
    
    undo() {
        console.log(`Undoing: ${this.task}`);
    }
}

// Usage
const queue = new CommandQueue();

queue.add(new AsyncCommand('Task 1', 1000));
queue.add(new AsyncCommand('Task 2', 500));
queue.add(new AsyncCommand('Task 3', 800));
// Tasks execute sequentially

When to Use Command Pattern

Good use cases:
  • Undo/redo functionality
  • Command queuing and scheduling
  • Transaction systems
  • Macro recording and playback
  • Job scheduling
  • Logging and auditing

Benefits

  • Decouples sender/receiver: Invoker doesn't know about receiver
  • Supports undo/redo: Easy to implement reversible operations
  • Command queuing: Queue and execute commands later
  • Logging/auditing: Track all operations
  • Macro commands: Combine multiple commands

Best Practices

1. Always implement both execute and undo
class MyCommand extends Command {
    execute() {
        // Do something
    }
    
    undo() {
        // Reverse it
    }
}
2. Store state needed for undo
class DeleteCommand extends Command {
    execute() {
        this.deletedData = this.receiver.getData();
        this.receiver.delete();
    }
    
    undo() {
        this.receiver.restore(this.deletedData);
    }
}
3. Use command manager for history
const manager = new CommandManager();
manager.execute(command); // Adds to history
manager.undo();
manager.redo();

Key Takeaways

  • Command encapsulates requests as objects
  • Perfect for undo/redo functionality
  • Supports queuing and logging
  • Decouples invoker from receiver
  • Easy to add new commands
  • Store state needed for undo operations
  • Use command manager for history tracking
  • Common in text editors, graphics apps, transaction systems
← Back to Overview More JavaScript Tutorials →

© 2024 rws8.tech. All rights reserved.

GitHub LinkedIn