JavaScript · Синтаксис · Просунутий рівень

Патерни проектування

10 завдань

Повторно використовувані рішення для поширених структурних та поведінкових проблем. Покриває Observer, Strategy, Factory та Decorator у чистому JavaScript.

Навіщо патерни, Observer з попередженням про витік, Strategy

#
**Навіщо патерни проектування** Патерни проектування — іменовані рішення повторюваних задач дизайну. Це не код для копіювання — це словник. Коли колега каже 'це має бути Observer' — обидва одразу розуміють структуру. В JavaScript багато класичних OOP-патернів мають простіші функціональні еквіваленти — знання обох дозволяє вибрати що читабельніше. **Патерн Observer — підписка/оповіщення** Observer роз'єднує виробників подій від споживачів. Основа DOM-подій, Node.js EventEmitter та бібліотек управління станом: ```js class EventEmitter { constructor() { this._listeners = {}; } on(event, fn) { (this._listeners[event] ??= []).push(fn); } off(event, fn) { this._listeners[event] = (this._listeners[event] || []).filter(l => l !== fn); } emit(event, ...args) { (this._listeners[event] || []).forEach(fn => fn(...args)); } once(event, fn) { const wrap = (...a) => { fn(...a); this.off(event, wrap); }; this.on(event, wrap); } } ``` **Увага: витік пам'яті!** Слухачі що реєструються через `.on()` тримають посилання на callback. Якщо прикріплюєте слухачів до довгоживучого emitter з короткоживучого компонента — *обов'язково* викликайте `.off()` при знищенні: ```js // БАГ: handler ніколи не видаляється class Widget { mount() { bus.on('data', this.handleData); } // destroy() відсутній — витік пам'яті! } // ПРАВИЛЬНО: class Widget { mount() { bus.on('data', this.handleData); } destroy() { bus.off('data', this.handleData); } } ``` **Патерн Strategy — заміна алгоритмів під час виконання** ```js class Sorter { constructor(strategy) { this.strategy = strategy; } sort(arr) { return this.strategy([...arr]); } } const asc = arr => arr.sort((a, b) => a - b); const desc = arr => arr.sort((a, b) => b - a); const s = new Sorter(asc); s.sort([3, 1, 2]); // [1, 2, 3] ``` В JavaScript Strategy часто просто 'передати функцію як аргумент' — клас не потрібен.

Factory, Decorator (HOF + pipe), Command з undo/redo

#
**Патерн Factory — приховати деталі створення** ```js function createUser(role) { const base = { role, createdAt: new Date() }; if (role === 'admin') return { ...base, permissions: ['read', 'write', 'delete'] }; if (role === 'viewer') return { ...base, permissions: ['read'] }; throw new Error(`Unknown role: ${role}`); } ``` Factory корисна коли: логіка створення складна, потрібно повертати різні підкласи залежно від вхідних даних, або потрібно кешувати екземпляри. **Патерн Decorator — розширити через обгортку (функціональний стиль)** ```js // Декоратор таймінгу const withTiming = fn => (...args) => { const t = performance.now(); const result = fn(...args); console.log(`${fn.name} took ${(performance.now() - t).toFixed(2)}ms`); return result; }; // Декоратор мемоізації const memoize = fn => { const cache = new Map(); return (...args) => { const key = JSON.stringify(args); if (!cache.has(key)) cache.set(key, fn(...args)); return cache.get(key); }; }; // Compose декораторів: const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x); const timedMemoFib = pipe(memoize, withTiming)(fib); ``` **Патерн Command — інкапсулювати дії для undo/redo** ```js class TextEditor { constructor() { this.text = ''; this.history = []; } execute(command) { command.execute(this); this.history.push(command); } undo() { this.history.pop()?.undo(this); } } const insertCmd = text => ({ execute: editor => { editor.text += text; }, undo: editor => { editor.text = editor.text.slice(0, -text.length); }, }); const editor = new TextEditor(); editor.execute(insertCmd('Hello')); editor.execute(insertCmd(' world')); console.log(editor.text); // 'Hello world' editor.undo(); console.log(editor.text); // 'Hello' ```

