Главная / Статьи / Шесть шаблонов проектирования JavaScript для устранения «спагетти-кода»

Шесть шаблонов проектирования JavaScript для устранения «спагетти-кода»

Объясняются шесть практических шаблонов JavaScript — Strategy, Factory, Observer, Adapter, Composition и Pipeline — которые заменяют запутанный код на структуру, удобную для обслуживания.

2146 слов

Превращение хаотичных скриптов в предсказуемые и удобные для обслуживания системы

Любой, кто долго занимается разработкой программного обеспечения, знает о том неприятном моменте, когда вы снова открываете проект спустя несколько месяцев после его выпуска и уже не можете понять, как все между собой связано.

Падение в хаос редко начинается намеренно. Сначала добавляется быстрый переключатель для интерфейса, затем механизм загрузки данных, потом обработка крайних случаев, индикаторы загрузки и запросы в аналитические системы. Через несколько недель ваш ранее аккуратный скрипт превращается в бесконечную сеть из более чем 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));
    

    Почему это работает в масштабе

    • Простая перестановка: добавление, удаление или изменение порядка шагов — например, включение этапа генерации миниатюр — требует почти никаких усилий.
    • Отладка шаг за шагом: вы можете вставить простую функцию логирования в любой момент конвейера, чтобы проверить, что поступает входом и что выходит.

    Как избавиться от запутанного кода

    Код становится запутанным не из-за отсутствия у разработчиков навыков. Он запутывается из-за того, что системы развиваются под давлением времени, и разработчики используют любой фрагмент кода, который быстрее всего решает текущую проблему.

    Настоящий ключ к архитектуре с чистым кодом — это не применение тяжеловесных корпоративных фреймворков. Это использование небольших, тщательно продуманных структур там, где они действительно необходимы:

    1. Соответствуйте шаблону реальной проблеме: используйте стратегии, когда условные операции выходят из-под контроля, применяйте механизмы pub/sub, когда модули начинают вызывать друг друга бесконечно, и используйте адаптеры, когда требования сторонних поставщиков могут нарушить стабильность интерфейса.
    2. Давайте предпочтение простоте сложности: вам не нужны все шаблоны с самого начала. Позвольте фрагменту логики повториться дважды, прежде чем браться за его абстрагирование при третьем случае.
    3. Сохраняйте чистоту функций и четкость интерфейсов: предсказуемые входные и выходные данные значительно упрощают последующую рефакторинговую работу.

    Чистый код — это не то, что пишется идеально с первого раза; это код, который будет легко изменять даже через шесть месяцев.

    Связанные статьи

  • Cursor, Claude Code и Codex: как выбрать инструмент для программирования с ИИ для JS — В этом сравнении рассматривается, как Cursor, Claude Code и Codex подходят к различным рабочим процессам в JavaScript, от программирования в редакторе до задач автономных агентов.
  • Десять распространённых шаблонов JavaScript и компромиссы, связанные с каждым из них — Клозулы безопасности, карты поиска, объекты результатов, AbortController, allSettled, фабрики и небольшие кэш-хранилища: когда каждый шаблон оказывается полезным, а когда приводит к проблемам.