Шість шаблонів проектування JavaScript для уникнення „спагеті-коду“
Пояснюється шість практичних шаблонів JavaScript — Strategy, Factory, Observer, Adapter, Composition та Pipeline — які замінюють заплутаний код на структуру, що легко підтримується.
Перетворення хаотичних скриптів на прогнозовані, керовані системи
Кожен, хто досить довго створює програмне забезпечення, знає той неприємний момент, коли ви знову відкриваєте проект через кілька місяців після його випуску та більше не можете зрозуміти, як усе взаємопов’язано.
Падіння у хаос рідко починається навмисно. Спочатку додається швидкий елемент керування інтерфейсом, потім функція отримання даних, потім обробка крайніх випадків, потім індикатори завантаження, а ще — виклик систем аналітики. Через кілька тижнів ваш колись охайний скрипт перетворюється на складну структуру з понад 800 рядків, наповнену вкладеними функціями-відповідачами, випадковими глобальними змінними та крихкими ланцюгами умов if/else.
Ось у чому суть „спагеті-коду“: бізнес-правила та логіка відображення інформації настільки переплітаються, що зміна однієї частини може призвести до пошкодження двох інших у різних місцях.
Створення JavaScript, який може масштабуватися, не означає необхідності використання важких абстракцій у стилі корпоративних рішень для кожної функції. Йдеться переважно про розділення обов’язків та використання кількох надійних шаблонів.
Нижче наведено шість перевірених шаблонів проектування та архітектури, які допомагають упорядкувати складний JavaScript та зберегти ваш кодовий базис під контролем у міру його зростання.
1. Шаблон стратегії: позбавлення від вкладених умовних операторів
Проблема
Кожного разу, коли логіка потребує розгалуження залежно від категорії користувача, способу оплати чи режиму обробки, багато розробників інстинктивно вдаються до використання послідовностей операторів if/else або об’ємних блоків switch.
// The Spaghetti Way
function calculateShipping(order) {
if (order.type === 'standard') {
return order.weight * 1.5;
} else if (order.type === 'express') {
return order.weight * 3.0 + 10;
} else if (order.type === 'overnight') {
return order.weight * 5.0 + 25;
} else if (order.type === 'international') {
return order.weight * 8.0 + 50;
} else {
throw new Error('Unknown shipping method');
}
}
Щоразу, коли ваша команда впроваджує новий рівень доставки, вам доводиться змінювати цю саму центральну функцію. Одна помилка чи несправний оператор тут призводить до збою у розрахунках доставки для всіх типів замовлень одночасно.
Рішення
Шаблон стратегії розділяє кожен алгоритм на окрему самостійну функцію та зберігає їх у спільному об’єкті пошуку.
// The Scalable Way
const shippingStrategies = {
standard: (order) => order.weight * 1.5,
express: (order) => order.weight * 3.0 + 10,
overnight: (order) => order.weight * 5.0 + 25,
international: (order) => order.weight * 8.0 + 50,
};
function calculateShipping(order) {
const strategy = shippingStrategies[order.type];
if (!strategy) {
throw new Error(`Unsupported shipping method: ${order.type}`);
}
return strategy(order);
}
Чому це ефективно у масштабі
- Принцип відкритості/закритості: додавання десятка нових варіантів доставки означає лише додавання нових записів до
shippingStrategies, без необхідності змін у самій функціїcalculateShipping. - Простіше тестування: кожну функцію стратегії можна експортувати, проаналізувати та протестувати повністю окремо.
2. Шаблони модуля та фабрики: контроль стану
Проблема
Глобальні змінні з нечітко визначеним діапазоном та спільні змінні об’єкти створюють складні для виявлення помилки. Як тільки кілька компонентів інтерфейсу можуть вільно читати та змінювати один і той самий стан, визначення того, який з них пошкодив значення, перетворюється на справжню розслідувальну роботу.
// The Spaghetti Way
let cart = [];
let total = 0;
function addItem(item) {
cart.push(item);
total += item.price;
}
function resetCart() {
cart = [];
total = 0;
}
Ніщо не заважає якомусь непов’язаному скрипту на сторінці встановити cart у значення null або оновити total, не торкаючись при цьому cart синхронно.
Рішення
Замикання всередині функцій-фабрик дозволяють зберігати стан у приватному режимі, відкриваючи лише ті операції, які дійсно потрібні іншому коду, та повністю приховуючи первинні змінні.
// The Scalable Way
function createCart() {
// Private variables protected inside the closure
let items = [];
return {
addItem(product) {
if (!product || typeof product.price !== 'number') {
throw new Error('Invalid product payload');
}
items.push({ ...product, id: crypto.randomUUID() });
},
removeItem(productId) {
items = items.filter((item) => item.id !== productId);
},
getItems() {
// Return a shallow copy so external mutations don't alter state
return [...items];
},
getTotal() {
return items.reduce((sum, item) => sum + item.price, 0);
},
clear() {
items = [];
}
};
}
const userCart = createCart();
userCart.addItem({ name: 'Mechanical Keyboard', price: 120 });
console.log(userCart.getTotal()); // 120
Чому це ефективно у масштабах
- Відсутні витоки змінних: код ззовні не може безпосередньо перезаписати
items— для цього потрібно використовувати перевірені публічні методи. - Безпечне створення кількох екземплярів: кожен виклик
createCart()повертає власний незалежний стан, без ризику того, що один екземпляр пошкодить інший.
3. Паттерн спостерігача (Pub/Sub): послаблення тісно пов’язаного коду
Проблема
Коли покупець натискає „Оформити замовлення“, одночасно мають відбутися кілька дій: кошик очищується, з’являється повідомлення про підтвердження, відправляється піксель відстеження, і сервер отримує сповіщення. Загортання всієї цієї логіки в одну функцію призводить до того, що вона перетворюється на некерований збірник усього можливого.
// The Spaghetti Way
async function handleCheckout(order) {
await api.submitOrder(order);
// UI logic mixed directly with tracking and data operations
document.querySelector('#cart-count').textContent = '0';
document.querySelector('#modal').classList.add('active');
analytics.trackPurchase(order);
notificationSystem.sendPush('Order confirmed');
}
Якщо скрипт відстеження генерує помилку або елемент DOM змінює свою назву, весь процес оформлення покупки ризикує зазнати невдачі.
Рішення
// The Scalable Way
class EventEmitter {
constructor() {
this.events = new Map();
}
subscribe(eventName, listener) {
if (!this.events.has(eventName)) {
this.events.set(eventName, new Set());
}
this.events.get(eventName).add(listener);
// Return an easy unsubscribe function
return () => this.events.get(eventName).delete(listener);
}
publish(eventName, data) {
const listeners = this.events.get(eventName);
if (listeners) {
listeners.forEach((listener) => {
try {
listener(data);
} catch (err) {
console.error(`Error executing listener for ${eventName}:`, err);
}
});
}
}
}
const appBus = new EventEmitter();
// Feature modules register their own behavior
appBus.subscribe('order:placed', (order) => {
analytics.trackPurchase(order);
});
appBus.subscribe('order:placed', () => {
document.querySelector('#cart-count').textContent = '0';
});
// The emitter stays minimal and decoupled
async function handleCheckout(order) {
await api.submitOrder(order);
appBus.publish('order:placed', order);
}
Чому це працює у масштабі
- Відсутність залежностей між частинами: функція оформлення покупки не знає, хто її використовує. Ви можете додати нові інструменти аналітики, сповіщення електронною поштою чи ефекти інтерфейсу, не торкаючись
handleCheckout. - Ізоляція помилок: збій у одному слухачі не призводить до зупинки функції, яка ініціювала подію.
4. Шаблон адаптера: ізоляція вашого коду від нестабільних залежностей
Проблема
Зовнішні сервіси, пакети npm та внутрішні кінцеві точки мають звичку змінювати свої контракти без попередження. Якщо п’ятнадцять окремих компонентів отримують дані користувача та кожен з них безпосередньо читає поля сирого відповіді, зміна назви однієї властивості — скажімо, з user_id на id — призводить до рефакторингу, який впливає на всю вашу кодову базу.
// The Spaghetti Way: scattered across multiple UI components
function renderProfile(rawApiResponse) {
// Directly tied to backend-specific naming conventions
const name = `${rawApiResponse.first_name} ${rawApiResponse.last_name}`;
const address = rawApiResponse.shipping_address_line_1;
const avatar = rawApiResponse.meta_info.profile_image_url;
}
Рішення
Введіть шар адаптера між зовнішнім джерелом даних та внутрішньою логікою вашого додатку. Перетворіть дані, якими надходить зовнішній пакет, у стабільну, передбачувану структуру ще до того, як вони потраплять до наступних етапів обробки.
// The Scalable Way
function userAdapter(externalUser) {
return {
id: externalUser.user_id || externalUser.id,
fullName: `${externalUser.first_name || ''} ${externalUser.last_name || ''}`.trim(),
address: externalUser.shipping_address_line_1 || externalUser.street || 'N/A',
avatar: externalUser.meta_info?.profile_image_url || '/assets/default-avatar.png',
};
}
// Your components only ever consume normalized models
async function getUserProfile(userId) {
const response = await fetch(`/api/v1/users/${userId}`);
const rawData = await response.json();
return userAdapter(rawData);
}
Чому це працює у масштабі
- Одне місце для корекції: якщо наступного тижня сервер змінить схему свого відповіді, вам доведеться внести зміни лише до
userAdapter, а не шукати сорок пошкоджених компонентів. - Простіші тестові аналоги: тести користувацького інтерфейсу потребують перевірки лише нормалізованої структури даних, а не постійно змінюваного зовнішнього формату.
5. Віддача переваги композиції перед успадкуванням: складання функцій як будівельних блоків
Проблема
Глибокі ієрархії класів часто не витримують своєї складності. Припустимо, ви починаєте з загального класу User та розділяєте його на AdminUser, ModeratorUser та GuestUser. Усе ламається в ту мить, коли з’являється потреба у GuestModerator — особі, яка має деякі повноваження модератора, але не всю їхню сукупність.
// The Spaghetti Way: Deep Inheritance
class BaseUser {
login() { /* ... */ }
}
class Admin extends BaseUser {
deleteContent() { /* ... */ }
manageBilling() { /* ... */ }
}
// What happens when you need a "BillingAgent" who cannot delete content?
Довгі ланцюги успадкування поєднують функціональні можливості у способи, які не відповідають реальності, і підкласи в кінцевому підсумку успадковують методи, які для них не мають сенсу.
Рішення
Замість цього використовуйте композицію об’єктів: невеликі, повторно використовувані функції поведінки (міксини), які можна додавати до об’єкта за потребою. Створюйте набори функціональних можливостей на основі того, що об’єкт робить, замість того щоб примушувати його підпорядковуватися жорсткій ієрархії типів is-a.
// The Scalable Way: Composable Behaviors
const canAuthenticate = (state) => ({
login: () => console.log(`${state.email} logged in`),
logout: () => console.log(`${state.email} logged out`),
});
const canModerateContent = () => ({
deletePost: (postId) => console.log(`Post ${postId} deleted`),
banUser: (userId) => console.log(`User ${userId} banned`),
});
const canManageBilling = () => ({
processInvoice: (amount) => console.log(`Invoice processed: ${amount}`),
});
// Build specialized actors on demand
function createSupportStaff(email) {
const state = { email };
return {
email,
...canAuthenticate(state),
...canModerateContent(),
};
}
function createSuperAdmin(email) {
const state = { email };
return {
email,
...canAuthenticate(state),
...canModerateContent(),
...canManageBilling(),
};
}
const moderator = createSupportStaff('support@example.com');
moderator.login();
moderator.deletePost(404);
// moderator.processInvoice is undefined - zero privilege leakage
Чому це ефективно у великих масштабах
- Немає ієрархії, якою потрібно керувати: поведінки поєднуються в реальному часі, без необхідності заздалегідь планувати дерево класів.
canAuthenticate однаково добре працює як у облікових записах клієнтів, так і в внутрішніх облікових записах співробітників чи у облікових записах автоматизованих ботів.6. Шаблон Pipeline: послідовні асинхронні кроки
Проблема
Послідовне застосування кількох асинхронних трансформацій часто призводить до глибоко вкладених, складних для розуміння кодів, які поєднують у собі нерелевантні елементи.
// The Spaghetti Way
async function handleImageUpload(file) {
if (file.size > 5000000) {
throw new Error('Too large');
}
const compressed = await compressImage(file);
const metadata = await extractExif(compressed);
const tagged = await tagCategories(compressed, metadata);
const uploadResult = await uploadToS3(tagged);
return uploadResult;
}
Це можна керувати за допомогою трьох кроків, але як тільки починають додаватися функції логування, телеметрії, повторних спроб та верифікації, весь код стає складним для розуміння.
Рішення
Використовуйте шаблон конвеєра: моделюйте кожну трансформацію як невелику функцію з однією метою та поєднуйте їх у ланцюжок, щоб весь процес читався зрозуміло від початку до кінця, зверху вниз або зліва направо.
// The Scalable Way
const pipeAsync = (...functions) => (initialValue) =>
functions.reduce(
(currentPromise, currentFunction) => currentPromise.then(currentFunction),
Promise.resolve(initialValue)
);
// Each step is an isolated, testable transformation
const validateSize = async (file) => {
if (file.size > 5 * 1024 * 1024) throw new Error('File exceeds 5MB limit');
return file;
};
const compress = async (file) => compressImage(file);
const attachWatermark = async (image) => applyWatermark(image);
const upload = async (finalImage) => uploadToCloud(finalImage);
// Create the pipeline
const processUserImage = pipeAsync(
validateSize,
compress,
attachWatermark,
upload
);
// Usage
processUserImage(rawFileInput)
.then((res) => console.log('Upload complete:', res))
.catch((err) => console.error('Pipeline failed:', err.message));
Чому це працює у масштабі
- Легке переупорядкування: додавання, видалення або зміна послідовності кроків — наприклад, вставка етапу створення мініатюр — не вимагає значних зусиль.
- Поетапна діагностика: ви можете додати просту функцію логування в будь-яку точку конвеєра, щоб перевірити, що надходить та що виходить.
Як позбутися заплутаного коду
Код не стає хаотичним через брак навичок у тих, хто його пише. Він стає хаотичним тому, що системи розвиваються під тиском часу, і розробники вдаються до будь-якого фрагмента коду, який найшвидше вирішує поточну проблему.
Справжній ключ до чистої архітектури — це не використання якоїсь важкої корпоративної фреймворк-системи. Це застосування невеликих, ретельно продуманих структур там, де вони є доцільними:
- Відповідайте шаблону реальному симптому: використовуйте Strategy, коли умовні оператори виходять з-під контролю, застосовуйте pub/sub, коли модулі починають взаємно викликатися у замкнутому циклі, та використовуйте adapter, коли умови, встановлені сторонніми постачальниками, загрожують дестабілізувати ваш інтерфейс користувача.
- Віддають перевагу простоті перед винахідливістю: вам не потрібні всі шаблони з самого початку. Дозвольте фрагменту логіки повторитися двічі, перш ніж починати його абстрагувати після третього використання.
- Зберігайте функції чистими та інтерфейси добре визначеними: передбачувані вхідні та вихідні дані значно полегшують подальшу оптимізацію коду.
Чистий код — це не щось, що можна написати ідеально один раз; це код, який залишається легким для змін навіть через шість місяців.
Пов’язана література
- Шаблони проектування React: від класичного OOP до сучасних хуків — пояснює, як класичні шаблони програмного забезпечення, такі як Singleton, Factory та Observer, застосовуються в React, а також шаблони, специфічні для React, як-от HOCs, хуки та складові компоненти.
- Розуміння принципів SOLID за допомогою практичних прикладів коду — цей посібник детально розглядає всі п’ять принципів SOLID із конкретними прикладами коду, показуючи, як вони застосовуються у реальних проектах та додатках на React.