Таблиця патернів, функціональні альтернативи, коли не використовувати

#
**Порівняння патернів** | Патерн | Яку проблему вирішує | JS-ідіома | |---|---|---| | Observer | Роз'єднати виробників подій від споживачів | `EventEmitter`, `addEventListener` | | Strategy | Заміна алгоритму під час виконання | Передати функцію як аргумент | | Factory | Приховати складність створення об'єктів | Функція що повертає об'єкт | | Decorator | Розширити без зміни оригіналу | Функція вищого порядку | | Command | Інкапсулювати дії; undo/черга | Об'єкт з `execute`/`undo` | | Singleton | Один спільний екземпляр | `const inst = new X()` на рівні модуля | **Функціональні альтернативи — часто простіше в JS** ```js // Strategy — просто передати функцію: function process(data, transform) { return transform(data); } process(data, x => x * 2); // Decorator — compose з pipe: const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x); const sanitize = pipe(trim, lowercase, removeSpecialChars); // Singleton — сам модуль є singleton: // config.js export const config = { debug: false, version: '1.0' }; // Всі хто імпортує config — отримують один і той самий об'єкт ``` **Коли НЕ використовувати патерни** - Не використовуйте Observer коли функція викликається в одному місці - Не використовуйте Factory коли `new MyClass(arg)` вже достатньо читабельний - Не використовуйте Singleton для безстанових утиліт — просто експортуйте функції - Патерни вирішують проблеми *комунікації* між компонентами, не обчислення **Розпізнавання патернів** | Ви бачите... | Патерн | |---|---| | `.addEventListener` / `.on()` / `.subscribe()` | Observer | | Функція як параметр конфігурації | Strategy | | `createX()` замість `new X()` | Factory | | Функція що обгортає функцію (`withX(fn)`) | Decorator | | Пара `execute()` + `undo()` | Command | | Кешований екземпляр на рівні модуля | Singleton |
01

Observer: клас EventEmitter

#

Реалізуйте клас `EventEmitter` з трьома методами: `on(event, listener)` — зареєструвати слухача; `off(event, listener)` — видалити слухача; `emit(event, ...args)` — викликати всіх слухачів події з аргументами.

class EventEmitter {
  constructor() {
    this._listeners = {};
  }

  on(event, listener) {
    // зареєструйте слухача
  }

  off(event, listener) {
    // видаліть слухача
  }

  emit(event, ...args) {
    // викличте всіх слухачів
  }
}

const emitter = new EventEmitter();
const greet = name => console.log(`Hello, ${name}!`);
emitter.on('greet', greet);
emitter.emit('greet', 'Alice');  // Hello, Alice!
emitter.off('greet', greet);
emitter.emit('greet', 'Carol');  // (нічого)
Рішення
class EventEmitter {
  constructor() {
    this._listeners = {};
  }

  on(event, listener) {
    if (!this._listeners[event]) this._listeners[event] = [];
    this._listeners[event].push(listener);
  }

  off(event, listener) {
    if (!this._listeners[event]) return;
    this._listeners[event] = this._listeners[event].filter(l => l !== listener);
  }

  emit(event, ...args) {
    (this._listeners[event] || []).forEach(l => l(...args));
  }
}

const emitter = new EventEmitter();
const greet = name => console.log(`Hello, ${name}!`);
emitter.on('greet', greet);
emitter.emit('greet', 'Alice');  // Hello, Alice!
emitter.off('greet', greet);
emitter.emit('greet', 'Carol');  // (нічого)
02

Observer: once() — спрацювати лише раз

#

Додайте метод `once(event, listener)` до `EventEmitter`. Він має зареєструвати слухача, який автоматично видаляється після першого спрацювання.

class EventEmitter {
  constructor() { this._listeners = {}; }

  on(event, fn) {
    if (!this._listeners[event]) this._listeners[event] = [];
    this._listeners[event].push(fn);
  }

