Skip to main content

Що таке патерн Command?

Патерн Command перетворює дію на об'єкт, який можна зберегти, передати, поставити в чергу або скасувати.

Теорія

TL;DR

  • Аналогія: офіціант передає кухні (receiver) slip із замовленням (command object). Кухня виконує, не знаючи хто клієнт.
  • Головна ідея: обгорнути запит в об'єкт, щоб відправник і виконавець нічого не знали одне про одного.
  • execute() та undo() живуть на об'єкті-команді, що робить будь-яку дію зворотною.
  • Використовуй коли потрібне undo/redo, черги задач або складені макрокоманди.
  • Для простих одноразових викликів без черги і undo, просто виклич метод напряму.

Короткий приклад

javascript
class Light { on() { console.log('Світло увімкнено'); } off() { console.log('Світло вимкнено'); } } class LightOnCommand { constructor(light) { this.light = light; } execute() { this.light.on(); } // Світло увімкнено } class RemoteControl { setCommand(cmd) { this.slot = cmd; } pressButton() { this.slot.execute(); } } const remote = new RemoteControl(); remote.setCommand(new LightOnCommand(new Light())); remote.pressButton(); // Світло увімкнено

RemoteControl нічого не знає про Light. Він лише викликає execute(). Замінюєш LightOnCommand на DimmerOnCommand і код пульта залишається без змін.

Головна відмінність від прямого виклику

Коли викликаєш light.on() прямо з пульта, пульт і лампочка пов'язані. Додаєш димер і переписуєш пульт. З Command пульт тримає об'єкт-команду і викликає execute(). Відправник не змінюється, коли змінюється отримувач.

Коли використовувати

  • Потрібне undo/redo: команда зберігає в конструкторі достатньо стану для скасування.
  • Черги задач: дії стають об'єктами, які можна серіалізувати і відкласти.
  • Макрокоманди: кілька команд об'єднуються в одну складену дію.
  • Логування і транзакції: обгортаєш execute хуками до і після.
  • Простий одноразовий виклик без черги і undo: не потрібен Command, накладні витрати на об'єкти без жодного результату.

Як це працює в JavaScript

Команди - це звичайні об'єкти в V8. Виклик execute() диспетчеризується через прототипне наслідування, без спеціальної магії рушія. Стан зберігається через closure і посилання, передані в конструктор. Node.js event loop природно ставить команди в чергу через setImmediate; браузерні UI-команди часто використовують requestAnimationFrame.

Типові помилки

1. Забув зберегти стан у конструкторі

javascript
// Неправильно: this.text = undefined в undo class AddTextCommand { execute() { editor.add('hello'); } undo() { editor.remove(this.text); } // TypeError: undefined } // Правильно class AddTextCommand { constructor(editor, text) { this.editor = editor; this.text = text; // зберігаємо для undo } execute() { this.editor.add(this.text); } undo() { this.editor.remove(this.text); } }

2. Мутація спільного стану без знімку

javascript
// Неправильно: undo видаляє елемент, але re-execute знову росте масив constructor(editor) { this.editor = editor; } // немає backup // Правильно: знімок у конструкторі constructor(editor, text) { this.backup = [...editor.text]; // зберегли до execute } undo() { this.editor.text = this.backup; } // повне відновлення

Це найчастіший баг у черзі undo, який я бачив у продакшені. Масив мутує між re-execute і логіка скасування тихо збивається.

3. Немає null-перевірки на history.pop()

javascript
// Падає на порожній черзі undo() { const cmd = history.pop(); cmd.undo(); } // Безпечно undo() { const cmd = history.pop(); cmd?.undo(); }

4. Зловживання Command для простих дій

Одна функція без черги і undo? Просто виклич її. Кожен об'єкт-команда коштує алокацію і цикл GC. У щільних циклах це відчутно.

Де зустрічається в реальних проектах

