⚡ JavaScript

Паттерны проектирования в JavaScript (Node.js): подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

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

Паттерны проектирования (Design Patterns) — это такие же проверенные «архитектурные чертежи» для программистов. Это не готовый код, а шаблон решения типовой проблемы, которая возникает снова и снова при создании программ.

Паттерн — это не библиотека, которую можно скачать. Это инструкция: «Если у тебя проблема X, сделай Y, и вот почему это сработает».

Зачем они нужны? Представь, что ты пишешь игру. Тебе нужно, чтобы в ней был только один главный герой (нельзя создать двух «Игроков»). Или тебе нужно, чтобы разные части программы узнавали о событиях (например, о том, что монстр умер), не мешая друг другу. Паттерны дают готовые, красивые и понятные решения для таких ситуаций.

В мире JavaScript (особенно в Node.js) паттерны помогают сделать код:

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

Теория: как это работает изнутри

В программировании паттерны делятся на три большие группы. Представь, что это три разных набора инструментов:

Группа Что делает? Пример из жизни
Порождающие (Creational) Отвечают за создание объектов. Помогают сделать этот процесс гибким и контролируемым. Как фабрика по производству машин: ты не собираешь двигатель руками, а просто говоришь «дай мне седан».
Структурные (Structural) Определяют, как классы и объекты собираются в большие структуры. Как конструктор LEGO: отдельные кирпичики (объекты) соединяются друг с другом, чтобы получился замок.
Поведенческие (Behavioral) Управляют взаимодействием и общением между объектами. Как диспетчер в аэропорту: он не даёт самолётам столкнуться и координирует их действия.

Как это работает «под капотом»? Паттерн — это всегда набор правил. Например, паттерн «Одиночка» (Singleton) говорит: «У класса должен быть только один экземпляр, и ты должен дать к нему глобальную точку доступа». В коде это реализуется через проверку: если объект уже создан — верни его, если нет — создай новый. Всё просто, но гениально.

Важно понимать: паттерны не привязаны к конкретному языку. Ты можешь реализовать «Наблюдателя» и на JavaScript, и на Python, и на C++. Но синтаксис будет разный. JavaScript (особенно в Node.js) очень гибкий: функции — это объекты, объекты можно создавать на лету. Это делает реализацию некоторых паттернов невероятно элегантной.

Синтаксис в JavaScript (Node.js): все формы и варианты

В JavaScript нет ключевого слова implements или abstract, как в Java. Но это не проблема. Мы используем возможности самого языка: объекты, функции, классы (появились в ES6), замыкания и прототипы.

Рассмотрим три самых популярных паттерна, которые ты будешь встречать в каждом втором проекте на Node.js.

1. Одиночка (Singleton)

Гарантирует, что у класса есть только один экземпляр. В Node.js это часто используется для подключения к базе данных (чтобы не создавать 100500 соединений).

// Вариант 1: Через класс (классический)
class Database {
    constructor() {
        // Если экземпляр уже существует, возвращаем его
        if (Database.instance) {
            return Database.instance;
        }
        // Иначе создаём новый
        this.connection = 'Соединение с БД установлено';
        Database.instance = this; // Сохраняем ссылку
    }

    query(sql) {
        console.log(`Выполняю запрос: ${sql}`);
    }
}

// Проверка
const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2); // true! Это один и тот же объект

// Вариант 2: Через модуль (самый популярный в Node.js)
// В Node.js модули кешируются. Если ты экспортируешь объект, 
// он будет создан один раз при первом require.
class Logger {
    log(message) {
        console.log(`[Лог]: ${message}`);
    }
}
// Создаём экземпляр прямо здесь и экспортируем его
module.exports = new Logger(); 
// Теперь в любом файле, где ты напишешь const logger = require('./logger');
// ты получишь один и тот же объект.

2. Фабрика (Factory)

Позволяет создавать объекты, не указывая конкретный класс. Вместо new Car() ты говоришь factory.create('car').

