Как я написал собственный Event Bus на JavaScript
- суббота, 1 августа 2026 г. в 00:00:11
Эта статья не претендует на создание полноценной замены Node.js EventEmitter или других готовых библиотек. Цель — разобраться, как устроен Event Bus и написать собственную минимальную реализацию.
Однажды, размышляя над архитектурой своего приложения, я задался вопросом: как связать модули между собой так, чтобы они оставались полностью автономными? Без импортов и экспорта функциональности.
И решил я написать свою систему коммуникации модулей.
В первую очередь нужно было определиться с сущностью. Было несколько вариантов: сделать один универсальный объект или, может, класс. Но подумав, решил написать всё одной функцией.
const Emitter = () => {}
Начало положено. Именно эта функция будет представлять всю систему.
Итак, как теперь реализовать коммуникацию? Нужно сделать так, чтобы модули могли подписываться на определённые события.
Например, у нас есть приложение todo list. У него есть кнопка "Добавить задачу". Нам нужно сделать так, чтобы при клике на неё:
Один модуль создавал новую задачу
Модуль аналитики (если есть) отправлял на сервер данные о том, что человек нажал на кнопку.
Модулей может быть сколь угодно много. Как можно решить задачу в обычной ситуации?
/* 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)) }
И вот здесь уже должны появиться вопросы:
Зачем аргумент data?
Почему data = {}
Что за ?.
Почему 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() для очистки всех подписок.