javascript

Как я написал собственный Event Bus на JavaScript

  • суббота, 1 августа 2026 г. в 00:00:11
https://habr.com/ru/articles/1065200/

Эта статья не претендует на создание полноценной замены Node.js EventEmitter или других готовых библиотек. Цель — разобраться, как устроен Event Bus и написать собственную минимальную реализацию.

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

И решил я написать свою систему коммуникации модулей.

В первую очередь нужно было определиться с сущностью. Было несколько вариантов: сделать один универсальный объект или, может, класс. Но подумав, решил написать всё одной функцией.

const Emitter = () => {}

Начало положено. Именно эта функция будет представлять всю систему.

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

Например, у нас есть приложение todo list. У него есть кнопка "Добавить задачу". Нам нужно сделать так, чтобы при клике на неё:

  1. Один модуль создавал новую задачу

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

Модулей может быть сколь угодно много. Как можно решить задачу в обычной ситуации?

/* main.js */

import createTask from './modules/createTask';
import sendAnalytics from './modules/sendAnalytics';
import function3 from './modules/function3';
import function4 from './modules/function4';

document.querySelector(<кнопка>).addEventListener('click', function() {
  createTask()
  sendAnalytics()
  function3()
  function4()
})

Выглядит довольно объёмно, согласитесь.

А теперь представим, что вместо этого мы вызываем всего одно событие.

/* main.js */

document.querySelector(<кнопка>).addEventListener('click', function() {
  // вызываем событие
  emit('add-task') 
})

А каждый модуль будет вести себя так:

/* module.js */

on('add-task', function() { 
  // При срабатывании события пишем функцию.
  // У каждого модуля она своя
})

Рассмотрим, как это работает.

Теперь любой модуль может самостоятельно подписаться на событие и выполнить свою часть работы.

Зачем тебе писать велосипед? У js уже есть CustomEvent. Возьми его.

Да, в JavaScript уже есть CustomEvent. Но он привязан к DOM, а мне хотелось бы получить максимально простую шину событий, которую можно использовать в любом JavaScript-коде.

Для прототипа нам нужны следующие функции:

  • on - подписка на событие

  • emit - вызов события

  • off - для отписки от события

Это основные функции. Напишем их.

const Emitter = () => {
  let listeners = {}

  const on = (eventName, fn) => {
    if (!listeners[eventName]) listeners[eventName] = [fn]
    else listeners[eventName] = [...listeners[eventName], fn]
  }

  const off = (eventName, fn) => {
    listeners[eventName] = listeners[eventName]?.filter(func => func !== fn)
  }

  const emit = (eventName, data = {}) => {
    listeners[eventName]?.forEach(fn => fn(data))
  }

  // Не забываем вынести все функции наружу
  return { on, off, emit }
}

Простейшая конструкция из 3 функций готова. А сейчас объясню весь код.

Для начала, объект listeners. Это реестр обработчиков событий.

То есть когда приложение будет работать, он будет выглядеть вот так:

{
  'add-task': [function, function],
  'view-task': [function],
  'del-task': [function, function, function]
}

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

Функция on нужна для того, чтобы добавить функцию к какому-то событию. Если этого события в реестре нет, то мы создаём массив и добавляем туда первую функцию. За это отвечает вот эта строчка кода.

const on = (eventName, fn) => {
  if (!listeners[eventName]) listeners[eventName] = [fn]
  // Остальной код
}

Иначе, мы просто докидываем новую функцию в массив

const on = (eventName, fn) => {
  // Начало функции
  else listeners[eventName] = [...listeners[eventName], fn]
}

Примечание: можно использовать и push(). Я использовал оператор расширения просто потому, что мне так удобнее.

Функция off отвечает за удаление функции из события.

Тут всё просто. Находим функцию в реестре и возвращаем массив уже без неё.

const off = () => {
  listeners[eventName] = listeners[eventName]?.filter(func => func !== fn)
}

Функция emit отвечает за вызов события.

const emit = (eventName, data = {}) => {
  listeners[eventName]?.forEach(fn => fn(data))
}

И вот здесь уже должны появиться вопросы:

  1. Зачем аргумент data?

  2. Почему data = {}

  3. Что за ?.

  4. Почему fn(data), если мы просто функции вызываем?

Отвечаю. Вопрос №1:

Аргумент data требуется для того, чтобы можно было не только вызывать события, но и отправлять через них данные.

emit('add-task')

Тут мы вызываем событие "добавить задание". Но ведь у задания есть: название, статус выполнения, срок завершения и т. д.

К примеру, нам надо добавить 2 задания:

  • Сходить за продуктами

  • Позаниматься гимнастикой

С аргументом data мы можем сделать вот так.

