Эта статья не претендует на создание полноценной замены Node.js EventEmitter или других готовых библиотек. Цель — разобраться, как устроен Event Bus и написать собственную минимальную реализацию.Однажды, размышляя над архитектурой своего приложения, я задался вопросом: как связать модули между собой так, чтобы они оставались полностью автономными? Без импортов и экспорта функциональности.И решил я написать свою систему коммуникации модулей.В первую очередь нужно было определиться с сущностью. Было несколько вариантов: сделать один универсальный объект или, может, класс. Но подумав, решил написать всё одной функцией.const Emitter = () => {}Начало положено. Именно эта функция будет представлять всю систему.Итак, как теперь реализовать коммуникацию? Нужно сделать так, чтобы модули могли подписываться на определённые события. Например, у нас есть приложение todo list. У него есть кнопка "Добавить задачу". Нам нужно сделать так, чтобы при клике на неё: Читать далее
Эта статья не претендует на создание полноценной замены 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() для очистки всех подписок.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Один поток, а всё успевает: как работает цикл событий в JavaScript | 0 | 7.77 | 24-07-2026 |
| 2 | Как я организовал проект на Next.js | 0 | 6.5 | 21-07-2026 |
| 3 | Широкий вход, узкий выход: Vue 3 внутри jQuery‑формы без бесконечных циклов | 0 | 7.31 | 28-07-2026 |
| 4 | Реальная и мнимая многопоточность в JS | 0 | 5.83 | 30-07-2026 |
| 5 | «Зачем Javasript-у DI контейнер?» | 0 | 8.78 | 21-07-2026 |
| 6 | Модель исполнения JavaScript по спецификации ECMAScript: call stack, контексты, окружения и замыкания | 0 | 6.66 | 28-07-2026 |
| 7 | История JavaScript: браузерные войны, ECMAScript, Node.js, TypeScript, React | 0 | 8.31 | 10-07-2026 |
| 8 | Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов | 5 | 8 | 07-07-2026 |
| 9 | Next.js, localStorage в App Router без hydration-ошибок | 0 | 7.17 | 30-07-2026 |
| 10 | Дайджест JS/TS: новинки ES2026, гонка рантаймов и EAP | 0 | 12.18 | 29-06-2026 |