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

от автора

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

ссылка на оригинал статьи https://habr.com/ru/articles/1065200/