Кратко о целях
- С нуля напишем паттерн передачи событий
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:
Читайте также:
- 12 хуков React, которые должен знать каждый разработчик
- Почему пора завязывать с React
- Как создать библиотеку компонентов для совместного использования
Читайте нас в Telegram, VK и Дзен
Перевод статьи Daw-Chih Liou: How to Use Event Bus in React Architecture