Що таке патерн Command?
Патерн Command перетворює дію на об'єкт, який можна зберегти, передати, поставити в чергу або скасувати.
Теорія
TL;DR
- Аналогія: офіціант передає кухні (receiver) slip із замовленням (command object). Кухня виконує, не знаючи хто клієнт.
- Головна ідея: обгорнути запит в об'єкт, щоб відправник і виконавець нічого не знали одне про одного.
execute()таundo()живуть на об'єкті-команді, що робить будь-яку дію зворотною.- Використовуй коли потрібне undo/redo, черги задач або складені макрокоманди.
- Для простих одноразових викликів без черги і undo, просто виклич метод напряму.
Короткий приклад
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. Забув зберегти стан у конструкторі
// Неправильно: 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. Мутація спільного стану без знімку
// Неправильно: 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()
// Падає на порожній черзі
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 відновлює попередній знімок. Для паралельних оновлень додаєш лічильник версій і відкидаєш застарілі команди.
Приклади
Базовий: пульт дистанційного керування
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 з історією змін
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 зберігають історію малювання. Кожна дія користувача - одна команда в стеку.
Просунутий рівень: транзакційна макрокоманда
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. Саме так транзакції в базах даних обробляють часткову відмову.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.