Kurs JavaScript i TypeScript · Moduł 8: Zdarzenia i DOM

Event Bus i architektura aplikacji

4 min czytania ·

Implementacja systemu komunikacji między komponentami za pomocą event bus.

Wyobraź sobie moduł wykrywający ucieczki dinozaurów. Gdy raptor sforsuje ogrodzenie, trzeba włączyć syrenę, zaznaczyć strefę na mapie, zablokować bramy i wysłać wiadomość do ochrony. Jeśli moduł ucieczek wywołuje każdą z tych funkcji bezpośrednio, każdy nowy system oznacza grzebanie w jego kodzie, a po roku nikt nie wie, co od czego zależy. Event Bus działa jak centrala radiowa parku: moduły nadają komunikaty na wspólnej szynie, a zainteresowani sami ich słuchają. Nikt nie musi znać adresu nikogo.

Implementacja Event Bus

Klasa przechowuje listenery w obiekcie events. Kluczem jest nazwa wydarzenia, a wartością tablica funkcji, które na nie czekają.

class EventBus {
  constructor() {
    this.events = {};
  }

  // Rejestracja nasłuchiwacza
  on(eventName, callback) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(callback);

    // Zwróć funkcję do usunięcia listenera
    return () => this.off(eventName, callback);
  }

Metoda on() tworzy tablicę przy pierwszej rejestracji, dopisuje do niej funkcję i zwraca gotową funkcję do wyrejestrowania. Dzięki temu moduł nie musi przechowywać referencji do callbacka tylko po to, żeby go później usunąć. Zapis eventBus.on('alert', callback) czyta się jak zdanie: nasłuchuj na alert i wtedy wywołaj callback. Dwie kolejne metody zdejmują listenery.

  // Usunięcie nasłuchiwacza
  off(eventName, callback) {
    if (this.events[eventName]) {
      this.events[eventName] = this.events[eventName].filter(cb => cb !== callback);
    }
  }

  // Jednorazowy nasłuchiwacz
  once(eventName, callback) {
    const onceCallback = (...args) => {
      callback(...args);
      this.off(eventName, onceCallback);
    };
    this.on(eventName, onceCallback);
  }

Metoda off() nie modyfikuje starej tablicy, tylko tworzy nową przez filter(), więc usunięcie listenera w trakcie emisji nie psuje trwającej pętli. Metoda once() owija callback w onceCallback, który najpierw wywołuje oryginał, a potem sam się wyrejestrowuje. Listener zadziała więc dokładnie raz i zniknie automatycznie. Ta wersja ma jedno ograniczenie: zapisaliśmy onceCallback, a nie oryginalny callback, więc wywołanie off() z oryginałem go nie usunie. Czas na nadawanie komunikatów.

  // Emisja wydarzenia
  emit(eventName, ...args) {
    if (this.events[eventName]) {
      this.events[eventName].forEach(callback => {
        try {
          callback(...args);
        } catch (error) {
          console.error(`Error in event listener for ${eventName}:`, error);
        }
      });
    }
  }

Metoda emit() przekazuje wszystkie argumenty każdemu słuchaczowi w kolejności rejestracji. Blok try...catch wokół każdego wywołania to ważna decyzja architektoniczna: błąd w jednym module trafia do konsoli, ale nie zatrzymuje powiadomień dla pozostałych. Syrena zawyje, nawet jeśli moduł mapy akurat ma usterkę. Na koniec sprzątanie hurtowe.

  // Usunięcie wszystkich listenerów dla wydarzenia
  removeAllListeners(eventName) {
    if (eventName) {
      delete this.events[eventName];
    } else {
      this.events = {};
    }
  }
}

Metoda removeAllListeners() z nazwą usuwa jeden kanał, a bez argumentu czyści całą szynę. Przydaje się przy zamykaniu aplikacji i w testach, gdy każdy test ma startować z czystą centralą.

Użycie w modułach aplikacji

W aplikacji tworzymy jedną wspólną instancję, którą importują wszystkie moduły. Moduł A zapisuje się na wiadomość o ucieczce.

// Globalna instancja event bus
const eventBus = new EventBus();

