EventBus (Pub/Sub)

Минималистичная реализация шины событий (publish/subscribe) без сторонних библиотек — для связи между несвязанными модулями.

Задача

Два модуля приложения должны обмениваться данными, не зная друг о друге. Прямые импорты создают жёсткие зависимости. EventBus позволяет издателю публиковать событие, а подписчику — реагировать на него независимо.

Решение

// event-bus.ts
type EventHandler<T = unknown> = (payload: T) => void;

class EventBus {
  private events: Map<string, Set<EventHandler>> = new Map();

  on<T>(event: string, handler: EventHandler<T>):  => void {
    if (!this.events.has(event)) {
      this.events.set(event, new Set);
    }
    this.events.get(event)!.add(handler as EventHandler);

    // Возвращает функцию отписки
    return  => this.off(event, handler);
  }

  once<T>(event: string, handler: EventHandler<T>): void {
    const wrapper: EventHandler<T> = (payload) => {
      handler(payload);
      this.off(event, wrapper as EventHandler);
    };
    this.on(event, wrapper);
  }

  off<T>(event: string, handler: EventHandler<T>): void {
    this.events.get(event)?.delete(handler as EventHandler);
  }

  emit<T>(event: string, payload?: T): void {
    this.events.get(event)?.forEach((handler) => handler(payload));
  }

  clear(event?: string): void {
    if (event) {
      this.events.delete(event);
    } else {
      this.events.clear();
    }
  }
}

// Синглтон для всего приложения
export const bus = new EventBus();

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

import { bus } from './event-bus';

// Модуль A: подписка
const unsubscribe = bus.on<{ userId: number }>('user:login', ({ userId }) => {
  console.log('Пользователь вошёл:', userId);
});

// Модуль B: публикация
bus.emit('user:login', { userId: 42 });

// Одноразовая подписка
bus.once('app:ready', () => console.log('Приложение готово'));

// Отписка
unsubscribe;

Ключевые моменты

  • on возвращает функцию отписки — удобнее, чем хранить ссылку на handler отдельно.
  • Set вместо Array исключает дублирование одного и того же обработчика.
  • once автоматически удаляет обработчик после первого вызова.
  • Синглтон через именованный экспорт bus — один экземпляр на всё приложение.

Варианты

  • mitt — 200 байт, TypeScript, популярная минималистичная замена.
  • EventTarget — встроенный браузерный API, можно использовать без кастомной реализации:
    const bus = new EventTarget();
    bus.dispatchEvent(new CustomEvent('user:login', { detail: { userId: 42 } }));
    bus.addEventListener('user:login', (e) => console.log(e.detail));
    
  • В React — предпочти Context + useReducer вместо глобального EventBus.

Когда НЕ использовать

  • Когда данные нужно хранить, а не просто передавать — используй глобальный стейт (Zustand, Redux).
  • Когда события связаны с жизненным циклом компонентов React — предпочти хуки и Context.

Связанные рецепты / темы