emit('add-task', { title: 'Сходить за продуктами' })
emit('add-task', { title: 'Позаниматься гимнастикой' })

Теперь мы имеем одно событие, но у которого передаются разные данные.

И сразу отвечу на 4 вопрос:

fn(data) вызывается для того, чтобы мы могли достать переданную информацию.

on('add-task', (data) => {
  console.log(data.title)

  // Вывод:
  // Сходить за продуктами
  // Позаниматься гимнастикой
}

Вопрос №2: Почему data = {}?

Это значение по умолчанию. На самом деле тут может быть любой тип данных. В CustomEvent тоже используется объект.

Вопрос №3: Что за ?. ?

Оператор ?. позволяет обратиться к свойству или вызвать метод. Если значение слева равно null или undefined, выражение просто вернёт undefined, не выбросив ошибку.

Взглянем на код:

listeners[eventName]?.forEach(fn => fn(data))

Здесь мы говорим: "Вызови метод forEach, если он есть. Там возьми каждую функцию, передай им данные и вызови их с этими данными".

Если у listeners[eventName] нет метода forEach, тогда строка кода ничего больше не сделает.

Если у события есть функции, то listeners[eventName] будет массивом.

Если у события слушателей нет, тогда listeners[eventName] вернёт undefined. Так что ?.forEach... просто не выполнится и функция пропустит этот кусок кода.

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

Напишем функцию once

const once = (eventName, fn) => {
  const wrapper = (data) => {
    off(eventName, wrapper)
    fn(data)
  }
  on(eventName, wrapper)
}

Выглядит сложно, но сейчас объясню что к чему.

Поскольку once должен выполняться один раз, нам нужно сделать функцию-обёртку. В ней будет отписка от события.

Глянем на предпоследнюю строку.

on(eventName, wrapper)

Здесь мы подписываемся на событие, но в качестве функции передаём не саму функцию, а обёртку.

И зачем она нужна?

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

Сейчас у нас есть механизмы:

  • подписки на события

  • отписки

  • одноразовые подписки

  • вызов события

И я предлагаю добавить функцию when

Зачем она?

Представьте, что у вас есть огромное приложение. Там имеется жизненный цикл. И вот тут приходит идея сделать систему плагинов, но она должна инициализироваться в определённый момент.

emit('init') // Инициализировалась система

// куча кода

on('init', pluginInit)

Но что если нам нужно подключить плагин намного позже? То есть мы очутимся в ситуации, когда не получится отловить событие init? Оно уже вызвалось и больше вызываться не будет.

Для таких ситуаций и нужен when. Реализуем.

const history = new Set()

Здесь мы будем хранить только список событий. Список функций нам не нужен.

Set мы используем для того, чтобы события не повторялись.

Немного меняем emit

const emit = (eventName, data) => {
  history.add(eventName)
  listeners[eventName]?.forEach(fn => fn(data))
}

Теперь при вызове события, мы добавляем его в историю.

Пишем when

const when = (eventName, fn) => {
  if (history.has(eventName)) fn()
  else once(eventName, fn)
}

Для простоты when запоминает только факт того, что событие уже произошло.

На этом минимальная реализация Event Bus готова.

Код:

const Emitter = () => {
  let listeners = {}
  const history = new Set()


  const on = (eventName, fn) => {
    if (!listeners[eventName]) listeners[eventName] = [fn]
    else listeners[eventName] = [...listeners[eventName], fn]
  }

  const off = (eventName, fn) => {
    listeners[eventName] = listeners[eventName]?.filter(func => func !== fn)
  }

  const once = (eventName, fn) => {
    const wrapper = (data) => {
      off(eventName, wrapper)
      fn(data)
    }
    on(eventName, wrapper)
  }
  
  const when = (eventName, fn) => {
    if (history.has(eventName)) fn()
    else once(eventName, fn)
  }
  

  const emit = (eventName, data = {}) => {
    history.add(eventName)
    listeners[eventName]?.forEach(fn => fn(data))
  }

  // Не забываем вынести все функции наружу
  return { on, off, emit, once, when }
}

На этом базовая реализация Event Bus готова. Конечно, её можно развивать дальше: добавить приоритеты обработчиков, пространства имён, асинхронные события или хранение последних данных. Но даже в таком виде она уже отлично подходит для небольших проектов.

Что можно добавить дальше?

В этой статье я специально остановился на минимальной реализации. При желании её можно расширить:

  • использовать Map вместо обычного объекта;

  • хранить последние данные события для when;

  • добавить приоритеты обработчиков;

  • реализовать пространства имён событий;

  • поддержать асинхронные обработчики;

  • добавить метод clear() для очистки всех подписок.