  • Redux: кожен dispatched action - об'єкт-команда, reducer - receiver.
  • BullMQ (Node.js): задачі в черзі реалізовані як команди з вбудованою логікою retry.
  • Figma і Photoshop: вся історія undo - це стек команд.
  • Бібліотека use-undo (React): обгортає зміни стану як зворотні команди.
  • jQuery UI: використовує команди всередині для undo у drag-and-drop.

Follow-up питання

Q: Як реалізувати undo для canvas?
A: Зберігай знімок стану в конструкторі команди. В undo() відновлюй зі знімку, а не намагайся вгадати зворотну операцію.

Q: Як Command дозволяє макрокоманди?
A: MacroCommand тримає масив команд. execute() проходить вперед, undo() у зворотному порядку. Якщо одна команда падає, всі вже виконані відкочуються назад перед повторним кидком помилки.

Q: Яка різниця між Command і Strategy?
A: Strategy підміняє алгоритм під час виконання, але виконує його одразу. Command обгортає запит для відкладення, черги або скасування. Strategy про як зробити, Command про коли і чи взагалі.

Q: Як обробляти async-команди в Node.js?
A: Повертай Promise з execute() і чекай його в invoker. Сам патерн не змінюється, але треба обробляти часткове відкочування якщо команда в макроланцюзі відхиляє Promise.

Q: Спроектуй кошик з undo і знижками.
A: Кожна дія (додати товар, застосувати знижку) стає командою. Знімаєш знімок суми до execute. Rollback відновлює попередній знімок. Для паралельних оновлень додаєш лічильник версій і відкидаєш застарілі команди.

Приклади

Базовий: пульт дистанційного керування

javascript
class Light { on() { console.log('Світло увімкнено'); } off() { console.log('Світло вимкнено'); } } 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(); } } class RemoteControl { constructor() { this.history = []; } press(cmd) { cmd.execute(); this.history.push(cmd); } undo() { this.history.pop()?.undo(); } } const remote = new RemoteControl(); const light = new Light(); remote.press(new LightOnCommand(light)); // Світло увімкнено remote.press(new LightOffCommand(light)); // Світло вимкнено remote.undo(); // Світло увімкнено (скасовано)

Пульт тримає об'єкти-команди, не пряме посилання на лампочку. Код пульта не змінюється при будь-якій заміні команди.

Середній рівень: canvas з історією змін

javascript
class Canvas { constructor() { this.elements = []; } add(shape) { this.elements.push(shape); console.log(`Додано ${shape}`); } remove(shape) { this.elements = this.elements.filter(s => s !== shape); console.log(`Видалено ${shape}`); } } class AddShapeCommand { constructor(canvas, shape) { this.canvas = canvas; this.shape = shape; } execute() { this.canvas.add(this.shape); } undo() { this.canvas.remove(this.shape); } } class History { constructor() { this.stack = []; } execute(cmd) { cmd.execute(); this.stack.push(cmd); } undo() { this.stack.pop()?.undo(); } } const canvas = new Canvas(); const history = new History(); history.execute(new AddShapeCommand(canvas, 'коло')); // Додано коло history.execute(new AddShapeCommand(canvas, 'квадрат')); // Додано квадрат history.undo(); // Видалено квадрат console.log(canvas.elements); // ['коло']

Саме так Figma і Photoshop зберігають історію малювання. Кожна дія користувача - одна команда в стеку.

Просунутий рівень: транзакційна макрокоманда

javascript
class MacroCommand { constructor(commands) { this.commands = commands; } execute() { const done = []; for (const cmd of this.commands) { try { cmd.execute(); done.push(cmd); } catch (e) { done.slice().reverse().forEach(c => c.undo()); // відкочування throw e; } } } undo() { this.commands.slice().reverse().forEach(cmd => cmd.undo()); } } class ApiCallCommand { constructor(id) { this.id = id; } execute() { if (Math.random() < 0.3) throw new Error('Таймаут мережі'); console.log(`Оброблено ${this.id}`); } undo() { console.log(`Відкочено ${this.id}`); } } const macro = new MacroCommand([ new ApiCallCommand(1), new ApiCallCommand(2), new ApiCallCommand(3), ]); try { macro.execute(); } catch (e) { console.log('Макрос упав, всі зміни відкочено'); }

Зворотний порядок undo тут принциповий. Якщо крок 3 падає після успішних кроків 1 і 2, відкочуємо спочатку 2, потім 1. Саме так транзакції в базах даних обробляють часткову відмову.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?