Kurs JavaScript i React · Moduł 15: Wzorce i architektura

Event-Driven Architecture w React - Luźna komunikacja

7 min czytania
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:

  1. Emitowaniu zdarzeń: komponent ogłasza, że coś się stało
  2. Nasłuchiwaniu zdarzeń: inne komponenty reagują na te ogłoszenia
  3. 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

CechaPropsContext APIEvent-Driven
KierunekRodzic -> dzieckoPrzodek -> potomkowieDowolny
PowiązanieŚcisłeŚrednieLuźne
RerenderyBezpośrednieWszyscy konsumenciTylko subskrybenci
Komunikacja między gałęziamiTylko przez wspólnego rodzicaWymaga wspólnego przodkaBezpośrednia
DebugowalnośćŁatwaŚredniaWymaga 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. 1. Jaki jest główny cel Event-Driven Architecture w aplikacji React?

  2. 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:

Przydatne artykuły