Пошук уроків, статей та іншого контенту
Дізнайтеся, як фабричні функції можуть створювати незалежні об'єкти або функції з власним замкненим станом. Розберіть практичні патерни створення конфігурованої поведінки без глобальних змінних.
Фабрична функція — це функція, яка створює та повертає новий об’єкт або іншу функцію.
На відміну від класу, для створення об’єкта не потрібно використовувати new. Фабрична функція зазвичай:
приймає конфігурацію;
створює внутрішній стан;
повертає об’єкт із потрібною поведінкою.
function createUser(name, role) {
return {
name,
role,
describe() {
return `${this.name} має роль ${this.role}`;
}
};
}
const admin = createUser("Олена", "admin");
const editor = createUser("Андрій", "editor");
console.log(admin.describe()); // Олена має роль admin
console.log(editor.describe()); // Андрій має роль editorКожен виклик createUser створює окремий об’єкт. Зміни в одному об’єкті не впливають на інший.
Замикання — це функція разом із лексичним оточенням, у якому її було створено.
Простіше кажучи, внутрішня функція може використовувати змінні зовнішньої функції навіть після завершення її виконання.
function createCounter() {
let count = 0;
return function increment() {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3Після завершення createCounter змінна count не зникає, тому що функція increment продовжує мати до неї доступ.
Змінна count не доступна напряму:
console.log(count); // ReferenceErrorЦе дає змогу створювати приватний стан без глобальних змінних.
Фабричні функції особливо корисні разом із замиканнями. Кожен виклик фабрики створює власне лексичне оточення.
function createCounter(initialValue = 0) {
let value = initialValue;
return {
increment() {
value += 1;
},
decrement() {
value -= 1;
},
getValue() {
return value;
}
};
}
const firstCounter = createCounter(10);
const secondCounter = createCounter(100);
firstCounter.increment();
firstCounter.increment();
secondCounter.decrement();
console.log(firstCounter.getValue()); // 12
console.log(secondCounter.getValue()); // 99У цьому прикладі:
firstCounter має власну змінну value;
secondCounter має іншу змінну value;
методи мають доступ до свого замкненого стану;
стан не можна змінити напряму ззовні.
Фабрика створює незалежні екземпляри навіть тоді, коли вони мають однакову структуру.
function createShoppingCart() {
const items = [];
return {
add(product) {
items.push(product);
},
remove(productId) {
const index = items.findIndex((item) => item.id === productId);
if (index !== -1) {
items.splice(index, 1);
}
},
getItems() {
return [...items];
},
getTotal() {
return items.reduce((total, item) => total + item.price, 0);
}
};
}
const firstCart = createShoppingCart();
const secondCart = createShoppingCart();
firstCart.add({ id: 1, name: "Книга", price: 300 });
firstCart.add({ id: 2, name: "Ручка", price: 50 });
secondCart.add({ id: 3, name: "Зошит", price: 80 });
console.log(firstCart.getItems());
// [
// { id: 1, name: "Книга", price: 300 },
// { id: 2, name: "Ручка", price: 50 }
// ]
console.log(firstCart.getTotal()); // 350
console.log(secondCart.getTotal()); // 80Метод getItems повертає копію масиву через [...items]. Це захищає внутрішній масив від прямої зміни:
const cart = createShoppingCart();
cart.add({ id: 1, name: "Книга", price: 300 });
const items = cart.getItems();
items.push({ id: 2, name: "Ручка", price: 50 });
console.log(cart.getItems());
// Дані кошика не змінилися зовнішнім pushЯкщо повернути сам items, зовнішній код зможе змінити приватний стан.
Фабрика може приймати конфігураційні параметри та створювати різні варіанти поведінки.
Наприклад, створимо форматувач цін:
function createPriceFormatter({
currency = "UAH",
locale = "uk-UA",
minimumFractionDigits = 2
} = {}) {
const formatter = new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits
});
return function formatPrice(value) {
return formatter.format(value);
};
}
const format hryvnias = createPriceFormatter({
currency: "UAH"
});У попередньому прикладі ім’я змінної містить помилку синтаксису через пробіл. Правильний варіант:
function createPriceFormatter({
currency = "UAH",
locale = "uk-UA",
minimumFractionDigits = 2
} = {}) {
const formatter = new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits
});
return function formatPrice(value) {
return formatter.format(value);
};
}
const formatHryvnias = createPriceFormatter({
currency: "UAH"
});
const formatDollars = createPriceFormatter({
currency: "USD",
locale: "en-US"
});
console.log(formatHryvnias(1250.5)); // приблизно 1 250,50 ₴
console.log(formatDollars(1250.5)); // приблизно $1,250.50Функції formatHryvnias і formatDollars використовують різні екземпляри Intl.NumberFormat, створені з різною конфігурацією.
Фабрика не обов’язково має повертати об’єкт. Вона може повертати функцію, яка запам’ятовує конфігурацію.
function createFilter(predicate) {
return function filterItems(items) {
return items.filter(predicate);
};
}
const products = [
{ name: "Книга", category: "books", available: true },
{ name: "Ручка", category: "stationery", available: false },
{ name: "Зошит", category: "stationery", available: true }
];
const filterAvailable = createFilter((product) => product.available);
const filterStationery = createFilter(
(product) => product.category === "stationery"
);
console.log(filterAvailable(products));
// Книга та Зошит
console.log(filterStationery(products));
// Ручка та ЗошитТут createFilter створює функцію з певним правилом фільтрації. Саме правило зберігається в замиканні.
function createLogger(prefix) {
return {
info(message) {
console.log(`[${prefix}] INFO: ${message}`);
},
error(message) {
console.error(`[${prefix}] ERROR: ${message}`);
}
};
}
const apiLogger = createLogger("API");
const databaseLogger = createLogger("DATABASE");
apiLogger.info("Запит успішно виконано");
// [API] INFO: Запит успішно виконано
databaseLogger.error("Не вдалося підключитися");
// [DATABASE] ERROR: Не вдалося підключитисяКожен логер зберігає власний prefix. Глобальна змінна для цього не потрібна.
Розглянемо повніший приклад фабрики, яка керує завданнями.
function createTaskManager(initialTasks = []) {
let nextId = 1;
const tasks = initialTasks.map((task) => ({
id: task.id ?? nextId++,
title: task.title,
completed: Boolean(task.completed)
}));
function findTask(taskId) {
return tasks.find((task) => task.id === taskId);
}
return {
add(title) {
const task = {
id: nextId++,
title,
completed: false
};
tasks.push(task);
return { ...task };
},
complete(taskId) {
const task = findTask(taskId);
if (!task) {
return false;
}
task.completed = true;
return true;
},
remove(taskId) {
const taskIndex = tasks.findIndex((task) => task.id === taskId);
if (taskIndex === -1) {
return false;
}
tasks.splice(taskIndex, 1);
return true;
},
getAll() {
return tasks.map((task) => ({ ...task }));
},
getCompleted() {
return tasks
.filter((task) => task.completed)
.map((task) => ({ ...task }));
}
};
}
const tasks = createTaskManager([
{ title: "Прочитати документацію" },
{ title: "Написати тести" }
]);
const newTask = tasks.add("Перевірити pull request");
tasks.complete(newTask.id);
console.log(tasks.getAll());
console.log(tasks.getCompleted());tasks — приватний масив, доступний лише методам фабрики;
nextId — приватний лічильник ідентифікаторів;
findTask — приватна допоміжна функція;
назовні повертаються лише дозволені операції;
методи повертають копії об’єктів, а не сам внутрішній стан.
Такий підхід зменшує кількість способів випадково пошкодити дані.
Користувач фабрики має працювати з публічним API, а не знати внутрішню реалізацію.
const manager = createTaskManager();
manager.add("Вивчити замикання");
manager.complete(1);
console.log(manager.getCompleted());Код, який використовує manager, не знає:
де саме зберігаються завдання;
чи використовується масив;
як генеруються ідентифікатори;
як реалізовано пошук завдання.
У майбутньому внутрішню реалізацію можна змінити, не змінюючи зовнішній інтерфейс.
Під час роботи із замиканнями важливо розуміти різницю між let і var.
let створює окреме значення для кожної ітераціїconst functions = [];
for (let index = 0; index < 3; index += 1) {
functions.push(() => index);
}
console.log(functions[0]()); // 0
console.log(functions[1]()); // 1
console.log(functions[2]()); // 2var використовує одну спільну зміннуconst functions = [];
for (var index = 0; index < 3; index += 1) {
functions.push(() => index);
}
console.log(functions[0]()); // 3
console.log(functions[1]()); // 3
console.log(functions[2]()); // 3У випадку з var усі функції посилаються на одну змінну index, значення якої після циклу дорівнює 3.
У сучасному JavaScript для циклів зазвичай слід використовувати let або const.
thisФабричні функції можуть повертати об’єкти з методами. Для методів, які використовують замкнені змінні, this часто взагалі не потрібен.
function createCounter() {
let value = 0;
return {
increment() {
value += 1;
},
getValue() {
return value;
}
};
}Це відрізняється від підходу, у якому стан зберігається як властивість об’єкта:
function createCounter() {
return {
value: 0,
increment() {
this.value += 1;
},
getValue() {
return this.value;
}
};
}Обидва варіанти можуть бути правильними, але вони мають різні властивості.
Замикання:
краще приховує стан;
не залежить від правильного значення this;
ускладнює пряме тестування внутрішніх змінних.
Стан у властивості об’єкта:
простіше оглядати та тестувати;
можна випадково змінити ззовні;
залежить від контексту виклику методів.
Фабричні функції добре підходять, коли:
потрібно створювати багато незалежних об’єктів;
кожен екземпляр має власний стан;
потрібно приховати внутрішні змінні;
поведінка залежить від конфігурації;
не потрібне успадкування через класи;
потрібно повертати простий і чіткий API;
необхідно створювати спеціалізовані функції.
Приклади використання:
менеджери стану;
кошики та списки;
форматувачі;
логери;
валідатори;
клієнти API з різною конфігурацією;
генератори обробників подій;
адаптери над зовнішніми сервісами.
Фабрика:
function createUser(name) {
return {
name,
greet() {
return `Привіт, я ${this.name}`;
}
};
}
const user = createUser("Марія");Клас:
class User {
constructor(name) {
this.name = name;
}
greet() {
return `Привіт, я ${this.name}`;
}
}
const user = new User("Марія");Клас може бути зручнішим, якщо:
потрібне успадкування;
необхідно перевіряти instanceof;
об’єкти мають спільний прототип;
команда використовує класичну об’єктно-орієнтовану модель.
Фабрика може бути зручнішою, якщо:
потрібна композиція замість успадкування;
важливі приватні змінні через замикання;
об’єкт має простий публічний API;
не потрібен оператор new.
Це не питання того, який підхід є «правильним» завжди. Вибір залежить від структури програми та вимог до об’єктів.
Помилковий варіант:
const items = [];
function createList() {
return {
add(item) {
items.push(item);
},
getItems() {
return [...items];
}
};
}
const firstList = createList();
const secondList = createList();
firstList.add("A");
console.log(secondList.getItems()); // ["A"]Масив items створено поза фабрикою, тому всі екземпляри використовують його спільно.
Правильний варіант:
function createList() {
const items = [];
return {
add(item) {
items.push(item);
},
getItems() {
return [...items];
}
};
}function createProfile() {
const profile = {
name: "Олена",
role: "developer"
};
return {
getProfile() {
return profile;
}
};
}
const profileManager = createProfile();
const profile = profileManager.getProfile();
profile.role = "admin";Тепер зовнішній код змінив внутрішній стан. Якщо це небажано, повертайте копію:
function createProfile() {
const profile = {
name: "Олена",
role: "developer"
};
return {
getProfile() {
return { ...profile };
}
};
}Для вкладених структур поверхневої копії може бути недостатньо. У такому разі потрібно продумати, які дані можна змінювати, або повертати спеціально підготовлене представлення.
thisМетод, що використовує this, може втратити контекст, якщо передати його окремо:
const counter = {
value: 0,
increment() {
this.value += 1;
}
};
const increment = counter.increment;
// increment(); — this не посилається на counterВаріанти розв’язання:
викликати метод через об’єкт;
використати стрілкову функцію для іншого дизайну;
прив’язати контекст через bind;
зберігати стан у замиканні та не залежати від this.
const boundIncrement = counter.increment.bind(counter);
boundIncrement();
console.log(counter.value); // 1Замикання корисне, але не кожну змінну потрібно робити приватною. Якщо стан має бути відкритим і його зручно перевіряти або серіалізувати, звичайна властивість об’єкта може бути кращим рішенням.
Потрібно знайти баланс між:
захистом даних;
простотою тестування;
зручністю налагодження;
вимогами публічного API.
Методи, оголошені всередині фабрики, створюються під час кожного виклику фабрики:
function createUser(name) {
return {
greet() {
return `Привіт, ${name}`;
}
};
}Для більшості прикладних задач це нормально. Але якщо створюються тисячі або мільйони об’єктів, варто профілювати програму та розглянути прототипи або класи. Оптимізацію слід робити на основі вимірювань, а не припущень.
Фабрична функція створює та повертає об’єкт або функцію.
Замикання дає функції доступ до змінних зовнішнього лексичного оточення.
Кожен виклик фабрики може створити незалежний приватний стан.
Замикання допомагають приховувати внутрішні дані без глобальних змінних.
Фабрики можуть приймати конфігурацію та створювати різні варіанти поведінки.
Публічний API краще повертати явно, не відкриваючи внутрішню реалізацію.
Для захисту стану повертайте копії масивів і об’єктів, якщо зовнішній код не повинен змінювати їх напряму.
Фабричні функції є альтернативою класам, особливо коли потрібні композиція, простий API та приватний стан.