// Фабрика для создания разных типов уведомлений
class EmailNotification {
    send(message) {
        console.log(`Отправляю email: ${message}`);
    }
}

class SMSNotification {
    send(message) {
        console.log(`Отправляю SMS: ${message}`);
    }
}

class PushNotification {
    send(message) {
        console.log(`Отправляю Push: ${message}`);
    }
}

// Сама фабрика
class NotificationFactory {
    create(type) {
        switch (type) {
            case 'email':
                return new EmailNotification();
            case 'sms':
                return new SMSNotification();
            case 'push':
                return new PushNotification();
            default:
                throw new Error('Неизвестный тип уведомления');
        }
    }
}

// Использование
const factory = new NotificationFactory();
const notifier = factory.create('sms');
notifier.send('Привет!'); // Отправляю SMS: Привет!

3. Наблюдатель (Observer)

Один объект (издатель) уведомляет множество других (подписчиков) о событиях. Это основа событийной модели Node.js (EventEmitter).

// Встроенный модуль Node.js
const EventEmitter = require('events');

// Создаём издателя
class NewsAgency extends EventEmitter {
    publishNews(news) {
        console.log(`Агентство публикует: ${news}`);
        // Генерируем событие 'news'
        this.emit('news', news);
    }
}

// Создаём подписчиков
const agency = new NewsAgency();

// Подписываемся на событие 'news'
agency.on('news', (news) => {
    console.log(`Подписчик 1 получил: ${news}`);
});

agency.on('news', (news) => {
    console.log(`Подписчик 2 получил: ${news.toUpperCase()}`);
});

// Публикуем новость
agency.publishNews('JavaScript — лучший язык!');
// Вывод:
// Агентство публикует: JavaScript — лучший язык!
// Подписчик 1 получил: JavaScript — лучший язык!
// Подписчик 2 получил: JAVASCRIPT — ЛУЧШИЙ ЯЗЫК!

Примеры кода — от простого к сложному

Теперь давай посмотрим на 7 живых примеров, которые ты сможешь запустить у себя на Node.js.

Пример 1: Простейший «Одиночка» (счётчик посещений)

// Счётчик, который должен быть один на всё приложение
class VisitCounter {
    constructor() {
        if (VisitCounter.instance) {
            return VisitCounter.instance;
        }
        this.count = 0;
        VisitCounter.instance = this;
    }

    visit() {
        this.count++;
        console.log(`Посещение номер ${this.count}`);
    }
}

// Проверяем
const counter1 = new VisitCounter();
const counter2 = new VisitCounter();

counter1.visit(); // Посещение номер 1
counter2.visit(); // Посещение номер 2 (счётчик общий!)
console.log(counter1 === counter2); // true

Пример 2: Фабрика для создания игровых персонажей

// Создаём разных врагов в игре
class Goblin {
    attack() {
        console.log('Гоблин атакует копьём! Урон: 5');
    }
}

class Orc {
    attack() {
        console.log('Орк атакует топором! Урон: 15');
    }
}

class Dragon {
    attack() {
        console.log('Дракон дышит огнём! Урон: 50');
    }
}

class EnemyFactory {
    createEnemy(level) {
        if (level < 5) {
            return new Goblin();
        } else if (level < 10) {
            return new Orc();
        } else {
            return new Dragon();
        }
    }
}

// Игрок заходит на новый уровень
const factory = new EnemyFactory();
const enemy1 = factory.createEnemy(3); // Гоблин
const enemy2 = factory.createEnemy(7); // Орк
enemy1.attack(); // Гоблин атакует копьём!
enemy2.attack(); // Орк атакует топором!

Пример 3: «Строитель» (Builder) для сложных объектов

// Собираем сложный заказ в пиццерии
class Pizza {
    constructor() {
        this.size = 'medium';
        this.cheese = false;
        this.pepperoni = false;
        this.mushrooms = false;
    }

    describe() {
        console.log(`Пицца: ${this.size}, сыр: ${this.cheese}, пепперони: ${this.pepperoni}, грибы: ${this.mushrooms}`);
    }
}

