Пошук уроків, статей та іншого контенту
Застосуєте композицію для складання складних об’єктів із незалежних компонентів замість глибокого успадкування.
Композиція об’єктів — це підхід, за якого складний об’єкт утворюється з кількох незалежних компонентів, кожен із яких додає одну поведінку або частину стану.
Замість ієрархії:
Entity
└── Task
└── TimedTask
└── RepeatingTimedTaskможна створити об’єкт із набором можливостей:
Task = Identity + Events + Timestamps + Status + LifecycleКожен компонент відповідає лише за власну частину логіки. Це зменшує зв’язаність коду та полегшує повторне використання поведінки.
Успадкування моделює відношення «є різновидом»:
class AdminUser extends User {}Композиція моделює відношення «має можливість»:
const user = compose(
withIdentity,
withAuthentication,
withPermissions
)();Об’єкт user не обов’язково належить до складної ієрархії класів. Він просто отримує потрібні можливості.
поведінку можна комбінувати в різних наборах;
компоненти можна тестувати окремо;
залежності між частинами системи стають явними;
немає проблем із глибокою ієрархією класів;
простіше замінювати одну реалізацію іншою;
компоненти можуть зберігати приватний стан у замиканнях.
Композиція не означає, що класи заборонені. Класи корисні, коли потрібно моделювати стабільний тип із чітким контрактом. Проте для складання незалежних можливостей композиція часто є гнучкішою.
Компонент композиції зазвичай є функцією, яка отримує спільний стан і повертає методи об’єкта.
Найпростіший приклад:
function withIdentity(state) {
state.id = crypto.randomUUID();
return {
getId() {
return state.id;
}
};
}Тут компонент:
додає дані до state;
повертає методи, які працюють із цими даними;
не залежить від конкретного типу об’єкта.
Для складання компонентів потрібен компоновщик:
function compose(...components) {
return function createObject(initialState = {}) {
const state = initialState;
const api = {};
for (const component of components) {
Object.assign(api, component(state, api));
}
return api;
};
}state містить внутрішній стан, а api — публічні методи. Компоненти отримують посилання на один і той самий api, тому можуть взаємодіяти через його публічний контракт.
Створимо об’єкт завдання, який матиме такі можливості:
унікальний ідентифікатор;
події;
часові мітки;
статус;
життєвий цикл.
Жоден компонент не представляє «клас завдання» повністю. Кожен відповідає тільки за одну поведінку.
function compose(...components) {
return function createObject(initialState = {}) {
const state = initialState;
const api = {};
for (const component of components) {
Object.assign(api, component(state, api));
}
return api;
};
}
function withIdentity(state) {
state.id = crypto.randomUUID();
return {
getId() {
return state.id;
}
};
}
function withEvents() {
const listeners = new Map();
function on(eventName, listener) {
if (!listeners.has(eventName)) {
listeners.set(eventName, new Set());
}
listeners.get(eventName).add(listener);
// Повертаємо функцію для відписки від події
return () => {
listeners.get(eventName)?.delete(listener);
};
}
function emit(eventName, payload) {
const eventListeners = listeners.get(eventName);
if (!eventListeners) {
return;
}
// Копія захищає від змін набору слухачів під час ітерації
for (const listener of [...eventListeners]) {
listener(payload);
}
}
return {
on,
emit
};
}
function withTimestamps(state, api) {
const now = new Date();
state.createdAt = now;
state.updatedAt = now;
function touch() {
state.updatedAt = new Date();
}
return {
getCreatedAt() {
return state.createdAt;
},
getUpdatedAt() {
return state.updatedAt;
},
touch() {
touch();
api.emit?.("updated", {
updatedAt: state.updatedAt
});
}
};
}
function withStatus(state, api) {
const allowedTransitions = {
draft: new Set(["in-progress"]),
"in-progress": new Set(["completed", "cancelled"]),
completed: new Set(),
cancelled: new Set()
};
state.status = "draft";
function setStatus(nextStatus) {
const currentStatus = state.status;
const transitions = allowedTransitions[currentStatus];
if (!transitions?.has(nextStatus)) {
throw new Error(
`Неможливо змінити статус із "${currentStatus}" на "${nextStatus}"`
);
}
state.status = nextStatus;
api.touch?.();
api.emit?.("status:changed", {
previous: currentStatus,
current: nextStatus
});
}
return {
getStatus() {
return state.status;
},
setStatus
};
}
function withLifecycle(state, api) {
return {
start() {
if (api.getStatus() !== "draft") {
throw new Error("Запустити можна лише завдання у статусі draft");
}
api.setStatus("in-progress");
api.emit("started", {
id: api.getId()
});
},
complete() {
if (api.getStatus() !== "in-progress") {
throw new Error(
"Завершити можна лише завдання у статусі in-progress"
);
}
api.setStatus("completed");
api.emit("completed", {
id: api.getId()
});
},
cancel() {
const status = api.getStatus();
if (status !== "draft" && status !== "in-progress") {
throw new Error("Скасувати це завдання вже неможливо");
}
api.setStatus("cancelled");
api.emit("cancelled", {
id: api.getId()
});
}
};
}
const createTask = compose(
withIdentity,
withEvents,
withTimestamps,
withStatus,
withLifecycle
);
const task = createTask();
task.on("started", ({ id }) => {
console.log(`Завдання ${id} запущено`);
});
task.on("status:changed", ({ previous, current }) => {
console.log(`Статус: ${previous} → ${current}`);
});
task.on("updated", ({ updatedAt }) => {
console.log(`Оновлено: ${updatedAt.toISOString()}`);
});
console.log("Ідентифікатор:", task.getId());
console.log("Початковий статус:", task.getStatus());
task.start();
task.complete();
console.log("Фінальний статус:", task.getStatus());Приклад можна запустити в Node.js, оскільки crypto.randomUUID() доступний у сучасних версіях Node.js.
compose створює спільні state і api, після чого послідовно викликає всі компоненти.
withIdentity відповідає лише за ідентифікатор:
function withIdentity(state) {
state.id = crypto.randomUUID();
return {
getId() {
return state.id;
}
};
}withEvents ізолює колекцію слухачів у замиканні. Код ззовні не може напряму змінити listeners.
withStatus керує допустимими переходами між статусами. Він використовує api.touch() та api.emit(), але не знає, як саме ці методи реалізовані.
withLifecycle реалізує операції start, complete і cancel. Він працює через публічний API, а не звертається до внутрішньої реалізації інших компонентів.
Замикання особливо корисні, коли компонент повинен мати приватний стан.
Наприклад, лічильник можна додати до будь-якого об’єкта:
function withCounter() {
let value = 0;
return {
increment() {
value += 1;
return value;
},
decrement() {
value -= 1;
return value;
},
getValue() {
return value;
}
};
}
const createCountable = compose(withCounter);
const counter = createCountable();
counter.increment();
counter.increment();
console.log(counter.getValue()); // 2Змінна value не є властивістю публічного об’єкта. Користувач може змінити її лише через методи компонента.
Це часто безпечніше, ніж відкриті властивості:
// Будь-який код може зламати інваріант
object.value = -999;Компоненти можна зберігати як окремі об’єкти й об’єднувати через Object.assign або spread-синтаксис:
const canLog = {
log(message) {
console.log(`[LOG] ${message}`);
}
};
const canValidate = {
validate(value) {
return typeof value === "string" && value.length > 0;
}
};
const service = {
name: "UserService",
...canLog,
...canValidate
};
service.log("Сервіс створено");
console.log(service.validate("Alice")); // trueТакий підхід простий, але має обмеження:
можливі конфлікти назв;
стан часто стає відкритим;
складніше виразити залежності між компонентами;
методи можуть випадково залежати від this.
Функціональні компоненти із замиканнями краще ізолюють внутрішню реалізацію.
Компонент може використовувати інший компонент через публічний API.
Наприклад, компонент логування може вимагати метод getId:
function withAuditLog(state, api) {
if (typeof api.getId !== "function") {
throw new Error("withAuditLog потребує компонент withIdentity");
}
const entries = [];
function record(action) {
entries.push({
id: api.getId(),
action,
at: new Date()
});
}
return {
record,
getAuditLog() {
return [...entries];
}
};
}Порядок компонентів у цьому випадку важливий:
const createAuditedObject = compose(
withIdentity,
withAuditLog
);Якщо поміняти компоненти місцями, withAuditLog не знайде getId.
Краще, коли залежності:
невеликі;
явно перевіряються;
спрямовані на стабільний API;
не залежать від внутрішніх властивостей іншого компонента.
Під час композиції два компоненти можуть повернути метод з однаковою назвою:
function firstComponent() {
return {
save() {
console.log("Перше збереження");
}
};
}
function secondComponent() {
return {
save() {
console.log("Друге збереження");
}
};
}
const createObject = compose(firstComponent, secondComponent);
const object = createObject();
object.save(); // "Друге збереження"Object.assign перезаписує властивість останнім значенням. Така поведінка може бути неочевидною.
Можливі рішення:
function withCache() {
return {
getCachedValue() {
// ...
},
setCachedValue() {
// ...
}
};
}function withPersistence() {
return {
persistence: {
save() {
console.log("Збереження");
},
remove() {
console.log("Видалення");
}
}
};
}function composeStrict(...components) {
return function createObject(initialState = {}) {
const state = initialState;
const api = {};
for (const component of components) {
const part = component(state, api);
for (const key of Object.keys(part)) {
if (key in api) {
throw new Error(`Конфлікт API: "${key}"`);
}
api[key] = part[key];
}
}
return api;
};
}Такий варіант вимагає явного вирішення конфлікту замість тихого перезаписування.
Композиція не зобов’язує використовувати мутабельний стан. Компоненти можуть повертати нові об’єкти замість зміни спільного state.
Наприклад:
function addDisplayName(user) {
return {
...user,
getDisplayName() {
return `${user.firstName} ${user.lastName}`;
}
};
}
const user = addDisplayName({
firstName: "Olena",
lastName: "Koval"
});
console.log(user.getDisplayName()); // Olena KovalДля простих об’єктів такий підхід зручний. Проте для складного стану часте копіювання всіх даних може бути дорожчим або ускладнити взаємодію компонентів.
Вибір між мутабельним і незмінним станом залежить від задачі:
замикання та локальна мутація зручні для інкапсульованих сервісів;
незмінні об’єкти зручні для передбачуваних трансформацій даних;
у великих системах важливо не змішувати обидва підходи без чітких правил.
Композиція також може бути використана неправильно. Якщо об’єкт складається з десятків компонентів, а кожен компонент залежить від п’яти інших, система стає складною для розуміння.
Ознаки надмірної композиції:
для створення об’єкта потрібен довгий список компонентів;
порядок компонентів важко визначити;
компоненти мають багато прихованих залежностей;
публічний API постійно змінюється;
незрозуміло, який компонент відповідає за конкретну поведінку.
У такій ситуації варто:
об’єднати тісно пов’язані компоненти;
винести залежності в явні параметри;
створити окремий фасад;
використати клас, якщо потрібен стабільний життєвий цикл об’єкта;
обмежити публічний API.
Композиція — це інструмент, а не вимога застосовувати функції для кожної частини програми.
Незалежні компоненти легко тестувати ізольовано.
function withStatusForTest(state, api) {
state.status = "draft";
return {
getStatus() {
return state.status;
},
setStatus(status) {
state.status = status;
api.emit?.("status:changed", status);
}
};
}
const state = {};
const api = {
emit(eventName, payload) {
console.log(eventName, payload);
}
};
Object.assign(api, withStatusForTest(state, api));
console.assert(api.getStatus() === "draft");
api.setStatus("in-progress");
console.assert(api.getStatus() === "in-progress");Тест не потребує створення повного об’єкта завдання, підключення сховища чи налаштування всіх подій.
Для складніших компонентів можна передавати залежності явно:
function createNotifier(sendMessage) {
return {
notify(message) {
return sendMessage(message);
}
};
}
const notifier = createNotifier((message) => `Відправлено: ${message}`);
console.log(notifier.notify("Готово"));Це робить компонент придатним для тестування без реальної мережі, бази даних або файлової системи.
Компонент, який одночасно створює ідентифікатор, зберігає дані, змінює статус і відправляє повідомлення, важко повторно використати.
Краще розділити його на кілька компонентів із вузькими обов’язками.
Погано:
function withLogging(state, api) {
return {
log() {
api.database.write(state.id);
}
};
}Компонент не перевіряє, що database існує, і залежить від внутрішньої структури іншого API.
Краще передати залежність явно або перевірити контракт під час створення:
function withLogging(database) {
if (!database || typeof database.write !== "function") {
throw new TypeError("Потрібна база даних із методом write");
}
return function loggingComponent(state) {
return {
log() {
database.write(state.id);
}
};
};
}Якщо компонент залежить від іншого, це має бути зрозуміло з коду або перевірятися під час виконання. Прихована вимога «цей компонент потрібно додати третім» створює крихку систему.
thisМетоди, отримані через композицію, можуть бути передані як callback:
setTimeout(task.start, 1000);Якщо start використовує this, контекст може бути втрачений. Замикання або стрілкові функції зменшують цю проблему.
Якщо компоненти мають однакові назви методів, останній компонент може непомітно змінити поведінку об’єкта. Використовуйте перевірку конфліктів або домовленість про імена.
Spread-синтаксис створює лише поверхневу копію. Вкладені об’єкти та масиви залишаються спільними:
const first = {
settings: {
enabled: true
}
};
const second = {
...first
};
second.settings.enabled = false;
console.log(first.settings.enabled); // falseЯкщо потрібна ізоляція вкладених даних, її потрібно реалізувати окремо або не змінювати вкладені структури.
Під час створення компонентів композиції корисно дотримуватися таких правил:
один компонент — одна відповідальність;
публічний API має бути малим і зрозумілим;
залежності передавайте явно;
не покладайтеся на внутрішні властивості інших компонентів;
перевіряйте конфлікти назв;
ізолюйте приватний стан у замиканнях;
документуйте порядок компонентів, якщо він важливий;
тестуйте компоненти окремо;
не використовуйте композицію лише заради уникнення класів;
обирайте модель стану відповідно до вимог задачі.
Композиція об’єктів дозволяє будувати складну поведінку з незалежних компонентів.
Ключові ідеї:
складний об’єкт можна скласти з невеликих можливостей;
компоненти можуть додавати стан і методи;
замикання допомагають приховувати внутрішній стан;
компоненти мають взаємодіяти через публічний API;
залежності потрібно робити явними;
конфлікти методів слід виявляти або запобігати їм;
композиція зменшує залежність від глибоких ієрархій успадкування;
надмірна кількість компонентів також може ускладнити систему.
Добре спроєктована композиція дає об’єкти, поведінку яких можна вибирати, комбінувати та повторно використовувати без зміни великої ієрархії класів.