Шесть шаблонов проектирования 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?
Длинные цепочки наследования связывают функции объекта таким образом, который не соответствует реальности, и подклассы в итоге унаследовывают методы, которые для них бессмысленны.
Решение
Вместо этого используйте композицию объектов: небольшие, повторно используемые функции поведения (миксины), которые можно добавлять к объекту по мере необходимости. Формируйте наборы функций на основе того, что объект делает, вместо того чтобы принуждать его к жесткой иерархии типов является.
// 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. Шаблон конвейера: последовательные асинхронные шаги
Проблема
Связывание нескольких асинхронных преобразований часто приводит к глубоко вложенному коду, который трудно понять и в котором смешиваются несвязанные между собой аспекты.
// 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));
Почему это работает в масштабе
- Простая перестановка: добавление, удаление или изменение порядка шагов — например, включение этапа генерации миниатюр — требует почти никаких усилий.
- Отладка шаг за шагом: вы можете вставить простую функцию логирования в любой момент конвейера, чтобы проверить, что поступает входом и что выходит.
Как избавиться от запутанного кода
Код становится запутанным не из-за отсутствия у разработчиков навыков. Он запутывается из-за того, что системы развиваются под давлением времени, и разработчики используют любой фрагмент кода, который быстрее всего решает текущую проблему.
Настоящий ключ к архитектуре с чистым кодом — это не применение тяжеловесных корпоративных фреймворков. Это использование небольших, тщательно продуманных структур там, где они действительно необходимы:
- Соответствуйте шаблону реальной проблеме: используйте стратегии, когда условные операции выходят из-под контроля, применяйте механизмы pub/sub, когда модули начинают вызывать друг друга бесконечно, и используйте адаптеры, когда требования сторонних поставщиков могут нарушить стабильность интерфейса.
- Давайте предпочтение простоте сложности: вам не нужны все шаблоны с самого начала. Позвольте фрагменту логики повториться дважды, прежде чем браться за его абстрагирование при третьем случае.
- Сохраняйте чистоту функций и четкость интерфейсов: предсказуемые входные и выходные данные значительно упрощают последующую рефакторинговую работу.
Чистый код — это не то, что пишется идеально с первого раза; это код, который будет легко изменять даже через шесть месяцев.
Связанные статьи
- Шаблоны проектирования в React: от классического ООП до современных хуков — В статье объясняется, как классические шаблоны программирования, такие как Singleton, Factory и Observer, применяются в React, а также шаблоны, специфичные для этой библиотеки, вроде HOC, хуков и составных компонентов.
- Понимание принципов SOLID на примерах практического кода — В этом руководстве подробно раскрыты все пять принципов SOLID с конкретными примерами кода, показывающими их применение в реальных проектах и приложениях на React.