  off(event, fn) {
    if (!this._listeners[event]) return;
    this._listeners[event] = this._listeners[event].filter(l => l !== fn);
  }

  emit(event, ...args) {
    (this._listeners[event] || []).forEach(l => l(...args));
  }

  once(event, fn) {
    // спрацьовує fn один раз, потім видаляється автоматично
  }
}

const ee = new EventEmitter();
ee.once('connect', () => console.log('connected!'));
ee.emit('connect');  // connected!
ee.emit('connect');  // (нічого)
Рішення
class EventEmitter {
  constructor() { this._listeners = {}; }

  on(event, fn) {
    if (!this._listeners[event]) this._listeners[event] = [];
    this._listeners[event].push(fn);
  }

  off(event, fn) {
    if (!this._listeners[event]) return;
    this._listeners[event] = this._listeners[event].filter(l => l !== fn);
  }

  emit(event, ...args) {
    (this._listeners[event] || []).forEach(l => l(...args));
  }

  once(event, fn) {
    const wrapper = (...args) => {
      fn(...args);
      this.off(event, wrapper);
    };
    this.on(event, wrapper);
  }
}

const ee = new EventEmitter();
ee.once('connect', () => console.log('connected!'));
ee.emit('connect');  // connected!
ee.emit('connect');  // (нічого)
03

Strategy: заміна алгоритмів сортування

#

Створіть клас `Sorter` з методом `sort(arr)`. Алгоритм сортування вводиться через конструктор як функція-стратегія. Підтримайте як мінімум дві стратегії: bubble sort і вбудоване сортування. Продемонструйте заміну стратегій під час виконання.

class Sorter {
  constructor(strategy) {
    this.strategy = strategy;
  }

  sort(arr) {
    return this.strategy([...arr]);
  }
}

const bubbleSort = arr => {
  // реалізуйте bubble sort
};

const nativeSort = arr => arr.sort((a, b) => a - b);

const sorter = new Sorter(bubbleSort);
console.log(sorter.sort([3, 1, 4, 1, 5])); // [1, 1, 3, 4, 5]

sorter.strategy = nativeSort;
console.log(sorter.sort([9, 2, 7]));       // [2, 7, 9]
Рішення
class Sorter {
  constructor(strategy) {
    this.strategy = strategy;
  }

  sort(arr) {
    return this.strategy([...arr]);
  }
}

