리액트를 이용하여 이벤트 버스를 만들어 봅시다.
원문 링크입니다 : https://dawchihliou.github.io/articles/event-bus-for-react
TL;DR
- 🚌 단 60줄로 이벤트 버스를 구현해 봅니다.
- 🌱 React에서 Event Bus를 활용하는 방법을 배웁니다.
- 🏋️♀️ 데모에 Google Maps API와 Event Bus를 적용해 봅니다.
이벤트 버스가 뭐죠?
이벤트 버스는 컴포넌트 간 느슨한 결합을 유지하면서,
컴포넌트 간에 PubSub 스타일 통신을 허용하는 디자인 패턴입니다.
- Event : 이벤트 버스가 보내고 받는 메시지 입니다.
- Publisher : 이벤트를 발생시키는 발신자 입니다.
- Subscriber: 이벤트를 수신하는 수신자 입니다.
밑바닥부터 이벤트 버스 구현하기
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: Subscriber가 이벤트를 수신(구독)하고 이벤트 핸들러를 등록합니다.
- off: Subscriber가 이벤트 및 이벤트 핸들러를 제거(구독 취소)하는 경우.
- once: Subscriber가 이벤트를 한 번만 수신하도록 합니다.
- emit: Publisher가 이벤트 버스에 이벤트를 보낼 수 있습니다.
이제 이벤트 버스의 데이터 구조는 두 가지 일 을 할 수 있어야 합니다.
- Publisher를 위해 : emit이 호출될 때 이벤트 키와 연결된 이벤트 핸들러를 실행할 수 있습니다.
- Subscriber를 위해 : on, once 또는 off가 호출될 때 이벤트 핸들러를 추가하거나 제거할 수 있습니다.
우리는 키-값 구조를 사용할 수 있습니다.
type Bus = Record<string, EventHandler[]>
on 메소드를 구현하려면 버스에 이벤트 키를 추가하고 핸들러 배열에 이벤트 핸들러를 추가하기만 하면 됩니다.
또한 이벤트 핸들러를 제거하기 위해 구독 취소 함수를 반환합니다.
export function eventbus(config?: {
// error handler for later
onError: (...params: any[]) => void
}): EventBus {
const bus: Bus = {}
const on: EventBus['on'] = (key, handler) => {
if (bus[key] === undefined) {
bus[key] = []
}
bus[key]?.push(handler)
// unsubscribe function
return () => {
off(key, handler)
}
}
return { on }
}
const off: EventBus['off'] = (key, handler) => {
const index = bus[key]?.indexOf(handler) ?? -1
bus[key]?.splice(index >>> 0, 1)
}
const emit: EventBus['emit'] = (key, payload) => {
bus[key]?.forEach((fn) => {
try {
fn(payload)
} catch (e) {
config?.onError(e)
}
})
}
once는 이벤트를 정확히 한 번만 수신하므로, 핸들러를 한번만 실행하고 제거하는 역할을 하는 함수로 생각할 수 있습니다.
이 때, 자기 자신이 핸들러 역할을 하면서 실제 함수를 대신 실행해 줍니다. 즉 고차 핸들러 입니다.
그리고 자기 자신을 제거합니다.
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)
}
이제 우리는 이벤트 버스의 모든 메소드를 구현하였습니다!
타입스크립트 타입 강화하기
type EventKey = string | symbol
type EventHandler<T = any> = (payload: T) => void
type EventMap = Record<EventKey, EventHandler>
interface EventBus<T extends EventMap> {
on<Key extends keyof T>(key: Key, handler: T[Key]): () => void
off<Key extends keyof T>(key: Key, handler: T[Key]): void
emit<Key extends keyof T>(key: Key, ...payload: Parameters<T[Key]>): void
once<Key extends keyof T>(key: Key, handler: T[Key]): void
}
- type Bus = Record<string, EventHandler[]>
+ type Bus<E> = Record<keyof E, E[keyof E][]>
- export function eventbus(config?: {
+ export function eventbus<E extends EventMap>(config?: {
onError: (...params: any[]) => void
- }): EventBus {
+ }): EventBus<E> {
- const bus: Bus = {}
+ const bus: Partial<Bus<E>> = {}
- const on: EventBus['on'] = (key, handler) => {
+ const on: EventBus<E>['on'] = (key, handler) => {
- const off: EventBus['off'] = (key, handler) => {
+ const off: EventBus<E>['off'] = (key, handler) => {
- const emit: EventBus['emit'] = (key, payload) => {
+ const emit: EventBus<E>['emit'] = (key, payload) => {
- const once: EventBus['once'] = (key, handler) => {
+ const once: EventBus<E>['once'] = (key, handler) => {
return { on, off, once, emit }
}
interface MyBus {
'on-event-1': (payload: { data: string }) => void
}
const myBus = eventbus<MyBus>()
리액트에서 이벤트 버스 사용하기
GitHub repository for the demo here
- onMapIdle: 지도 인스턴스화가 완료되거나 사용자가 지도 드래그 또는 확대/축소를 완료하면 이벤트가 발생합니다.
- onMapClick: 사용자가 지도를 클릭할 때 이벤트가 발생합니다.
마커용
- onMarkerClick: 사용자가 지도 마커를 클릭할 때 이벤트가 발생합니다.
app/eventChannels/map.ts
import { eventbus } from 'eventbus'
export const mapEventChannel = eventbus<{
onMapIdle: () => void
onMapClick: (payload: google.maps.MapMouseEvent) => void
}>()
app/eventChannels/marker.ts
import { eventbus } from 'eventbus'
import type { MarkerData } from '~/data/markers'
export const markerEventChannel = eventbus<{
onMarkerClick: (payload: MarkerData) => void
}>()
app/routes/index.tsx
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(() => {
// subscribe to events when mounting
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)
}
)
// unsubscribe events when unmounting
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} />
</>
)
}
마지막 생각들
Reddit에서 Redux를 이벤트 버스로 사용하는 것에 대한 흥미로운 토론도 있습니다.
(discussion on Reddit about using Redux as an Event Bus)
Redux 메인테이너 중 한 명이 이벤트를 처리하기 위해 몇 가지 Redux 기반 도구를 제안했습니다.
추가
vue.js 권장 라이브러리 링크를 타고 들어가니 아래와 같은 말이 있네요.
대부분의 경우 컴포넌트 간 통신에 전역 이벤트 버스를 사용하는 것은 권장되지 않습니다.
단기적으로는 가장 간단한 솔루션인 경우가 많지만 장기적으로는 거의 예외 없이 유지 보수의 골칫거리입니다.
상황에 따라 이벤트 버스의 대안이 있습니다.
- Pinia와 같은 글로벌 상태 관리 솔루션
- Prop과 이벤트는 부모-자식 커뮤니케이션을 위한 첫 번째 선택이 되어야 합니다.
- 형제 자매는 부모를 통해 통신할 수 있습니다.
- Provide/Inject는 컴포넌트가 슬롯 내부과 통신할 수 있도록 합니다.
- 이것은 항상 함께 사용되는 밀접하게 결합된 컴포넌트에 유용합니다.
- Provide/Inject는 컴포넌트 간의 장거리 통신에도 사용할 수 있습니다.
- props이 필요하지 않은 여러 수준의 컴포넌트를 통해 props를 전달해야 하는 'prop 드릴링'을 방지하는 데 도움이 될 수 있습니다.
- 슬롯을 사용하도록 리팩토링하여 prop 드릴링을 피할 수도 있습니다.
- 중간 컴포넌트에 prop이 필요하지 않은 경우 관심사 분리 문제가 있음을 나타냅니다.
- 해당 컴포넌트에 슬롯을 도입하면 부모가 콘텐츠를 직접 생성할 수 있으므로 중간 컴포넌트가 개입할 필요 없이 prop을 전달할 수 있습니다.
References
'FrontEnd' 카테고리의 다른 글
타입스크립트 데코레이터 완벽 가이드[A Complete Guide to TypeScript Decorators] (0) | 2022.10.22 |
---|---|
Remix로 알아보는 전역 상태 관리와 프론트엔드 개발의 미래 (2) | 2022.10.21 |
리액트 use, 새로 등장한 훅을 알아보자 (React use) (0) | 2022.10.20 |
리액트 디자인 패턴 : uncontrolled compound components(제어없는 컴파운드 컴포넌트) (0) | 2022.10.19 |
리액트와 가상돔(virtual dom) (0) | 2022.10.18 |