Kurs JavaScript i React · Moduł 15: Wzorce i architektura
Event-Driven Architecture w React - Luźna komunikacja
W tej lekcji8
Panel silnika zmienia prędkość, a dowiedzieć się o tym muszą wyświetlacz na mostku i dziennik pokładowy, leżące w innych gałęziach drzewa komponentów. Propsy przekazywane przez pięć poziomów szybko zamieniają kod w plątaninę kabli. Na statku kosmicznym systemy nie znają się nawzajem: silnik, osłony i nawigacja komunikują się przez centralny system zdarzeń. To właśnie Event-Driven Architecture: wzorzec, w którym komponenty komunikują się przez zdarzenia zamiast bezpośrednich zależności.
Czym jest Event-Driven Architecture?
Event-Driven Architecture (EDA) polega na:
- Emitowaniu zdarzeń: komponent ogłasza, że coś się stało
- Nasłuchiwaniu zdarzeń: inne komponenty reagują na te ogłoszenia
- Luźnym powiązaniu: nadawca nie wie, kto nasłuchuje, a odbiorca nie wie, kto emituje
To klasyczny wzorzec Observer, nazywany też publish/subscribe (pub/sub): nadawca publikuje, subskrybenci reagują.
EventBus - centralna magistrala zdarzeń
Sercem systemu jest EventBus, czyli przekaźnik. Przechowuje mapę, w której każda nazwa zdarzenia ma zbiór czekających na nie funkcji. Metoda on zapisuje słuchacza i od razu zwraca funkcję, która go wypisze:
1// Prosty EventBus z nazwanymi zdarzeniami
2class EventBus {
3 constructor() {
4 this.listeners = new Map();
5 }
6
7 on(event, callback) {
8 if (!this.listeners.has(event)) {
9 this.listeners.set(event, new Set());
10 }
11 this.listeners.get(event).add(callback);
12
13 // Zwróć funkcję cleanup
14 return () => {
15 this.listeners.get(event)?.delete(callback);
16 };
17 }Set sprawia, że ta sama funkcja nie zapisze się dwa razy. Zostały dwie metody: emit wywołuje wszystkich słuchaczy danego zdarzenia, a off usuwa jednego ręcznie. Na końcu tworzymy jedną instancję dla całej aplikacji:
1 emit(event, payload) {
2 if (this.listeners.has(event)) {
3 this.listeners.get(event).forEach(cb => cb(payload));
4 }
5 }
6
7 off(event, callback) {
8 this.listeners.get(event)?.delete(callback);
9 }
10}
11
12// Singleton - jeden bus dla całej aplikacji
13const eventBus = new EventBus();Nazwy w stylu engine:speedChanged to tylko konwencja: przedrostek mówi, który moduł nadaje, a reszta, co się stało. Sam EventBus to zwykła klasa JavaScript, bez React.
Custom hooki do obsługi zdarzeń
Komponent nie powinien sam pamiętać o zapisie i wypisaniu. Ukrywamy to w hooku useEventListener, który zapisuje słuchacza po zamontowaniu i wypisuje go przy odmontowaniu:
1// Hook do nasłuchiwania zdarzeń
2function useEventListener(event, handler) {
3 const savedHandler = useRef(handler);
4
5 useEffect(() => {
6 savedHandler.current = handler;
7 }, [handler]);
8
9 useEffect(() => {
10 const listener = (payload) => savedHandler.current(payload);
11 const unsubscribe = eventBus.on(event, listener);
12 return unsubscribe; // Cleanup przy unmount
13 }, [event]);
14}Sprytny szczegół to useRef: hook zawsze woła najnowszą wersję handler, a subskrypcję odnawia tylko wtedy, gdy zmieni się nazwa zdarzenia. W React 19.2 ten sam problem rozwiązuje wbudowany hook useEffectEvent. Nadawanie jest prostsze, bo nie wymaga sprzątania:
1// Hook do emitowania zdarzeń
2function useEventEmitter() {
3 return useCallback((event, payload) => {
4 eventBus.emit(event, payload);
5 }, []);
6}Cykl życia słuchacza wygląda więc tak: komponent się montuje, useEffect zapisuje go przez eventBus.on(), każde emit() go wywołuje, a przy odmontowaniu funkcja sprzątająca wypisuje go z magistrali.
Praktyczny przykład: System alertów statku
Czas na prawdziwe moduły. Panel silnika to nadawca (publisher): po każdym kliknięciu emituje dwa zdarzenia, zmianę prędkości i wpis do dziennika:
1// Komponent silnika - emituje zdarzenia
2function EnginePanel() {
3 const emit = useEventEmitter();
4
5 const setSpeed = (speed) => {
6 emit('engine:speedChanged', { speed, timestamp: Date.now() });
7 emit('ship:log', { source: 'Silnik', message: `Prędkość: ${speed}` });
8 };
9
10 return (
11 <div className="panel">
12 <h3>Silnik</h3>
13 <button onClick={() => setSpeed('MAX')}>MAX</button>
14 <button onClick={() => setSpeed('CRUISE')}>CRUISE</button>
15 <button onClick={() => setSpeed('STOP')}>STOP</button>
16 </div>
17 );
18}Wyświetlacz prędkości to odbiorca (subscriber). Nasłuchuje tylko jednego zdarzenia i zapisuje otrzymaną prędkość we własnym stanie:
1// Komponent wyświetlacza - nasłuchuje zdarzeń
2function SpeedDisplay() {
3 const [speed, setSpeed] = useState('IDLE');
4
5 useEventListener('engine:speedChanged', (data) => {
6 setSpeed(data.speed);
7 });
8
9 return <div className="display">Prędkość: {speed}</div>;
10}Dziennik pokładowy nasłuchuje wszystkich wpisów ship:log, bez względu na to, który moduł je nadał, i trzyma dziesięć najnowszych:
1// Komponent logu - nasłuchuje wszystkich logów
2let nextEntryId = 0;
3
4function ShipLog() {
5 const [entries, setEntries] = useState([]);
6
7 useEventListener('ship:log', (data) => {
8 const entry = { ...data, id: nextEntryId++, time: new Date().toLocaleTimeString() };
9 setEntries(prev => [entry, ...prev].slice(0, 10));
10 });
11
12 return (
13 <ul className="log">
14 {entries.map(entry => (
15 <li key={entry.id}>[{entry.time}] {entry.source}: {entry.message}</li>
16 ))}
17 </ul>
18 );
19}Każdy wpis dostaje własne id z licznika, bo nowe wpisy lądują na początku listy, a klucz oparty na indeksie przesuwałby się przy każdym zdarzeniu. Zwróć uwagę: EnginePanel nie importuje SpeedDisplay ani ShipLog. Komponenty są całkowicie niezależne. Możesz usunąć ShipLog z drzewa, a EnginePanel dalej działa bez zmian. Gdy dołożysz sterowanie osłonami, wystarczy, że też będzie emitować ship:log, a dziennik pokaże jego wpisy.
Natywne EventTarget API
Zamiast własnego EventBusa możesz użyć natywnego API EventTarget, wbudowanego w przeglądarkę. Dane zdarzenia wędrują w polu detail obiektu CustomEvent:
1// EventTarget - wbudowany w przeglądarkę
2const shipEvents = new EventTarget();
3
4function useNativeEventListener(eventName, handler) {
5 useEffect(() => {
6 const listener = (e) => handler(e.detail);
7 shipEvents.addEventListener(eventName, listener);
8 return () => shipEvents.removeEventListener(eventName, listener);
9 }, [eventName, handler]);
10}
11
12function emitNativeEvent(eventName, detail) {
13 shipEvents.dispatchEvent(new CustomEvent(eventName, { detail }));
14}Efekt jest ten sam, a klasy nie trzeba pisać. Tu jednak handler jest w zależnościach efektu, więc nowa funkcja przy każdym renderze oznacza ponowny zapis słuchacza. Działa to poprawnie, ale lepiej przekazać stabilną funkcję albo użyć triku z useRef.
Zaawansowany EventBus z middleware
EventBus można rozszerzyć o middleware, czyli funkcje przechwytujące zdarzenia przed dostarczeniem. Każda dostaje nazwę i dane, a zwraca dane dalej albo null, żeby zatrzymać zdarzenie:
1class AdvancedEventBus extends EventBus {
2 constructor() {
3 super();
4 this.middleware = [];
5 }
6
7 use(middlewareFn) {
8 this.middleware.push(middlewareFn);
9 }
10
11 emit(event, payload) {
12 // Przepuść przez middleware
13 let finalPayload = payload;
14 for (const mw of this.middleware) {
15 finalPayload = mw(event, finalPayload);
16 if (finalPayload === null) return; // Middleware zablokowało zdarzenie
17 }
18 super.emit(event, finalPayload);
19 }
20}Klasa niczego nie robi, dopóki nie stworzymy instancji i nie podepniemy funkcji. Pierwsza loguje każde zdarzenie, druga prosi o potwierdzenie przed pełną mocą:
1const advancedBus = new AdvancedEventBus();
2
3// Middleware: logowanie
4advancedBus.use((event, payload) => {
5 console.log(`[Event] ${event}`, payload);
6 return payload;
7});
8
9// Middleware: walidacja
10advancedBus.use((event, payload) => {
11 if (event === 'engine:speedChanged' && payload.speed === 'MAX') {
12 if (!window.confirm('Na pewno pełna moc?')) return null; // Zablokuj!
13 }
14 return payload;
15});Middleware działają w kolejności dodania, a gdy walidacja zwróci null, zdarzenie nie dotrze do żadnego słuchacza. Ten sam pomysł znajdziesz w Reduksie i Expressie.
EDA vs Context API vs Props
| Cecha | Props | Context API | Event-Driven |
|---|---|---|---|
| Kierunek | Rodzic -> dziecko | Przodek -> potomkowie | Dowolny |
| Powiązanie | Ścisłe | Średnie | Luźne |
| Rerendery | Bezpośrednie | Wszyscy konsumenci | Tylko subskrybenci |
| Komunikacja między gałęziami | Tylko przez wspólnego rodzica | Wymaga wspólnego przodka | Bezpośrednia |
| Debugowalność | Łatwa | Średnia | Wymaga logowania |
Gdy zmienia się wartość kontekstu, React renderuje ponownie każdy komponent, który ją czyta. Przy EventBusie renderują się tylko subskrybenci, którzy w reakcji zmienią własny stan.
Kiedy używać Event-Driven Architecture?
- Komunikacja między odległymi komponentami: nie chcesz przekazywać propsów przez 5 poziomów
- System powiadomień/alertów: wiele źródeł generuje eventy, jeden komponent je wyświetla
- Logowanie i analityka: zdarzenia użytkownika emitowane raz, nasłuchiwane przez wiele systemów
- Aktualizacje na żywo: dane aktualizowane w czasie rzeczywistym (WebSocket -> EventBus -> komponenty)
Moja rada: EventBus służy do ogłaszania, że coś się stało, a nie do przechowywania danych. Stan, który ekran musi pokazać, trzymaj w React, w kontekście albo w bibliotece stanu, gdzie łatwo prześledzić każdą zmianę. EDA to centralny system komunikacji statku, a klucz do sukcesu to cleanup przy odmontowaniu, inaczej powstaną wycieki pamięci, jak uszkodzony przekaźnik, który nadal zużywa energię. W następnej lekcji uporządkujemy same moduły wzorcem Atomic Design.
Pamiętaj: nadawca ogłasza, co się stało, i nie obchodzi go, kto słucha.
Kod do tej lekcji: App.jsx
1import React, { useState, useEffect, useRef, useCallback } from 'react';
2
3// === MAGISTRALA ZDARZEŃ (EVENT BUS) ===
4class EventBus {
5 constructor() { this.listeners = new Map(); }
6
7 on(event, callback) {
8 if (!this.listeners.has(event)) this.listeners.set(event, new Set());
9 this.listeners.get(event).add(callback);
10 return () => this.listeners.get(event)?.delete(callback);
11 }
12
13 emit(event, payload) {
14 if (this.listeners.has(event)) {
15 this.listeners.get(event).forEach(cb => cb(payload));
16 }
17 }
18}
19
20const eventBus = new EventBus();
21
22// === CUSTOM HOOKI ===
23function useEventListener(event, handler) {
24 const saved = useRef(handler);
25 useEffect(() => { saved.current = handler; }, [handler]);
26 useEffect(() => {
27 const listener = (p) => saved.current(p);
28 return eventBus.on(event, listener);
29 }, [event]);
30}
31
32function useEventEmitter() {
33 return useCallback((event, payload) => eventBus.emit(event, payload), []);
34}
35
36// === NADAWCA (PUBLISHER): panel silnika ===
37function EnginePanel() {
38 const emit = useEventEmitter();
39 const setSpeed = (speed) => {
40 emit('engine:speedChanged', { speed });
41 emit('ship:log', { source: 'Silnik', message: 'Prędkość: ' + speed });
42 };
43 return (
44 <div className="panel publisher">
45 <h3>Sterowanie silnikiem</h3>
46 <span className="tag">Nadawca</span>
47 <div className="btn-group">
48 <button className="btn btn-danger" onClick={() => setSpeed('MAX')}>MAX</button>
49 <button className="btn btn-primary" onClick={() => setSpeed('CRUISE')}>CRUISE</button>
50 <button className="btn btn-default" onClick={() => setSpeed('STOP')}>STOP</button>
51 </div>
52 </div>
53 );
54}
55
56// === NADAWCA (PUBLISHER): panel osłon ===
57function ShieldPanel() {
58 const emit = useEventEmitter();
59 const [level, setLevel] = useState(100);
60 const update = (v) => {
61 setLevel(v);
62 emit('shields:level', { level: v });
63 emit('ship:log', { source: 'Osłony', message: v + '%' });
64 };
65 return (
66 <div className="panel publisher">
67 <h3>Sterowanie osłonami</h3>
68 <span className="tag">Nadawca</span>
69 <input type="range" min="0" max="100" value={level} onChange={e => update(Number(e.target.value))} />
70 <span className="value">{level}%</span>
71 </div>
72 );
73}
74
75// === ODBIORCA (SUBSCRIBER): wyświetlacz prędkości ===
76function SpeedDisplay() {
77 const [speed, setSpeed] = useState('IDLE');
78 useEventListener('engine:speedChanged', (data) => setSpeed(data.speed));
79 const colors = { MAX: '#f44336', CRUISE: '#4fc3f7', STOP: '#666', IDLE: '#999' };
80 return (
81 <div className="panel subscriber">
82 <h3>Prędkość</h3>
83 <span className="tag sub-tag">Odbiorca</span>
84 <div className="big-value" style={{ color: colors[speed] }}>{speed}</div>
85 </div>
86 );
87}
88
89// === ODBIORCA (SUBSCRIBER): wyświetlacz osłon ===
90function ShieldDisplay() {
91 const [level, setLevel] = useState(100);
92 useEventListener('shields:level', (data) => setLevel(data.level));
93 const color = level > 60 ? '#4caf50' : level > 30 ? '#ff9800' : '#f44336';
94 return (
95 <div className="panel subscriber">
96 <h3>Osłony</h3>
97 <span className="tag sub-tag">Odbiorca</span>
98 <div className="bar"><div className="bar-fill" style={{ width: level + '%', background: color }} /></div>
99 <span className="value">{level}%</span>
100 </div>
101 );
102}
103
104// === ODBIORCA (SUBSCRIBER): dziennik pokładowy ===
105let nextEntryId = 0;
106
107function ShipLog() {
108 const [entries, setEntries] = useState([]);
109 useEventListener('ship:log', (data) => {
110 const entry = { ...data, id: nextEntryId++, time: new Date().toLocaleTimeString() };
111 setEntries(prev => [entry, ...prev].slice(0, 10));
112 });
113 return (
114 <div className="panel subscriber log-panel">
115 <h3>Dziennik pokładowy</h3>
116 <span className="tag sub-tag">Odbiorca (wszystkie zdarzenia)</span>
117 {entries.length === 0 ? <p className="empty">Brak zdarzeń</p> : (
118 <ul>{entries.map(e => <li key={e.id}>[{e.time}] {e.source}: {e.message}</li>)}</ul>
119 )}
120 </div>
121 );
122}
123
124// === GŁÓWNY KOMPONENT ===
125export default function App() {
126 return (
127 <div className="app">
128 <h1>Event-Driven Architecture</h1>
129 <p className="subtitle">Nadawcy emitują zdarzenia, odbiorcy reagują niezależnie</p>
130 <div className="grid">
131 <div className="col">
132 <EnginePanel />
133 <ShieldPanel />
134 </div>
135 <div className="col">
136 <SpeedDisplay />
137 <ShieldDisplay />
138 </div>
139 </div>
140 <ShipLog />
141 </div>
142 );
143}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jaki jest główny cel Event-Driven Architecture w aplikacji React?
2. Która cecha ODRÓŻNIA Event-Driven Architecture od Context API w React?
Zadania praktyczne w grze
- Edytor kodu
Dokończ magistralę zdarzeń i hooki, przez które rozmawiają dwa niezależne komponenty. ___BLANK1___: funkcja zwrócona przez on usuwa ze zbioru słuchaczy zdarzenia event tylko ten jeden callback. ___BLANK2___: emit wywołuje każdego słuchacza z danymi payload. ___BLANK3___: w useEffect zapisz słuchacza listener w eventBus i zwróć funkcję wypisującą jako cleanup efektu. ___BLANK4___: EnginePanel po kliknięciu emituje zdarzenie engine:speedChanged z obiektem { speed }, a SpeedDisplay pokazuje otrzymaną prędkość.
- Układanie w pionie
Ułóż kroki implementacji Event-Driven Architecture w React:
- Edytor kodu
EventBus i hooki są gotowe, dokończ dwóch nadawców i dwóch odbiorców. ___BLANK1___: ShieldControl po przesunięciu suwaka emituje shields:level z nowym poziomem jako liczbą (next, nie stara wartość ze stanu). ___BLANK2___: AlertSystem po kliknięciu emituje alert:status z poziomem alarmu (alert.level: green, yellow albo red). ___BLANK3___: StatusDashboard nasłuchuje zdarzenia, które zmienia stan alarmu. ___BLANK4___: ShipLog dopisuje nowy wpis na początek listy i zostawia najwyżej 8 najnowszych wpisów.
- Układanie w pionie
Ułóż warstwy Event-Driven Architecture od najniższej do najwyższej: