Руководство по применению паттерна Event Bus в архитектуре React

Кратко о целях

  • С нуля напишем паттерн передачи событий Event Bus (пер. шина событий), содержащий всего 60 строк! 
  • Рассмотрим наилучший способ применения Event Bus в React. 
  • Используем Event Bus в демо-примере с Google Maps API. 

Как-то в процессе работы я натолкнулся на один интересный пример с Event Bus  —  упрощенный модуль, организующий процесс логирования для аналитики в веб-приложениях глобального масштаба. Он придает большой базе кода предельную ясность. В статье представлены результаты изучения этого эффективного паттерна проектирования. 

Приступим! 

Что такое Event Bus?

Event Bus  —  это паттерн проектирования, обеспечивающий взаимодействие между слабо связанными компонентами по принципу “публикатор события-подписчик на событие”. 

Компонент может отправить сообщение в шину событий, не зная конечного получателя. С другой стороны, компонент может прослушать сообщение в шине событий и решить, что с ним делать, не располагая информацией об его отправителе. Посредством данного паттерна независимые компоненты могут взаимодействовать между собой, не зная друг друга. 

Наглядное представление принципа паттерна Event Bus:

  • Событие (англ. Event)  —  сообщение, которое отправляется и принимается в шине событий. 
  • Публикатор (англ. Publisher)  —  отправитель, запускающий событие. 
  • Подписчик (англ. Subscriber)  —  получатель, прослушивающий событие. 

Рассмотрим паттерн Event Bus поподробнее. 

Создание Event Bus с нуля 

Вдохновляясь Events API от Vue, реализуем следующие API для Event Bus

type EventHandler = (payload: any) => void

interface EventBus {
on(key: string, handler: EventHandler): () => void
off(key: string, handler: EventHandler): void
emit(key: string, ...payload: Parameters<EventHandler>): void
once(key: string, handler: EventHandler): void
}
  • on: для подписчика с целью прослушивания события (подписки на событие) и регистрации его обработчика.
  • off: для подписчика с целью удаления события (отмены подписки на событие) и его обработчика.
  • once: для подписчика с целью однократного прослушивания события. 
  • emit: для публикатора с целью отправки события в шину событий. 

Структура данных для Event Bus должна решать 2 задачи: 

  • запускать для публикаторов зарегистрированные обработчики события, связанные с его ключом при вызове emit;
  • добавлять или удалять обработчики событий для подписчиков при вызове on, once и off.

С этой целью задействуется структура “ключ-значение”, как показано ниже:

type Bus = Record<string, EventHandler[]>

Для реализации метода on достаточно добавить ключ события в шину, а обработчик события  —  в массив обработчика. Помимо этого, потребуется вернуть функцию отмены подписки для удаления обработчика события: 

export function eventbus(config?: {
// обработчик ошибок для последующего использования
onError: (...params: any[]) => void
}): EventBus {
const bus: Bus = {}
const on: EventBus['on'] = (key, handler) => {
if (bus[key] === undefined) {
bus[key] = []
}
bus[key]?.push(handler)
// функция отмены подписки
return () => {
off(key, handler)
}
}
return { on }
}

Для реализации off просто удаляем обработчик события из шины.

const off: EventBus['off'] = (key, handler) => {
const index = bus[key]?.indexOf(handler) ?? -1
bus[key]?.splice(index >>> 0, 1)
}

При вызове emit должны запускаться все обработчики, связанные с событием. Здесь мы добавляем обработку ошибок. Это позволяет убедиться, что все обработчики событий будут запущены, несмотря на ошибки:

const emit: EventBus['emit'] = (key, payload) => {
bus[key]?.forEach((fn) => {
try {
fn(payload)
} catch (e) {
config?.onError(e)
}
})
}

Поскольку once только единожды прослушивает событие, его можно рассмотреть как метод, регистрирующий обработчик, который отменяет свою регистрацию после запуска. Один из способов решения заключается в создании функции высшего порядка handleOnce. А вот и код: 

const once: EventBus['once'] = (key, handler) => {
const handleOnce = (payload: Parameters<typeof handler>) => {
handler(payload)
off(key, handleOnce as typeof handler)
}
on(key, handleOnce as typeof handler)
}

Итак, мы обеспечили Event Bus всеми необходимыми методами! 

Улучшение типизации TypeScript 

Текущая типизация для Event Bus характеризуется чрезмерной открытостью. Ключ события может быть любой строкой, а обработчик  —  любой функцией. В целях безопасного применения паттерна воспользуемся проверкой типов для добавления ключа и нужного обработчика в EventBus:

