Що таке патерн Strategy?
Патерн Strategy - поведінковий патерн проектування, який визначає сімейство алгоритмів, загортає кожен в окремий об'єкт і дає контексту змогу перемикати їх під час виконання.
Теорія
TL;DR
- Аналогія: зміна двигуна на піт-стопі - та сама машина (контекст), інший двигун (стратегія) для траси, траси або позадоріжжя
- Головна ідея: замість if/else ланцюжків - делегування поведінки ін'єктованим об'єктам
- Використовуй коли: 3+ алгоритми для однієї операції або вибір відбувається в runtime
- Пропусти коли: 2 варіанти. Тернарний оператор або дві функції справляться простіше.
Швидкий приклад
class PayPalStrategy { pay(amount) { return `Paid $${amount} via PayPal`; } }
class CardStrategy { pay(amount) { return `Paid $${amount} via Card`; } }
class Processor {
constructor(strategy) { this.strategy = strategy; }
process(amount) { return this.strategy.pay(amount); }
}
const p = new Processor(new PayPalStrategy());
console.log(p.process(100)); // "Paid $100 via PayPal"
p.strategy = new CardStrategy();
console.log(p.process(100)); // "Paid $100 via Card"Processor викликає pay() і довіряє стратегії обробити все інше. Він не перевіряє, який метод оплати ти передав.
Головна відмінність
Без Strategy метод process() обростає if/else на кожен спосіб оплати. Додаєш Crypto - відкриваєш файл, правиш блок, ризикуєш зламати вже робочу логіку. Зі Strategy пишеш один новий клас і ін'єктуєш його. Контекст не змінюється. Алгоритми розвиваються незалежно від коду, який їх викликає.
Коли використовувати
- 3+ способи зробити одну операцію (сортування, валідація, компресія, аутентифікація): Strategy
- Користувач обирає поведінку в runtime (метод оплати, провайдер входу): Strategy
- Поведінка змінюється залежно від конфігурації або середовища: Strategy
- У тебе
switchз 5+ варіантами, які роблять "одне і те ж по-різному": рефактор до Strategy - 2 варіанти без перемикання в runtime: тернарний оператор або два прямих виклики функцій
Як це працює в JavaScript
У V8 об'єкти стратегій є значеннями першого класу. Коли виконується this.strategy.pay(), рушій знаходить метод через динамічний пошук по ланцюжку прототипів у момент виклику. Ніякого зв'язування на етапі компіляції, ніякої vtable як у C++. Будь-який об'єкт з методом pay() підходить як стратегія. Duck typing робить те, що формальні інтерфейси роблять у Java або C#. Ключового слова implements не потрібно.
Типові помилки
Відсутність валідації стратегії в конструкторі
const p = new Processor(); // стратегію не передано
p.process(100); // TypeError: Cannot read properties of undefinedПеревіряй одразу в конструкторі:
constructor(strategy) {
if (!strategy?.pay) throw new Error('Strategy must implement pay()');
this.strategy = strategy;
}Спільний стан між контекстами
class LoggingStrategy {
constructor() { this.log = []; }
pay(amount) { this.log.push(amount); return 'Paid'; }
}
const shared = new LoggingStrategy();
const p1 = new Processor(shared);
const p2 = new Processor(shared);
p1.process(100);
p2.process(200);
// shared.log тепер [100, 200] - стан витік між обома процесорамиСтратегії мають бути без стану. Якщо потрібен стан, створюй новий екземпляр для кожного контексту. Це часте джерело непомітних багів у стратегіях логування та аналітики.
Надмірне ускладнення для двох варіантів
// Непотрібно: два класи для бінарного вибору
class USDStrategy { pay() { /* ... */ } }
class EURStrategy { pay() { /* ... */ } }Два варіанти? Пиши тернарний оператор. Патерн виправданий тільки коли варіантів 3+ і вони реально перемикаються в runtime.
Відсутність перевірки на null після динамічної заміни
p.strategy = null;
p.process(100); // крахЯкщо стратегії замінюються динамічно, захищайся від null:
process(amount) {
return this.strategy?.pay(amount) ?? 'No strategy configured';
}Де зустрічається
- Passport.js:
passport.use(new LocalStrategy(...))іpassport.use(new GoogleStrategy(...))- кожен провайдер аутентифікації є Strategy, підключеною до одного й того ж middleware-контексту. У кожній кодовій базі з Passport.js, яку я бачив, цей патерн вже є, незалежно від того, чи команда знала його назву. - TanStack Table v8+: функції-компаратори, ін'єктовані через column meta, для перемикання сортування в runtime
- Node.js zlib:
createDeflate(),createGzip(),createBrotliCompress()- кожна функція є окремою стратегією стиснення в stream pipeline - Lodash:
_.sortByз функціями-ітераторами - функції як легковагові стратегії без накладних витрат класів
Питання на співбесіді
Q: Чим Strategy відрізняється від Template Method?
A: Template Method розміщує скелет алгоритму в базовому класі і дозволяє підкласам перевизначати окремі кроки через наслідування. Strategy замінює весь алгоритм через композицію. Можна змінити поведінку без створення підкласу.
Q: Strategy проти Factory - у чому різниця?
A: Factory створює об'єкти. Strategy обирає, який алгоритм виконається в runtime. Вони часто з'являються разом: фабрика може створити потрібну стратегію на основі конфігурації або вхідних даних.
Q: Коли Strategy додає зайві накладні витрати?
A: Коли є лише 2 варіанти і немає перемикання в runtime. Для коду, де важлива продуктивність, зайва алокація об'єктів і непряма диспетчеризація методів додають вартість. У таких випадках передавай функцію напряму. Array.sort(comparatorFn) робить саме це без загортання в клас.
Q: Як рефакторити if/else ланцюжок до Strategy?
A: Витягни кожну гілку в клас з однаковою назвою методу, наприклад execute(). Замість умовного блоку використай контекст, який отримує стратегію через конструктор. Додавання нового варіанту означає новий клас, а не редагування існуючої логіки.
Q: Як Strategy працює у функціональному програмуванні?
A: Функції вищого порядку замінюють класи. const sorter = (strategy) => (items) => [...items].sort(strategy) - передаєш компаратор, отримуєш сортувальник. Та сама ідея, менше церемонії.
Q (senior): У мікросервісній архітектурі, як би ти управляв стратегіями, розподіленими між сервісами?
A: Використовуй патерн registry (реєстр) разом із service discovery. Контекст отримує потрібну стратегію через API-виклик (REST або gRPC). Додай circuit breakers для цього виклику. Якщо сервіс стратегій недоступний, повертайся до локального дефолту. Саме так системи feature flags та A/B-тестування управляють вибором стратегій у масштабі.
Приклади
Базовий: Взаємозамінні стратегії оплати
class PayPalStrategy {
pay(amount) { return `Paid $${amount} via PayPal`; }
}
class CreditCardStrategy {
pay(amount) { return `Paid $${amount} via Credit Card`; }
}
class CryptoStrategy {
pay(amount) { return `Paid $${amount} via Crypto`; }
}
class PaymentProcessor {
constructor(strategy) {
if (!strategy?.pay) throw new Error('Invalid strategy');
this.strategy = strategy;
}
setStrategy(strategy) { this.strategy = strategy; }
process(amount) { return this.strategy.pay(amount); }
}
const processor = new PaymentProcessor(new PayPalStrategy());
console.log(processor.process(50)); // "Paid $50 via PayPal"
processor.setStrategy(new CryptoStrategy());
console.log(processor.process(50)); // "Paid $50 via Crypto"Три методи оплати, жодного умовного оператора в PaymentProcessor. Четвертий метод - один новий клас. Більше нічого не змінюється.
Середній рівень: Express middleware з підключуваними стратегіями аутентифікації
class EmailStrategy {
async auth(req) {
const { email, password } = req.body;
// Симуляція запиту до БД
return email === 'user@example.com' && password === 'secret';
}
}
class OAuthStrategy {
async auth(req) {
const { token } = req.body;
// Симуляція валідації токена
return token === 'valid-oauth-token';
}
}
const authMiddleware = (strategy) => async (req, res, next) => {
const isValid = await strategy.auth(req);
if (isValid) return next();
res.status(401).send('Unauthorized');
};
app.post('/login', authMiddleware(new EmailStrategy()));
app.post('/oauth/callback', authMiddleware(new OAuthStrategy()));Саме так працює Passport.js у своїй основі. Middleware викликає auth() і або пропускає запит далі, або відхиляє його. Додати SAML, API key або magic link - значить додати клас, а не чіпати функцію middleware.
Просунутий рівень: React-хук із динамічним перемиканням стратегій сортування
const useSortableData = (items, initialConfig = null) => {
const [sortConfig, setSortConfig] = useState(initialConfig);
const comparators = {
name: (a, b) => a.name.localeCompare(b.name),
age: (a, b) => a.age - b.age,
// Вторинне сортування: score за спаданням, потім name за алфавітом
score: (a, b) => b.score - a.score || a.name.localeCompare(b.name),
};
const sortedItems = useMemo(() => {
if (!sortConfig?.key || !comparators[sortConfig.key]) return items;
return [...items].sort(comparators[sortConfig.key]); // spread запобігає мутації
}, [items, sortConfig]);
return { items: sortedItems, sortConfig, setSortConfig };
};
// У компоненті:
// <button onClick={() => setSortConfig({ key: 'score' })}>Сортувати за score</button>Два важливі моменти. Перший: [...items] перед sort - Array.sort() мутує вихідний масив, що порушує очікування незмінності в React. Без spread кожне сортування змінює джерело даних прямо на місці. Другий: компаратор score використовує складне сортування - за score у зворотному порядку, потім за name за алфавітом при рівності очок. Ось де Strategy себе виправдовує: ти називаєш логіку, тестуєш окремо і підмінюєш без жодних змін у компоненті.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.