Паттерн Observer в JavaScript: Глубокая реализация и современное применение

Один из наиболее элегантных способов организации взаимодействия между компонентами — паттерн Observer. Он обеспечивает слабую связанность, но его неправильная реализация часто приводит к утечкам памяти и неочевидным багам. Разберёмся, как реализовать его корректно и где он применяется в современных веб-технологиях.

Проблема: Жёсткие связи и хрупкая коммуникация

Представьте email-рассылку: пользователи подписываются на новости, а система уведомляет их о новых статьях. Наивный подход — хранить массив подписчиков в классе Newsletter и вызывать их методы напрямую:

javascript
class Newsletter {
  constructor() {
    this.subscribers = [];
  }
  
  addSubscriber(user) {
    this.subscribers.push(user);
  }

  sendUpdate(article) {
    this.subscribers.forEach(subscriber => {
      subscriber.sendEmail(article); // Прямая зависимость!
    });
  }
}

Проблемы:

  1. Класс Newsletter жёстко зависим от интерфейса subscriber.sendEmail.
  2. Подписчики не могут контролировать тип уведомлений (SMS, push и т.д.).
  3. Удалить подписку сложно — требуется фильтрация массива.

Реализация Observer: Декомпозиция ответственности

Паттерн Observer разделяет сущности на:

  • Subject (издатель): управляет подписчиками и оповещает их.
  • Observer (подписчик): предоставляет интерфейс для реакции на события.

Шаг 1: Абстракция подписчика

javascript
class Subscriber {
  update(article) {
    throw new Error("Метод update должен быть переопределён");
  }
}

Шаг 2: Рефакторинг издателя

javascript
class Newsletter {
  constructor() {
    this.subscribers = new Map(); // Используем Map для эффективного удаления
  }

  subscribe(observer) {
    const id = Symbol(); // Уникальный идентификатор подписки
    this.subscribers.set(id, observer);
    return () => this.subscribers.delete(id); // Функция отмены подписки
  }

  sendUpdate(article) {
    for (const observer of this.subscribers.values()) {
      observer.update(article);
    }
  }
}

Шаг 3: Конкретные подписчики

javascript
class EmailSubscriber extends Subscriber {
  constructor(email) {
    super();
    this.email = email;
  }

  update(article) {
    console.log(`Отправлено на ${this.email}: "${article.title}"`);
  }
}

class PushSubscriber extends Subscriber {
  update(article) {
    console.log(`Push-уведомление: Новая статья "${article.title}"`);
  }
}

Использование:

javascript
const newsletter = new Newsletter();

const unsubscribeJohn = newsletter.subscribe(
  new EmailSubscriber("john@example.com")
);

const unsubscribeApp = newsletter.subscribe(
  new PushSubscriber()
);

// Отправка новости
newsletter.sendUpdate({ title: "JavaScript Patterns 2023" });

// Отмена подписки John
unsubscribeJohn();

Ключевые преимущества

  1. Слабая связанность: Издатель ничего не знает о деталях реализации подписчиков.
  2. Динамическое управление: Подписки добавляются/удаляются в рантайме.
  3. Масштабируемость: Новые типы подписчиков (SMS, логирование) не требуют изменений в Newsletter.

Распространённые ошибки и их решение

Проблема: Утечки памяти
Если забыть отписать объект, он останется в памяти (особенно критично для SPA).

Решение:

  • Всегда возвращайте функцию отписки (как в примере выше).
  • В современных UI-фреймворках используйте встроенные средства (например, useEffect возвращает cleanup-функцию):
javascript
useEffect(() => {
  const unsubscribe = newsletter.subscribe(/* ... */);
  return unsubscribe; // Отписка при размонтировании
}, []);

Проблема: Неконтролируемые обновления
Множественные оповещения подписчиков могут привести к лавинообразным рендерам в UI.

Решение:

  • Реализуйте batching — группировку обновлений:
javascript
class Newsletter {
  // ...

  sendUpdate(article) {
    // Публикуем через setTimeout для объединения в одну таску
    setTimeout(() => {
      this.subscribers.forEach(observer => observer.update(article));
    }, 0);
  }
}

Observer в современных API браузера

  1. IntersectionObserver
    Следит за видимостью элемента:
javascript
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log("Элемент виден!", entry.target);
    }
  });
});

observer.observe(document.querySelector(".component"));
  1. MutationObserver
    Реагирует на изменения DOM:
javascript
const observer = new MutationObserver(mutations => {
  mutations.forEach(mut => console.log("Изменение:", mut.type));
});

observer.observe(document.body, { 
  childList: true, 
  attributes: true 
});

Когда использовать другие паттерны

  • Pub/Sub (Mediator): Подходит для сложных систем с множеством издателей и подписчиков (через централизованный event bus).
  • Reactive Programming (RxJS): Для асинхронных потоков данных с операторами (map, filter, debounce).

Выводы

Паттерн Observer — фундамент для событийно-ориентированного кода. Но помните:

  • Всегда удаляйте подписки для предотвращения утечек.
  • Избегайте тяжёлой логики в update — делегируйте её в очередь задач или воркеры.
  • Используйте нативные API браузера (IntersectionObserver, MutationObserver) вместо самописных решений.

Грамотная реализация Observer делает код предсказуемым, а его композицию — элегантной. Не заставляйте объекты следить друг за другом вручную — делегируйте эту работу паттерну.