class PizzaBuilder {
    constructor() {
        this.pizza = new Pizza();
    }

    setSize(size) {
        this.pizza.size = size;
        return this; // Возвращаем this для цепочки вызовов
    }

    addCheese() {
        this.pizza.cheese = true;
        return this;
    }

    addPepperoni() {
        this.pizza.pepperoni = true;
        return this;
    }

    addMushrooms() {
        this.pizza.mushrooms = true;
        return this;
    }

    build() {
        return this.pizza;
    }
}

// Используем строителя
const builder = new PizzaBuilder();
const myPizza = builder
    .setSize('large')
    .addCheese()
    .addPepperoni()
    .build();

myPizza.describe(); // Пицца: large, сыр: true, пепперони: true, грибы: false

Пример 4: «Адаптер» (Adapter) — делаем старый код совместимым с новым

// У нас есть старая система, которая работает в милях
class OldSpeedometer {
    getSpeedInMiles() {
        return 60; // Возвращает мили в час
    }
}

// Новая система хочет километры
class NewSpeedometer {
    getSpeedInKm() {
        // Ожидаем километры
    }
}

// Адаптер
class SpeedAdapter {
    constructor(oldSpeedometer) {
        this.oldSpeedometer = oldSpeedometer;
    }

    getSpeedInKm() {
        const miles = this.oldSpeedometer.getSpeedInMiles();
        return miles * 1.609; // Преобразуем мили в км
    }
}

// Использование
const old = new OldSpeedometer();
const adapter = new SpeedAdapter(old);
console.log(`Скорость: ${adapter.getSpeedInKm()} км/ч`); // Скорость: 96.54 км/ч

Пример 5: «Стратегия» (Strategy) — выбираем алгоритм на лету

// Разные способы оплаты в интернет-магазине
class CreditCardPayment {
    pay(amount) {
        console.log(`Оплачено ${amount} руб. картой.`);
    }
}

class PayPalPayment {
    pay(amount) {
        console.log(`Оплачено ${amount} руб. через PayPal.`);
    }
}

class CryptoPayment {
    pay(amount) {
        console.log(`Оплачено ${amount} руб. криптовалютой.`);
    }
}

// Контекст, который использует стратегию
class ShoppingCart {
    constructor(paymentStrategy) {
        this.paymentStrategy = paymentStrategy;
    }

    setPaymentStrategy(strategy) {
        this.paymentStrategy = strategy;
    }

    checkout(amount) {
        this.paymentStrategy.pay(amount);
    }
}

// Покупатель выбирает способ оплаты
const cart = new ShoppingCart(new CreditCardPayment());
cart.checkout(1000); // Оплачено 1000 руб. картой.

// Передумал и хочет через PayPal
cart.setPaymentStrategy(new PayPalPayment());
cart.checkout(500); // Оплачено 500 руб. через PayPal.

Пример 6: «Декоратор» (Decorator) — добавляем функционал без изменения класса

// Делаем кофе с добавками
class Coffee {
    cost() {
        return 100; // Базовая цена кофе
    }
}

// Декоратор для молока
class MilkDecorator {
    constructor(coffee) {
        this.coffee = coffee;
    }

    cost() {
        return this.coffee.cost() + 30; // Добавляем цену молока
    }
}

// Декоратор для сахара
class SugarDecorator {
    constructor(coffee) {
        this.coffee = coffee;
    }

    cost() {
        return this.coffee.cost() + 10;
    }
}

// Заказываем кофе с молоком и сахаром
let myCoffee = new Coffee();
myCoffee = new MilkDecorator(myCoffee);
myCoffee = new SugarDecorator(myCoffee);

console.log(`Итоговая цена: ${myCoffee.cost()} руб.`); // Итоговая цена: 140 руб.

Пример 7: «Цепочка обязанностей» (Chain of Responsibility) — передаём запрос по цепочке

// Система техподдержки: запрос проходит по уровням
class SupportHandler {
    constructor(level) {
        this.level = level;
        this.nextHandler = null;
    }

