Understanding Event Emitters

Avatar of Charles Peters
Charles Peters am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Betrachten wir ein DOM-Ereignis

const button = document.querySelector("button");

button.addEventListener("click", (event) => /* do something with the event */)

Wir haben einen Listener für einen Button-Klick hinzugefügt. Wir haben uns für ein ausgelöstes Ereignis *abonniert* und rufen eine Callback-Funktion auf, wenn es auftritt. Jedes Mal, wenn wir diesen Button klicken, wird das Ereignis ausgelöst und unsere Callback-Funktion wird mit dem Ereignis aufgerufen.

Es kann vorkommen, dass Sie ein benutzerdefiniertes Ereignis auslösen möchten, wenn Sie in einer bestehenden Codebasis arbeiten. Nicht speziell ein DOM-Ereignis wie das Klicken eines Buttons, sondern sagen wir, Sie möchten ein Ereignis basierend auf einem anderen Auslöser auslösen und darauf reagieren. Wir benötigen dazu einen benutzerdefinierten *Ereignis-Emitter*.

Ein Ereignis-Emitter ist ein Muster, das auf ein benanntes Ereignis lauscht, eine Callback-Funktion auslöst und dann dieses Ereignis mit einem Wert auslöst. Manchmal wird dies auch als „Pub/Sub“-Modell oder Listener bezeichnet. Es bezieht sich auf dasselbe.

In JavaScript könnte eine Implementierung etwa so aussehen:

let n = 0;
const event = new EventEmitter();

event.subscribe("THUNDER_ON_THE_MOUNTAIN", value => (n = value));

event.emit("THUNDER_ON_THE_MOUNTAIN", 18);

// n: 18

event.emit("THUNDER_ON_THE_MOUNTAIN", 5);

// n: 5

In diesem Beispiel haben wir uns für ein Ereignis namens „THUNDER_ON_THE_MOUNTAIN“ abonniert, und wenn dieses Ereignis ausgelöst wird, wird unsere Callback-Funktion value => (n = value) aufgerufen. Um dieses Ereignis auszulösen, rufen wir emit() auf.

Dies ist nützlich bei der Arbeit mit asynchronem Code, wenn ein Wert an einer Stelle aktualisiert werden muss, die sich nicht im aktuellen Modul befindet.

Ein sehr makro-level Beispiel dafür ist React Redux. Redux benötigt eine Möglichkeit, extern mitzuteilen, dass sein interner Speicher aktualisiert wurde, damit React weiß, dass sich diese Werte geändert haben, und setState() aufrufen kann, um die Benutzeroberfläche neu zu rendern. Dies geschieht über einen Ereignis-Emitter. Der Redux-Store verfügt über eine subscribe-Funktion, die eine Callback-Funktion aufnimmt, die den neuen Speicher bereitstellt, und in dieser Funktion wird die -Komponente von React Redux aufgerufen, die setState() mit dem neuen Speicherwert aufruft. Sie können die gesamte Implementierung hier einsehen.

Nun haben wir zwei verschiedene Teile unserer Anwendung: die React-Benutzeroberfläche und den Redux-Store. Keiner von beiden kann dem anderen über ausgelöste Ereignisse Auskunft geben.

Implementierung

Lassen Sie uns einen einfachen Ereignis-Emitter erstellen. Wir werden eine Klasse verwenden und darin die Ereignisse verfolgen.

class EventEmitter {
  public events: Events;
  constructor(events?: Events) {
    this.events = events || {};
  }
}

Ereignisse

Wir definieren unsere Events-Schnittstelle. Wir speichern ein einfaches Objekt, bei dem jeder Schlüssel das benannte Ereignis ist und sein jeweiliger Wert ein Array von Callback-Funktionen ist.

interface Events {
  [key: string]: Function[];
}

/**
{
  "event": [fn],
  "event_two": [fn]
}
*/

Wir verwenden ein Array, da es mehr als einen Abonnenten für jedes Ereignis geben könnte. Stellen Sie sich vor, wie oft Sie element.addEventLister("click") in einer Anwendung aufrufen würden… wahrscheinlich mehr als einmal.

Abonnieren

Jetzt müssen wir uns um das Abonnieren eines benannten Ereignisses kümmern. In unserem einfachen Beispiel nimmt die Funktion subscribe() zwei Parameter entgegen: einen Namen und eine Callback-Funktion, die ausgelöst werden soll.

