JavaScript · Синтаксис · Просунутий рівень
Патерни проектування
Повторно використовувані рішення для поширених структурних та поведінкових проблем. Покриває Observer, Strategy, Factory та Decorator у чистому JavaScript.
Короткий вступ до теми та пояснення перед вправами (вправи нижче):
Factory, Decorator (HOF + pipe), Command з undo/redo
#Таблиця патернів, функціональні альтернативи, коли не використовувати
#Вправи:
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'); // (нічого)
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'); // (нічого)
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]
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);
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
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
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
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']
Функціональна композиція як декорування
#Напишіть функцію `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!
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' });