const once: EventBus['once'] = (key, handler) => {
const handleOnce = (payload: Parameters<typeof handler>) => {
handler(payload)
off(key, handleOnce as typeof handler)
}
on(key, handleOnce as typeof handler)
}

Здесь мы указываем TypeScript, что ключ должен быть одним из keyof T, а обработчик должен иметь соответствующий тип. Например: 

interface MyBus {
'on-event-1': (payload: { data: string }) => void
}const myBus = eventbus<MyBus>()

При разработке необходимо видеть четкое определение типов: 

Применение Event Bus в React

Я создал приложение Remix, демонстрирующее работу только что созданного Event Bus

Репозиторий GitHub для рассматриваемого демо-примера представлен по этой ссылке

Демо показывает, как организовать процесс логирования с помощью Event Bus в изоморфном приложении React. Для логирования выбраны 3 события: 

  • onMapIdle: событие происходит, когда карта завершает инстанцирование или пользователь прекращает перемещение или масштабирование карты; 
  • onMapClick: событие происходит при нажатии пользователем на карту; 
  • onMarkerClick: событие происходит при нажатии пользователем на маркер. 

Создаем 2 канала событий: для карты и маркера: 

import { eventbus } from 'eventbus'
export const mapEventChannel = eventbus<{
onMapIdle: () => void
onMapClick: (payload: google.maps.MapMouseEvent) => void
}>()

import { eventbus } from 'eventbus'
import type { MarkerData } from '~/data/markers'

export const markerEventChannel = eventbus<{
onMarkerClick: (payload: MarkerData) => void
}>()

Разделение каналов событий вызвано необходимостью четкого разграничения задач. С ростом приложения данный паттерн может разрастаться горизонтально.  

Применяем каналы событий в компонентах React:

import { markers } from '~/data/marker'
import { logUserInteraction } from '~/utils/logger'
import { mapEventChannel } from '~/eventChannels/map'
import { markerEventChannel } from '~/eventChannels/marker'

export async function loader() {
return json({
GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY,
})
}

export default function Index() {
const data = useLoaderData()
const portal = useRef<HTMLDivElement>(null)
const [selectedMarker, setSelectedMarker] = useState<MarkerData>()

useEffect(() => {
// подписка на события при монтировании
const unsubscribeOnMapIdle = mapEventChannel.on('onMapIdle', () => {
logUserInteraction('on map idle.')
})
const unsubscribeOnMapClick = mapEventChannel.on(
'onMapClick',
(payload) => {
logUserInteraction('on map click.', payload)
}
)
const unsubscribeOnMarkerClick = markerEventChannel.on(
'onMarkerClick',
(payload) => {
logUserInteraction('on marker click.', payload)
}
)
// отмена подписки на событие при размонтировании
return () => {
unsubscribeOnMapIdle()
unsubscribeOnMapClick()
unsubscribeOnMarkerClick()
}
}, [])

const onMapIdle = (map: google.maps.Map) => {
mapEventChannel.emit('onMapIdle')
setZoom(map.getZoom()!)
const nextCenter = map.getCenter()
if (nextCenter) {
setCenter(nextCenter.toJSON())
}
}
const onMapClick = (e: google.maps.MapMouseEvent) => {
mapEventChannel.emit('onMapClick', e)
}
const onMarkerClick = (marker: MarkerData) => {
markerEventChannel.emit('onMarkerClick', marker)
setSelectedMarker(marker)
}

return (
<>
<GoogleMap
apiKey={data.GOOGLE_MAPS_API_KEY}
markers={markers}
onClick={onMapClick}
onIdle={onMapIdle}
onMarkerClick={onMarkerClick}
/>
<Portal container={portal.current}>
{selectedMarker && <Card {...selectedMarker} />}
</Portal>
<div ref={portal} />
</>
)
}

Мы подписались на события в компоненте Index и порождаем события при взаимодействии с картой и маркерами. Более того, подписываясь и отписываясь на этапах жизненного цикла компонента, мы регистрируем только необходимые обработчики события в момент перемещения пользователя по карте. 

Заключение 

Если вы ищите библиотеку Event Bus, то можете рассмотреть варианты, рекомендованные Vue.js

Советую изучить материал об использовании Redux в качестве Event Bus. Для обработки событий можно воспользоваться следующими инструментами на основе Redux: 

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Daw-Chih Liou: How to Use Event Bus in React Architecture

Предыдущая статьяPython 3.11: функционал, который вам понравится
Следующая статьяРоль Fragments в современной разработке приложений для Android