Эта статья не претендует на создание полноценной замены 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()для очистки всех подписок.
Автор: omyraucy