const bubbleSort = arr => {
  for (let i = 0; i < arr.length; i++) {
    for (let j = 0; j < arr.length - i - 1; j++) {
      if (arr[j] > arr[j + 1]) [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
    }
  }
  return arr;
};

const nativeSort = arr => arr.sort((a, b) => a - b);

const sorter = new Sorter(bubbleSort);
console.log(sorter.sort([3, 1, 4, 1, 5])); // [1, 1, 3, 4, 5]

sorter.strategy = nativeSort;
console.log(sorter.sort([9, 2, 7]));        // [2, 7, 9]
04

Strategy: способи оплати

#

Створіть клас `Checkout` з методом `pay(amount)`. Три стратегії оплати: `creditCard`, `paypal` та `crypto` — кожна є функцією, що приймає суму та виводить повідомлення. Стратегія встановлюється через `checkout.setStrategy(fn)`.

const creditCard = amount => console.log(`Charged $${amount} to credit card`);
const paypal = amount => console.log(`Sent $${amount} via PayPal`);
const crypto = amount => console.log(`Transferred $${amount} in BTC`);

class Checkout {
  setStrategy(strategy) {
    this.strategy = strategy;
  }

  pay(amount) {
    // викличте this.strategy
  }
}

const checkout = new Checkout();
checkout.setStrategy(creditCard);
checkout.pay(50);   // Charged $50 to credit card
Рішення
const creditCard = amount => console.log(`Charged $${amount} to credit card`);
const paypal = amount => console.log(`Sent $${amount} via PayPal`);
const crypto = amount => console.log(`Transferred $${amount} in BTC`);

class Checkout {
  setStrategy(strategy) {
    this.strategy = strategy;
  }

  pay(amount) {
    if (!this.strategy) throw new Error('No payment strategy set');
    this.strategy(amount);
  }
}

const checkout = new Checkout();
checkout.setStrategy(creditCard);
checkout.pay(50);
checkout.setStrategy(paypal);
checkout.pay(30);
checkout.setStrategy(crypto);
checkout.pay(100);
05

Factory: створення фігур

#

Напишіть фабричну функцію `createShape(type, ...dims)`, що створює об'єкти фігур. Підтримувані типи: `'circle'` (радіус), `'rect'` (ширина, висота). Кожен об'єкт фігури повинен мати метод `area()`. Кидайте помилку для невідомих типів.

function createShape(type, ...dims) {
  // поверніть об'єкт фігури залежно від типу
}

const c = createShape('circle', 5);
console.log(c.area().toFixed(2)); // 78.54

const r = createShape('rect', 4, 3);
console.log(r.area());             // 12
Рішення
function createShape(type, ...dims) {
  switch (type) {
    case 'circle': {
      const [r] = dims;
      return { type: 'circle', area: () => Math.PI * r * r };
    }
    case 'rect': {
      const [w, h] = dims;
      return { type: 'rect', area: () => w * h };
    }
    default:
      throw new Error(`Unknown shape: ${type}`);
  }
}

const c = createShape('circle', 5);
console.log(c.area().toFixed(2)); // 78.54

const r = createShape('rect', 4, 3);
console.log(r.area());             // 12
06

Decorator: додати логування до будь-якої функції

#

Напишіть декоратор `withLogging(fn)`, що обгортає будь-яку функцію. Перед викликом оригіналу виведіть `'Calling <fn.name> with <args>'`. Після повернення виведіть `'<fn.name> returned <result>'`. Поверніть нову функцію з тим самим підписом.

function withLogging(fn) {
  return function(...args) {
    // виведіть до виклику
    // викличте fn
    // виведіть після
    // поверніть результат
  };
}

function add(a, b) { return a + b; }

const loggedAdd = withLogging(add);
loggedAdd(2, 3);
// Calling add with 2,3
// add returned 5
Рішення
function withLogging(fn) {
  return function(...args) {
    console.log(`Calling ${fn.name} with ${args}`);
    const result = fn(...args);
    console.log(`${fn.name} returned ${result}`);
    return result;
  };
}

function add(a, b) { return a + b; }

const loggedAdd = withLogging(add);
loggedAdd(2, 3);
// Calling add with 2,3
// add returned 5
07

Decorator: memoize

#

Напишіть декоратор `memoize(fn)`, що кешує результати за аргументами. При повторних викликах з тими самими аргументами повертайте кешоване значення без повторного запуску. Використовуйте `Map` для кешу. Протестуйте з повільним `factorial`, що виводить коли запускається.

function memoize(fn) {
  const cache = new Map();
  return function(...args) {
    // перевірте кеш, обчисліть якщо промах, збережіть і поверніть
  };
}

function factorial(n) {
  console.log(`Computing factorial(${n})`);
  return n <= 1 ? 1 : n * factorial(n - 1);
}

const memoFactorial = memoize(factorial);
console.log(memoFactorial(5));  // виводить Computing, повертає 120
console.log(memoFactorial(5));  // без Computing — закешовано
Рішення
function memoize(fn) {
  const cache = new Map();
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

function factorial(n) {
  console.log(`Computing factorial(${n})`);
  return n <= 1 ? 1 : n * factorial(n - 1);
}

const memoFactorial = memoize(factorial);
console.log(memoFactorial(5));
console.log(memoFactorial(5));  // без Computing
08

Singleton: екземпляр на рівні модуля

#

Реалізуйте Singleton `Logger` з використанням патерну ES-модуля. Модуль експортує один екземпляр `logger`. Всі імпортери повинні ділити той самий екземпляр — повідомлення накопичуються між викликами. Покажіть, що виклики `logger.log()` з двох 'файлів' додаються до того ж логу.

// logger.js
class Logger {
  constructor() { this.messages = []; }
  log(msg) {
    this.messages.push(msg);
    console.log(`[LOG] ${msg}`);
  }
  getAll() { return this.messages; }
}

export const logger = new Logger();

// fileA.js
import { logger } from './logger.js';
logger.log('Event from A');

// fileB.js
import { logger } from './logger.js';
logger.log('Event from B');
console.log(logger.getAll()); // ['Event from A', 'Event from B']
Рішення
// logger.js — один екземпляр на весь застосунок
class Logger {
  constructor() { this.messages = []; }
  log(msg) { this.messages.push(msg); console.log(`[LOG] ${msg}`); }
  getAll() { return this.messages; }
}

export const logger = new Logger();

// Симуляція двох файлів в одному скрипті:
const sharedLogger = { messages: [], log(m) { this.messages.push(m); }, getAll() { return this.messages; } };

sharedLogger.log('Event from A');
sharedLogger.log('Event from B');
console.log(sharedLogger.getAll()); // ['Event from A', 'Event from B']
09

Функціональна композиція як декорування

#

Напишіть функцію `compose(...fns)`, що повертає нову функцію, яка застосовує задані функції справа наліво. Потім побудуйте текстовий конвеєр: `trim` -> `lowercase` -> `exclaim` (додати `!`). Композиція трьох дає функцію, що застосовує всі три послідовно.

const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);

const trim = s => s.trim();
const lowercase = s => s.toLowerCase();
const exclaim = s => s + '!';

const process = compose(exclaim, lowercase, trim);
console.log(process('  Hello World  ')); // hello world!
Рішення
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);