event.subscribe("named event", value => value);

Lassen Sie uns diese Methode definieren, damit unsere Klasse diese beiden Parameter übernehmen kann. Wir werden diese Werte lediglich an this.events anhängen, das wir intern in unserer Klasse verfolgen.

class EventEmitter {
  public events: Events;
  constructor(events?: Events) {
    this.events = events || {};
  }

  public subscribe(name: string, cb: Function) {
    (this.events[name] || (this.events[name] = [])).push(cb);
  }
}

Auslösen

Jetzt können wir Ereignisse abonnieren. Als Nächstes müssen wir diese Callback-Funktionen auslösen, wenn ein neues Ereignis ausgelöst wird. Wenn dies geschieht, verwenden wir den Ereignisnamen, den wir speichern (emit("event")), und jeden Wert, den wir mit der Callback-Funktion übergeben möchten (emit("event", value)). Ehrlich gesagt wollen wir keine Annahmen über diese Werte treffen. Wir übergeben einfach jeden Parameter nach dem ersten an die Callback-Funktion.

class EventEmitter {
  public events: Events;
  constructor(events?: Events) {
    this.events = events || {};
  }

  public subscribe(name: string, cb: Function) {
    (this.events[name] || (this.events[name] = [])).push(cb);
  }

  public emit(name: string, ...args: any[]): void {
    (this.events[name] || []).forEach(fn => fn(...args));
  }
}

Da wir wissen, welches Ereignis wir auslösen möchten, können wir es über die JavaScript-Objekt-Bracket-Syntax (d. h. this.events[name]) nachschlagen. Dies gibt uns das Array von Callback-Funktionen, die gespeichert wurden, sodass wir jede einzelne durchlaufen und alle übergebenen Werte anwenden können.

Abbestellen

Die Hauptteile haben wir bisher gelöst. Wir können ein Ereignis abonnieren und dieses Ereignis auslösen. Das sind die wichtigsten Dinge.

Jetzt müssen wir in der Lage sein, ein Ereignis zu abbestellen.

Wir haben den Namen des Ereignisses und die Callback-Funktion bereits in der subscribe()-Funktion. Da wir viele Abonnenten für ein einzelnes Ereignis haben könnten, möchten wir die Callback-Funktionen einzeln entfernen.

subscribe(name: string, cb: Function) {
  (this.events[name] || (this.events[name] = [])).push(cb);

  return {
    unsubscribe: () =>
      this.events[name] && this.events[name].splice(this.events[name].indexOf(cb) >>> 0, 1)
  };
}

Dies gibt ein Objekt mit einer unsubscribe-Methode zurück. Wir verwenden eine Pfeilfunktion (() =>), um den Gültigkeitsbereich der an die übergeordnete Funktion des Objekts übergebenen Parameter zu erhalten. In dieser Funktion suchen wir den Index der Callback-Funktion, die wir an die übergeordnete Funktion übergeben haben, und verwenden den bitweisen Operator (>>>). Der bitweise Operator hat eine lange und komplizierte Geschichte (über die Sie alles lesen können). Die Verwendung eines solchen Operators stellt sicher, dass wir jedes Mal eine echte Zahl erhalten, wenn wir splice() auf unserem Array von Callback-Funktionen aufrufen, auch wenn indexOf() keine Zahl zurückgibt.

Jedenfalls ist es uns zugänglich und wir können es so verwenden:

const subscription = event.subscribe("event", value => value);

subscription.unsubscribe();

Jetzt sind wir aus diesem speziellen Abonnement ausgestiegen, während alle anderen Abonnements weiterlaufen können.

Alles zusammen!

Manchmal hilft es, alle besprochenen Einzelteile zusammenzufügen, um zu sehen, wie sie zueinander in Beziehung stehen.

interface Events {
  [key: string]: Function[];
}

export class EventEmitter {
  public events: Events;
  constructor(events?: Events) {
    this.events = events || {};
  }

  public subscribe(name: string, cb: Function) {
    (this.events[name] || (this.events[name] = [])).push(cb);

    return {
      unsubscribe: () =>
        this.events[name] && this.events[name].splice(this.events[name].indexOf(cb) >>> 0, 1)
    };
  }

  public emit(name: string, ...args: any[]): void {
    (this.events[name] || []).forEach(fn => fn(...args));
  }
}

Demo