// Użycie w różnych modułach aplikacji
// Module A
eventBus.on('dinosaurEscape', (dinosaurData) => {
  showEmergencyAlert(dinosaurData);
});

Moduł A pokaże alarm, gdy tylko ktoś nada dinosaurEscape, choć nie wie nawet, który moduł to zrobi. Teraz nadawca i drugi słuchacz.

// Module B
function handleDinosaurEscape(dinosaur) {
  eventBus.emit('dinosaurEscape', {
    id: dinosaur.id,
    name: dinosaur.name,
    location: dinosaur.enclosure,
    dangerLevel: dinosaur.dangerLevel
  });
}

// Module C
eventBus.on('dinosaurEscape', (dinosaurData) => {
  if (dinosaurData.dangerLevel > 8) {
    activateEmergencyProtocols();
  }
});

Moduł B tylko nadaje komunikat z danymi dinozaura. Nie wie, że istnieją moduły A i C, a moduł C sam decyduje, że reaguje wyłącznie na poziom zagrożenia powyżej 8. To właśnie luźne powiązanie (loose coupling): dodanie modułu D to jedno nowe on(), bez żadnej zmiany w module B.

Kiedy sięgać po Event Bus

W lekcji o własnych wydarzeniach zbudowaliśmy szynę na EventTarget i CustomEvent, z danymi w detail. Ta wersja opiera się na zwykłym obiekcie JavaScript, więc działa także poza przeglądarką. Node.js ma zresztą wbudowaną klasę EventEmitter z tymi samymi metodami on, off, once i emit.

Moja rada: używaj szyny do powiadomień, które interesują wiele niezależnych modułów, ale nie zamieniaj w komunikaty każdego wywołania funkcji. Przy nadmiarze wydarzeń trudno odpowiedzieć na pytanie, kto co wysłał i dlaczego. Nazwy wydarzeń trzymaj w stałych, bo literówka w 'dinosaurEscape' nie zgłosi błędu, tylko zamieni alarm w ciszę w eterze. W piaskownicy poniżej zobaczysz szynę łączącą kilka modułów parku. W kolejnej lekcji nauczysz się testować handlery wydarzeń.

Pamiętaj: Event Bus to centrala radiowa parku, w której nadawca nie musi znać słuchaczy, a słuchacze nie muszą znać nadawcy.