const trim = s => s.trim();
const lowercase = s => s.toLowerCase();
const exclaim = s => s + '!';

const process = compose(exclaim, lowercase, trim);
console.log(process('  Hello World  ')); // hello world!

// pipe (зліва направо):
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);
const process2 = pipe(trim, lowercase, exclaim);
console.log(process2('  Hello World  ')); // hello world!
10

Observer: типізована шина подій з payload

#

Створіть `EventBus`, де події несуть типізовані payload. `subscribe(type, handler)` — зареєструвати обробник; `publish(type, payload)` — викликати всі обробники типу з payload; `unsubscribe(type, handler)` — видалити обробник. Продемонструйте з подією 'user:login', що несе `{ userId, name }`.

class EventBus {
  constructor() { this._handlers = {}; }

  subscribe(type, handler) {
    // зареєструйте обробник
  }

  unsubscribe(type, handler) {
    // видаліть обробник
  }

  publish(type, payload) {
    // викличте всі обробники
  }
}

const bus = new EventBus();
const onLogin = ({ userId, name }) => console.log(`User ${name} (${userId}) logged in`);
bus.subscribe('user:login', onLogin);
bus.publish('user:login', { userId: 42, name: 'Alice' });
bus.unsubscribe('user:login', onLogin);
bus.publish('user:login', { userId: 99, name: 'Bob' }); // (нічого)
Рішення
class EventBus {
  constructor() { this._handlers = {}; }

  subscribe(type, handler) {
    if (!this._handlers[type]) this._handlers[type] = [];
    this._handlers[type].push(handler);
  }

  unsubscribe(type, handler) {
    if (!this._handlers[type]) return;
    this._handlers[type] = this._handlers[type].filter(h => h !== handler);
  }

  publish(type, payload) {
    (this._handlers[type] || []).forEach(h => h(payload));
  }
}

const bus = new EventBus();
const onLogin = ({ userId, name }) => console.log(`User ${name} (${userId}) logged in`);
bus.subscribe('user:login', onLogin);
bus.publish('user:login', { userId: 42, name: 'Alice' });
bus.unsubscribe('user:login', onLogin);
bus.publish('user:login', { userId: 99, name: 'Bob' });