    setNext(handler) {
        this.nextHandler = handler;
        return handler; // Для цепочки
    }

    handle(request) {
        if (this.level >= request.difficulty) {
            console.log(`Уровень ${this.level} обработал запрос: ${request.message}`);
        } else if (this.nextHandler) {
            console.log(`Уровень ${this.level} передаёт запрос дальше...`);
            this.nextHandler.handle(request);
        } else {
            console.log('Никто не смог обработать запрос!');
        }
    }
}

// Создаём цепочку: Уровень 1 -> Уровень 2 -> Уровень 3
const level1 = new SupportHandler(1);
const level2 = new SupportHandler(2);
const level3 = new SupportHandler(3);

level1.setNext(level2).setNext(level3);

// Отправляем запросы
level1.handle({ message: 'Какой пароль?', difficulty: 1 }); // Обработает уровень 1
level1.handle({ message: 'Сервер упал!', difficulty: 3 }); // Обработает уровень 3
level1.handle({ message: 'Куда делась луна?', difficulty: 10 }); // Никто не обработает

Сравнение с Python: чем похоже и чем отличается

Python и JavaScript — оба очень популярны, но у них разная философия. Паттерны работают в обоих языках, но реализуются по-разному.

Аспект JavaScript (Node.js) Python
Создание объекта Использует new, но может обойтись без него (функции-конструкторы). Использует __new__ и __init__. В Python всё — объект.
Одиночка Через статическое свойство класса или модуль (кеширование require). Через переопределение __new__ или декоратор @singleton.
Наблюдатель Встроенный EventEmitter (события — это основа Node.js). Нет встроенного, но есть библиотеки (например, PyDispatcher).
Динамическая типизация Да, но есть TypeScript для строгости. Да, но есть подсказки типов (type hints).
Функции как объекты Функции — это объекты первого класса. Можно передавать как аргументы. То же самое. Функции — тоже объекты.
Декораторы Нет встроенного синтаксиса (но можно написать функцию, оборачивающую другую). Есть встроенный синтаксис @decorator.
Главное отличие: в Node.js паттерн «Наблюдатель» — это не просто паттерн, это архитектура платформы. Весь Node.js построен на событиях. В Python же чаще используют итераторы и генераторы.

Типичные ошибки и подводные камни

Даже опытные разработчики попадаются на этих граблях. Вот список того, чего стоит избегать:

  1. «Золотой молоток». Самая частая ошибка: «Если у меня есть молоток, то все проблемы — гвозди». Не нужно пихать паттерны везде. Иногда простой if лучше, чем фабрика из 10 классов.
  2. Преждевременная оптимизация. Не строй абстрактную фабрику для двух кнопок. Паттерны нужны, когда код начинает расти и становится больно его менять.
  3. Одиночка в тестах. Если ты используешь Singleton, юнит-тесты становятся сложными. Объект живёт всё время, и его состояние перетекает из теста в тест. Решение: используй Dependency Injection (внедрение зависимостей).
  4. Забыть про this. В JavaScript контекст this — это боль. Если ты передаёшь метод объекта как колбэк, this может потеряться. Используй стрелочные функции или .bind().
  5. Слишком сложная фабрика. Если твоя фабрика принимает 10 параметров и имеет 20 условий — ты делаешь что-то не так. Разбей на несколько фабрик или используй Builder.
  6. Нарушение принципа подстановки Лисков (LSP). Если ты создаёшь подкласс, он должен полностью заменять родительский класс. Не делай так, чтобы метод fly() у пингвина бросал ошибку.
Запомни: паттерн — это решение, а не цель. Если код стал понятнее и гибче — ты на правильном пути. Если он стал запутаннее — выброси паттерн и напиши просто.

Лучшие практики и советы профессионалов

