Пошук уроків, статей та іншого контенту
Порівняєте композицію прототипів, наслідування та міксини й оберете підхід для повторного використання поведінки.
У JavaScript повторне використання коду можна організувати кількома способами:
наслідуванням — об’єкт отримує поведінку через ланцюжок прототипів;
композицією — об’єкт збирається з незалежних частин поведінки;
міксинами — методи одних об’єктів або функцій додаються до іншого об’єкта;
делегуванням — об’єкт передає виконання окремій службі або іншому об’єкту.
Ці підходи не є взаємовиключними. Наприклад, клас може наслідувати базову модель, але окремі можливості отримати через композицію.
Головне питання:
Чи описуємо ми відношення «є різновидом», чи лише хочемо повторно використати певну поведінку?
Якщо Admin справді є різновидом User, наслідування може бути доречним. Якщо об’єкт лише «може логувати», «може кешувати» або «може бути вибраним», краще розглянути композицію.
Наслідування створює зв’язок між типами:
class User {
constructor(name) {
this.name = name;
}
describe() {
return `Користувач: ${this.name}`;
}
}
class Admin extends User {
describe() {
return `Адміністратор: ${this.name}`;
}
deleteUser(user) {
return `${this.name} видалив ${user.name}`;
}
}
const admin = new Admin("Олена");
console.log(admin.describe());
console.log(admin instanceof Admin); // true
console.log(admin instanceof User); // trueМетод describe шукається приблизно так:
у самому admin;
у Admin.prototype;
у User.prototype;
у Object.prototype.
Наслідування зручне, коли:
існує стабільна ієрархія типів;
дочірній тип повністю відповідає контракту батьківського;
методи природно працюють із тим самим станом;
поліморфізм справді потрібен.
Однак ієрархія швидко стає жорсткою. Якщо клас одночасно повинен бути Serializable, Cacheable, Loggable і Selectable, JavaScript не дозволяє успадкувати його від кількох класів.
Композиція не створює ієрархію. Вона збирає об’єкт із менших частин:
function createLogger(prefix) {
return {
log(message) {
console.log(`[${prefix}] ${message}`);
},
};
}
function createCache() {
const values = new Map();
return {
set(key, value) {
values.set(key, value);
},
get(key) {
return values.get(key);
},
has(key) {
return values.has(key);
},
};
}
function createReportService() {
const logger = createLogger("ReportService");
const cache = createCache();
return {
getReport(id) {
if (cache.has(id)) {
logger.log(`Звіт ${id} взято з кешу`);
return cache.get(id);
}
const report = {
id,
title: `Звіт №${id}`,
};
cache.set(id, report);
logger.log(`Звіт ${id} завантажено`);
return report;
},
};
}
const reports = createReportService();
console.log(reports.getReport(42));
console.log(reports.getReport(42));Тут ReportService не є різновидом ні логера, ні кешу. Він використовує їх. Такий зв’язок часто називають відношенням «має», а не «є».
Переваги композиції:
залежності видно без аналізу ланцюжка прототипів;
компоненти можна тестувати окремо;
легше замінювати реалізації;
стан можна ізолювати в замиканнях;
немає проблеми множинного наслідування.
У JavaScript композиція прототипів може означати створення об’єкта, який делегує пошук методів іншому об’єкту через Object.create.
const canMove = {
move(distance) {
this.position += distance;
return this.position;
},
};
const canStop = {
stop() {
this.position = 0;
},
};
const robotPrototype = Object.assign(
Object.create(canMove),
canStop,
{
describe() {
return `${this.name}: позиція ${this.position}`;
},
},
);
const robot = Object.create(robotPrototype);
robot.name = "R2";
robot.position = 10;
console.log(robot.move(5)); // 15
console.log(robot.describe()); // R2: позиція 15
robot.stop();
console.log(robot.position); // 0Ланцюжок у цьому прикладі має вигляд:
robot
↓
robotPrototype
↓
canMove
↓
Object.prototypeАле важливо розуміти особливість Object.assign:
const target = {};
Object.assign(target, source);Методи копіюються як власні властивості target. Вони не додаються до ланцюжка прототипів target автоматично.
У попередньому прикладі:
canMove став прототипом robotPrototype;
canStop був скопійований у robotPrototype;
describe також є власною властивістю robotPrototype.
Це різні механізми: делегування через прототип і копіювання властивостей.
Іноді зручніше зберігати поведінки окремо та викликати їх явно:
const movement = {
move(entity, distance) {
entity.position += distance;
},
};
const battery = {
charge(entity, amount) {
entity.energy = Math.min(100, entity.energy + amount);
},
};
const drone = {
position: 0,
energy: 20,
move(distance) {
if (this.energy < distance) {
throw new Error("Недостатньо енергії");
}
movement.move(this, distance);
this.energy -= distance;
},
charge(amount) {
battery.charge(this, amount);
},
};
drone.move(5);
drone.charge(30);
console.log(drone.position); // 5
console.log(drone.energy); // 45Перевага такого стилю — залежність від поведінки видно без магії this. Недолік — методи мають приймати об’єкт явно, тому API може бути більш багатослівним.
Міксина — це набір методів, який можна додати до різних об’єктів або прототипів, не створюючи окремої ієрархії класів.
Найпростіший варіант — об’єкт із методами:
const timestamped = {
touch() {
this.updatedAt = new Date();
},
getUpdatedAt() {
return this.updatedAt;
},
};
const searchable = {
matches(query) {
return this.title.toLowerCase().includes(query.toLowerCase());
},
};
const document = {
title: "Архітектура застосунку",
};
Object.assign(document, timestamped, searchable);
document.touch();
console.log(document.matches("архітектура")); // true
console.log(document.getUpdatedAt() instanceof Date); // trueМіксина не зобов’язана мати стан. Вона може лише додавати методи, які очікують певний контракт об’єкта. У цьому прикладі searchable очікує, що об’єкт має властивість title.
Безпечнішим варіантом є функція, яка отримує базовий клас і повертає розширений клас:
class User {
constructor(name) {
this.name = name;
}
}
const WithTimestamps = (Base) =>
class extends Base {
constructor(...args) {
super(...args);
this.createdAt = new Date();
this.updatedAt = this.createdAt;
}
touch() {
this.updatedAt = new Date();
}
};
const WithPermissions = (Base) =>
class extends Base {
constructor(...args) {
super(...args);
this.permissions = new Set();
}
grant(permission) {
this.permissions.add(permission);
}
can(permission) {
return this.permissions.has(permission);
}
};
class Admin extends WithPermissions(WithTimestamps(User)) {
isAdmin() {
return true;
}
}
const admin = new Admin("Ірина");
admin.grant("users:delete");
admin.touch();
console.log(admin.name); // Ірина
console.log(admin.can("users:delete")); // true
console.log(admin.isAdmin()); // trueФункціональна міксина:
приймає базовий клас;
створює клас-нащадок;
додає поведінку;
повертає новий клас.
Це все ще використовує наслідування технічно, але ієрархія формується з незалежних можливостей, а не з великого дерева предметних типів.
Міксину можна застосувати до прототипу класу:
const identifiable = {
getIdentifier() {
return this.id;
},
};
class Product {
constructor(id, title) {
this.id = id;
this.title = title;
}
}
Object.assign(Product.prototype, identifiable);
const product = new Product(7, "Клавіатура");
console.log(product.getIdentifier()); // 7Цей підхід має практичні наслідки:
метод спільний для всіх екземплярів;
метод стає доступним через прототип;
усі екземпляри отримують однакову реалізацію;
конфлікт імен може непомітно перезаписати вже наявний метод.
Для бібліотечної або великої кодової бази краще явно контролювати додавання:
function applyMixin(target, mixin) {
for (const key of Reflect.ownKeys(mixin)) {
if (key in target) {
throw new Error(`Конфлікт властивості: ${String(key)}`);
}
const descriptor = Object.getOwnPropertyDescriptor(mixin, key);
Object.defineProperty(target, key, descriptor);
}
return target;
}
const auditable = {
audit() {
return `${this.name} перевірено`;
},
};
class Account {
constructor(name) {
this.name = name;
}
}
applyMixin(Account.prototype, auditable);
console.log(new Account("Основний рахунок").audit());Reflect.ownKeys враховує не лише звичайні рядкові ключі, а й Symbol. Використання дескрипторів зберігає характеристики властивості: enumerable, writable, configurable, а також гетери й сетери.
Якщо дві міксини містять метод з однаковою назвою, порядок застосування визначає результат:
const first = {
describe() {
return "first";
},
};
const second = {
describe() {
return "second";
},
};
const object = {};
Object.assign(object, first, second);
console.log(object.describe()); // secondОстання властивість перезаписує попередню. Це може бути навмисною стратегією пріоритетів, але без документації така поведінка створює приховані помилки.
Можливі рішення:
використовувати унікальні імена;
перевіряти конфлікти під час застосування міксини;
об’єднувати поведінку вручну;
зберігати функції під Symbol;
не копіювати методи, а передавати залежності явно.
Symbol допомагає уникати конфліктів з публічними властивостями:
const serialize = Symbol("serialize");
const serializable = {
[serialize]() {
return JSON.stringify(this);
},
};
const record = {
id: 1,
title: "Приклад",
};
Object.assign(record, serializable);
console.log(record[serialize]());Такий метод усе одно можна викликати, якщо має бути доступ до символу. Тому Symbol не є механізмом безпеки. Він лише зменшує ризик випадкового збігу імен і не додає властивість до звичайного переліку рядкових ключів.
Методи міксини часто працюють із властивостями this. Це створює неявний контракт.
const subscribable = {
subscribe(listener) {
this.listeners.add(listener);
},
notify(value) {
for (const listener of this.listeners) {
listener(value);
}
},
};
const channel = {
listeners: new Set(),
};
Object.assign(channel, subscribable);
channel.subscribe((value) => {
console.log(`Отримано: ${value}`);
});
channel.notify("повідомлення");Контракт цієї міксини:
this.listeners має бути Set;
він має бути створений до виклику subscribe;
слухачі мають бути функціями.
Якщо ці вимоги неочевидні, помилки виникатимуть під час виконання. Варіант із фабрикою може ізолювати стан:
function createSubscribable() {
const listeners = new Set();
return {
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
notify(value) {
for (const listener of listeners) {
listener(value);
}
},
};
}
const channel = createSubscribable();
const unsubscribe = channel.subscribe((value) => {
console.log(value);
});
channel.notify("перше повідомлення");
unsubscribe();
channel.notify("друге повідомлення");Замикання робить listeners недоступним для прямого пошкодження ззовні. Це не міксина в класичному сенсі, але часто є кращою композицією поведінки.
this у композиціїМетоди міксини зазвичай покладаються на динамічний this:
const counterBehavior = {
increment() {
this.value += 1;
},
};
const counter = {
value: 0,
};
Object.assign(counter, counterBehavior);
const increment = counter.increment;
// increment(); // TypeError або неправильний this у strict mode
counter.increment();
console.log(counter.value); // 1Вилучення методу в окрему змінну втрачає об’єкт-власник. Якщо метод передається як callback, його потрібно прив’язати:
const button = {
label: "Зберегти",
handle() {
console.log(this.label);
},
};
const handler = button.handle.bind(button);
handler(); // ЗберегтиУ функціональній композиції залежність можна зробити явною:
function increment(state) {
return {
...state,
value: state.value + 1,
};
}
const state = { value: 0 };
const nextState = increment(state);
console.log(nextState.value); // 1Такий підхід ближчий до чистих функцій: поведінка не залежить від контексту виклику.
superМіксини, що додаються через Object.assign, не отримують автоматичного ланцюжка композиції для super.
const baseBehavior = {
save() {
return "base";
},
};
const extraBehavior = {
save() {
return `${super.save()} + extra`;
},
};
const object = Object.create(baseBehavior);
Object.assign(object, extraBehavior);
// У цьому випадку super у extraBehavior.save()
// посилається на [[HomeObject]] об'єкта extraBehavior,
// а не на прототип object.Метод, який визначено як метод об’єкта, має внутрішній [[HomeObject]]. Якщо його просто скопіювати в інший об’єкт, super не починає наслідувати нову структуру так, як можна було б очікувати.
Для ланцюжків міксинів на класах функціональний підхід зазвичай передбачуваніший:
class Base {
save() {
return "base";
}
}
const WithLogging = (BaseClass) =>
class extends BaseClass {
save() {
const result = super.save();
return `${result} + logged`;
}
};
const WithValidation = (BaseClass) =>
class extends BaseClass {
save() {
const result = super.save();
return `${result} + validated`;
}
};
class Model extends WithValidation(WithLogging(Base)) {}
console.log(new Model().save());
// base + logged + validatedПорядок обгортання важливий: WithValidation(WithLogging(Base)) формує інший ланцюжок, ніж WithLogging(WithValidation(Base)).
Поведінку можна додавати не копіюванням методів, а обгортанням функції:
function withLogging(operation, logger = console) {
return (...args) => {
logger.log("Початок операції", args);
try {
const result = operation(...args);
logger.log("Операцію завершено");
return result;
} catch (error) {
logger.error("Операція завершилася помилкою");
throw error;
}
};
}
function withRetry(operation, attempts) {
return (...args) => {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return operation(...args);
} catch (error) {
lastError = error;
}
}
throw lastError;
};
}
let calls = 0;
const unstableOperation = () => {
calls += 1;
if (calls < 3) {
throw new Error("Тимчасова помилка");
}
return "успіх";
};
const operation = withLogging(
withRetry(unstableOperation, 3),
);
console.log(operation());Це приклад композиції функцій:
логування(
повторні спроби(
операція
)
)Такий стиль добре підходить для:
логування;
повторних спроб;
перевірки доступу;
кешування;
вимірювання часу;
транзакційних обгорток.
Перевага — немає потреби змінювати прототип або клас. Недолік — потрібно уважно проєктувати сигнатури, обробку помилок і порядок обгорток.
між типами існує чітке відношення «є різновидом»;
дочірній тип може виконувати контракт батьківського;
потрібен поліморфізм через спільний базовий API;
ієрархія мала та стабільна.
Не варто використовувати наслідування лише для доступу до кількох методів.
об’єкт використовує інші компоненти;
залежності потрібно замінювати в тестах;
поведінки мають різний життєвий цикл;
стан краще ізолювати;
предметна модель не утворює ієрархії.
набір методів має бути повторно використаний у багатьох несумісних типах;
контракт міксини чітко визначений;
конфлікти імен контролюються;
додавання поведінки справді зручніше за явне делегування.
Міксина добре підходить для можливості на кшталт Serializable або Timestamped, але може бути невдалим рішенням для складного сервісу зі значною кількістю залежностей.
потрібно додати поведінку до конкретної операції;
поведінка має бути незалежною від класу;
важливі тестованість і явний порядок виконання;
йдеться про наскрізні можливості: логування, кешування, повтори.
Глибокий ланцюжок класів ускладнює пошук джерела поведінки та зміну базових контрактів.
Краще обмежувати глибину ієрархій, а незалежні можливості переносити в композицію.
Object.assignObject.assign(target, mixinA, mixinB, mixinC);Такий код може мовчки перезаписати методи. Для важливих компонентів перевіряйте конфлікти або застосовуйте міксини по одній із валідацією.
thisМетод міксини може очікувати this.cache, this.listeners або this.options, хоча з його коду це не завжди очевидно.
Документуйте передумови або передавайте залежності явно.
const badPrototype = {
items: [],
};
const first = Object.create(badPrototype);
const second = Object.create(badPrototype);
first.items.push("дані");
console.log(second.items); // ["дані"]Масив належить прототипу й спільний для обох об’єктів. Стан екземпляра потрібно створювати окремо:
function createCollection() {
return {
items: [],
add(item) {
this.items.push(item);
},
};
}
const first = createCollection();
const second = createCollection();
first.add("дані");
console.log(first.items); // ["дані"]
console.log(second.items); // []Якщо міксина довільно змінює десятки властивостей об’єкта, її важко повторно використовувати. Краще мати вузький контракт і мінімальну кількість припущень.
Відношення «потрібен метод» не дорівнює відношенню «є підтипом». Якщо поліморфізм не потрібен, композиція зазвичай краще відображає залежність.
Перед вибором підходу поставте такі запитання:
Чи є між сутностями стабільне відношення «є різновидом»?
Чи повинен об’єкт відповідати контракту базового типу?
Чи має поведінка власний стан?
Чи потрібно застосовувати цю поведінку до несумісних типів?
Чи можливі конфлікти імен?
Чи потрібно замінювати залежність у тестах?
Чи важливий порядок виконання кількох поведінок?
Чи можна описати поведінку чистою функцією або обгорткою?
Загальна евристика:
для ієрархії типів — наслідування;
для співпраці компонентів — композиція;
для невеликих повторно використовуваних можливостей — міксини;
для наскрізних операцій — функціональні обгортки.
Наслідування формує ланцюжок прототипів і моделює зв’язок між типами.
Композиція збирає поведінку з незалежних компонентів і зазвичай зменшує зв’язаність.
Міксини додають методи до об’єктів або класів, але потребують контролю конфліктів і явного контракту.
Object.assign копіює властивості, а Object.create створює делегування через прототип.
Методи з this залежать від контексту виклику, тому міксини можуть мати приховані передумови.
Замикання допомагають ізолювати стан композиційних компонентів.
Функціональні міксини та обгортки дають змогу додавати поведінку без великої предметної ієрархії.
Наслідування варто обирати для поліморфізму, а не лише для повторного використання кількох методів.