Эта статья не претендует на создание полноценной замены 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()для очистки всех подписок.
ссылка на оригинал статьи https://habr.com/ru/articles/1065200/