Как использовать паттерны, чтобы твой код был крутым, а не «кашей»?

  • Изучай паттерны через реальные проекты. Открой любой популярный npm-пакет (например, Express, Socket.io). Ты увидишь там паттерны «Наблюдатель», «Фабрика», «Цепочка обязанностей» в действии.
  • Комбинируй паттерны. В реальной жизни они редко встречаются поодиночке. Например, «Фабрика» часто создаёт «Одиночек», а «Наблюдатель» использует «Стратегию» для обработки событий.
  • Пиши на TypeScript. TypeScript добавляет интерфейсы и типы. Это делает паттерны более строгими и понятными. Ты сразу увидишь, если нарушаешь контракт.
  • Не бойся рефакторинга. Сначала напиши «как получится». Потом, когда код начнёт пахнуть, примени паттерн. Это называется «рефакторинг к паттерну».
  • Используй принципы SOLID. Паттерны — это конкретные реализации принципов SOLID. Если ты понимаешь SOLID, ты будешь изобретать паттерны сам.
  • Документируй. Если ты используешь паттерн, напиши в комментарии: «Здесь используется паттерн Стратегия для выбора способа оплаты». Другие разработчики скажут тебе спасибо.

Вопросы для самопроверки

Проверь, как ты усвоил материал. Ответы спрятаны под спойлером (попробуй ответить сам, прежде чем смотреть).

  1. Что такое паттерн проектирования простыми словами?
  2. Назови три группы паттернов и приведи пример паттерна из каждой группы.
  3. Для чего нужен паттерн «Одиночка»? Приведи пример из жизни Node.js.
  4. В чём разница между «Фабрикой» и «Строителем»?
  5. Какой паттерн лежит в основе событийной модели Node.js?
  6. Что такое «Адаптер» и когда его применять?
  7. Какая самая распространённая ошибка при использовании паттернов?
  8. Как паттерны связаны с принципами SOLID?
  9. Почему в Node.js паттерн «Одиночка» можно реализовать просто через экспорт объекта из модуля?
  10. Приведи пример, когда НЕ нужно использовать паттерн.
Показать ответы
  1. Это проверенный шаблон решения типовой проблемы в программировании. Как чертёж для дома.
  2. Порождающие (Одиночка), Структурные (Адаптер), Поведенческие (Наблюдатель).
  3. Гарантирует, что у класса есть только один экземпляр. В Node.js — подключение к базе данных (чтобы не создавать много соединений).
  4. Фабрика создаёт объект целиком за один вызов. Строитель позволяет собрать объект по частям, шаг за шагом.
  5. Паттерн «Наблюдатель» (Observer). В Node.js он реализован через класс EventEmitter.
  6. Адаптер позволяет объектам с несовместимыми интерфейсами работать вместе. Например, старый код в милях и новый код в километрах.
  7. «Золотой молоток» — использование паттернов там, где они не нужны, усложняя код.
  8. Паттерны — это конкретные реализации принципов SOLID. Например, «Стратегия» реализует принцип открытости/закрытости (Open/Closed).
  9. В Node.js модули кешируются после первого require. Если экспортировать готовый объект, он будет создан один раз и будет общим для всех импортов.
  10. Когда задача решается простым условием if-else или функцией из 5 строк. Не нужно создавать иерархию классов для одной кнопки.

Итог и что изучить дальше

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

Но мир паттернов огромен. Если тебе стало интересно, вот твой следующий шаг:

  • Изучи остальные паттерны «Банды четырёх» (GoF). Их всего 23. Самые полезные после изученных: «Декоратор», «Команда», «Состояние».
  • Почитай книгу «Паттерны проектирования» Эрика Гаммы и др. Это классика, хотя примеры там на C++.
  • Попробуй написать свой маленький фреймворк. Например, простой HTTP-роутер, используя паттерн «Цепочка обязанностей».
  • Изучи антипаттерны. Это «плохие» паттерны, которые делать не надо. Например, «Божественный объект» (God Object) — когда один класс делает всё.
  • Перейди к архитектурным паттернам. MVC (Model-View-Controller), MVP, MVVM. Это уже уровень целого приложения.
Помни: хороший программист не тот, кто знает 50 паттернов, а тот, кто знает, когда их не применять. Удачи в кодинге!