Wir tun in diesem Beispiel ein paar Dinge. Erstens verwenden wir einen Ereignis-Emitter in einer anderen Ereignis-Callback-Funktion. In diesem Fall wird ein Ereignis-Emitter verwendet, um einige Logiken zu bereinigen. Wir wählen ein Repository auf GitHub aus, rufen Details dazu ab, cachen diese Details und aktualisieren das DOM, um diese Details widerzuspiegeln. Anstatt all das an einem Ort zu platzieren, holen wir ein Ergebnis in der Abonnement-Callback-Funktion aus dem Netzwerk oder dem Cache und aktualisieren das Ergebnis. Wir können dies tun, weil wir der Callback-Funktion ein zufälliges Repo aus der Liste geben, wenn wir das Ereignis auslösen.

Betrachten wir nun etwas weniger Konstruiertes. In einer gesamten Anwendung haben wir möglicherweise viele Anwendungszustände, die davon abhängen, ob wir angemeldet sind, und wir möchten möglicherweise, dass mehrere Abonnenten die Tatsache verarbeiten, dass der Benutzer sich abmeldet. Da wir ein Ereignis mit false ausgelöst haben, kann jeder Abonnent diesen Wert nutzen, unabhängig davon, ob wir die Seite umleiten, einen Cookie löschen oder ein Formular deaktivieren müssen.

const events = new EventEmitter();

events.emit("authentication", false);

events.subscribe("authentication", isLoggedIn => {
  buttonEl.setAttribute("disabled", !isLogged);
});

events.subscribe("authentication", isLoggedIn => {
  window.location.replace(!isLoggedIn ? "/login" : "");
});

events.subscribe("authentication", isLoggedIn => {
  !isLoggedIn && cookies.remove("auth_token");
});

Haken und Ösen (Gotchas)

Wie bei allem gibt es ein paar Dinge zu beachten, wenn Sie Emitter in Betrieb nehmen.

  • Wir müssen forEach oder map in unserer emit()-Funktion verwenden, um sicherzustellen, dass wir neue Abonnements erstellen oder uns von einem Abonnement abmelden, wenn wir uns in dieser Callback-Funktion befinden.
  • Wir können vordefinierte Ereignisse gemäß unserer Events-Schnittstelle übergeben, wenn eine neue Instanz unserer EventEmitter-Klasse instanziiert wurde, aber ich habe dafür noch keinen wirklichen Anwendungsfall gefunden.
  • Wir müssen keine Klasse dafür verwenden und es ist weitgehend persönliche Präferenz, ob Sie eine verwenden oder nicht. Ich persönlich verwende eine, weil sie sehr deutlich macht, wo Ereignisse gespeichert werden.

Wenn wir schon bei der Praktikabilität sind, könnten wir all dies mit einer Funktion tun:

function emitter(e?: Events) {
  let events: Events = e || {};

  return {
    events,
    subscribe: (name: string, cb: Function) => {
      (events[name] || (events[name] = [])).push(cb);

      return {
        unsubscribe: () => {
          events[name] && events[name].splice(events[name].indexOf(cb) >>> 0, 1);
        }
      };
    },
    emit: (name: string, ...args: any[]) => {
      (events[name] || []).forEach(fn => fn(...args));
    }
  };
}

Fazit: Eine Klasse ist nur eine Präferenz. Das Speichern von Ereignissen in einem Objekt ist ebenfalls eine Präferenz. Wir hätten genauso gut mit einem Map() arbeiten können. Machen Sie das, womit Sie sich am wohlsten fühlen.


Ich habe diesen Beitrag aus zwei Gründen geschrieben. Erstens hatte ich immer das Gefühl, das Konzept von Emittern gut verstanden zu haben, aber einen von Grund auf neu zu schreiben, war nichts, wozu ich mich je fähig gefühlt hätte, aber jetzt weiß ich, dass ich es kann – und ich hoffe, Sie fühlen sich jetzt genauso! Zweitens tauchen Emitter häufig in Jobinterviews auf. Es fällt mir schwer, in solchen Situationen kohärent zu sprechen, und dies so festzuhalten, erleichtert es, die Hauptidee zu erfassen und die wichtigsten Punkte zu veranschaulichen.

Ich habe das alles in einem GitHub-Repository eingerichtet, wenn Sie den Code herunterladen und damit experimentieren möchten. Und natürlich stehe ich Ihnen für Fragen in den Kommentaren zur Verfügung, wenn etwas auftaucht!