Skip to main content

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

Патерн Strategy - поведінковий патерн проектування, який визначає сімейство алгоритмів, загортає кожен в окремий об'єкт і дає контексту змогу перемикати їх під час виконання.

Теорія

TL;DR

  • Аналогія: зміна двигуна на піт-стопі - та сама машина (контекст), інший двигун (стратегія) для траси, траси або позадоріжжя
  • Головна ідея: замість if/else ланцюжків - делегування поведінки ін'єктованим об'єктам
  • Використовуй коли: 3+ алгоритми для однієї операції або вибір відбувається в runtime
  • Пропусти коли: 2 варіанти. Тернарний оператор або дві функції справляться простіше.

Швидкий приклад

javascript
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 не потрібно.

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

Відсутність валідації стратегії в конструкторі

javascript
const p = new Processor(); // стратегію не передано p.process(100); // TypeError: Cannot read properties of undefined

Перевіряй одразу в конструкторі:

javascript
constructor(strategy) { if (!strategy?.pay) throw new Error('Strategy must implement pay()'); this.strategy = strategy; }

Спільний стан між контекстами

javascript
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] - стан витік між обома процесорами

Стратегії мають бути без стану. Якщо потрібен стан, створюй новий екземпляр для кожного контексту. Це часте джерело непомітних багів у стратегіях логування та аналітики.

Надмірне ускладнення для двох варіантів

javascript
// Непотрібно: два класи для бінарного вибору class USDStrategy { pay() { /* ... */ } } class EURStrategy { pay() { /* ... */ } }

Два варіанти? Пиши тернарний оператор. Патерн виправданий тільки коли варіантів 3+ і вони реально перемикаються в runtime.

Відсутність перевірки на null після динамічної заміни

javascript
p.strategy = null; p.process(100); // крах

Якщо стратегії замінюються динамічно, захищайся від null:

javascript
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-тестування управляють вибором стратегій у масштабі.

Приклади

Базовий: Взаємозамінні стратегії оплати

javascript
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 з підключуваними стратегіями аутентифікації

javascript
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-хук із динамічним перемиканням стратегій сортування

javascript
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 себе виправдовує: ти називаєш логіку, тестуєш окремо і підмінюєш без жодних змін у компоненті.

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

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

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

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