Kod do tej lekcji: index.html
<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Event Bus Pattern</title>
    <style>
        body { font-family: Arial, sans-serif; background: linear-gradient(135deg, #2c3e50, #34495e); color: white; padding: 20px; }
        .module { background: rgba(0,0,0,0.7); padding: 20px; border-radius: 10px; margin: 20px 0; border-left: 4px solid #3498db; }
        button { padding: 10px 20px; background: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; margin: 5px; }
        .log { background: #000; padding: 15px; border-radius: 6px; font-family: monospace; font-size: 12px; max-height: 150px; overflow-y: auto; }
        .log-entry { padding: 3px 0; border-bottom: 1px solid #333; }
    </style>
</head>
<body>
    <h1>Event Bus Architecture</h1>

    <div class="module">
        <h3>Module A: Security System</h3>
        <button onclick="triggerSecurityAlert()">Trigger Security Alert</button>
        <div class="log" id="security-log"></div>
    </div>

    <div class="module">
        <h3>Module B: Visitor Management</h3>
        <button onclick="addVisitor()">Add Visitor</button>
        <div class="log" id="visitor-log"></div>
    </div>

    <div class="module">
        <h3>Module C: Analytics</h3>
        <div class="log" id="analytics-log"></div>
    </div>

    <script>
        console.log('Event Bus System initializing...');

        // EVENT BUS IMPLEMENTATION
        class EventBus {
            constructor() {
                this.events = {};
            }

            on(eventName, callback) {
                if (!this.events[eventName]) {
                    this.events[eventName] = [];
                }
                this.events[eventName].push(callback);
                return () => this.off(eventName, callback);
            }

            off(eventName, callback) {
                if (this.events[eventName]) {
                    this.events[eventName] = this.events[eventName].filter(cb => cb !== callback);
                }
            }

            emit(eventName, ...args) {
                if (this.events[eventName]) {
                    this.events[eventName].forEach(callback => {
                        try {
                            callback(...args);
                        } catch (error) {
                            console.error(`Error in ${eventName}:`, error);
                        }
                    });
                }
            }

            once(eventName, callback) {
                const onceCallback = (...args) => {
                    callback(...args);
                    this.off(eventName, onceCallback);
                };
                this.on(eventName, onceCallback);
            }
        }

        // Global Event Bus instance
        const eventBus = new EventBus();

        // Helper functions
        function log(containerId, message, color = '#95a5a6') {
            const container = document.getElementById(containerId);
            const entry = document.createElement('div');
            entry.className = 'log-entry';
            entry.style.color = color;
            entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
            container.insertBefore(entry, container.firstChild);
            if (container.children.length > 10) {
                container.removeChild(container.lastChild);
            }
        }

        // MODULE A: Security System
        eventBus.on('security:alert', (data) => {
            log('security-log', `ALERT: ${data.message}`, '#e74c3c');
            log('security-log', `Location: ${data.location}`, '#e74c3c');
            console.log('Security module received alert:', data);
        });

        eventBus.on('visitor:entered', (data) => {
            log('security-log', `Visitor ${data.name} cleared security`, '#27ae60');
        });

        // MODULE B: Visitor Management
        eventBus.on('visitor:entered', (data) => {
            log('visitor-log', `New visitor: ${data.name}`, '#3498db');
            log('visitor-log', `Ticket: ${data.ticketType}`, '#3498db');
        });

        eventBus.on('security:alert', (data) => {
            log('visitor-log', `Evacuation notice sent`, '#f39c12');
        });

        // MODULE C: Analytics
        let visitorCount = 0;
        let alertCount = 0;

        eventBus.on('visitor:entered', (data) => {
            visitorCount++;
            log('analytics-log', `Total visitors: ${visitorCount}`, '#9b59b6');
        });

        eventBus.on('security:alert', (data) => {
            alertCount++;
            log('analytics-log', `Total alerts: ${alertCount}`, '#9b59b6');
        });

        // Trigger functions
        function triggerSecurityAlert() {
            eventBus.emit('security:alert', {
                message: 'T-Rex escaped from enclosure!',
                location: 'Sector 7',
                severity: 'HIGH',
                timestamp: new Date()
            });
        }

        function addVisitor() {
            const names = ['John', 'Sarah', 'Mike', 'Emma'];
            const tickets = ['Standard', 'VIP', 'Family'];

            eventBus.emit('visitor:entered', {
                name: names[Math.floor(Math.random() * names.length)],
                ticketType: tickets[Math.floor(Math.random() * tickets.length)],
                timestamp: new Date()
            });
        }

        console.log('Event Bus ready');
        console.log('3 modules connected via Event Bus');
        log('security-log', 'Security System online', '#27ae60');
        log('visitor-log', 'Visitor Management online', '#27ae60');
        log('analytics-log', 'Analytics System online', '#27ae60');
    </script>
</body>
</html>

Hasła z dokumentacji w tej lekcji

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. Jaka jest główna zaleta wzorca Event Bus w architekturze aplikacji?

  2. 2. Co robi metoda once() w implementacji Event Bus?

Zadania praktyczne w grze

  • Edytor kodu

    W pliku index.js jest klasa EventBus z metodami on(), off(), emit() i once(), jak w lekcji o Event Bus. Uzupełnij luki: ___BLANK1___ to metoda tablicy, która w off() zostawia wszystkie listenery poza usuwanym, a ___BLANK2___ to metoda EventBus, za pomocą której listener jednorazowy z once() wypisuje się przed wywołaniem callbacku. emit() ma wywołać listenery w kolejności dodania z przekazanymi argumentami, off() usunąć tylko wskazany listener, a listener z once() zadziałać tylko przy pierwszym emit().

  • Układanie w poziomie

    Ułóż wywołanie metody Event Bus do nasłuchiwania na wydarzenie 'alert':

Przydatne artykuły