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
forEachodermapin unsereremit()-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 unsererEventEmitter-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!
Ich habe gerade eine React-App eingerichtet, die über WebSockets eine Verbindung zum Backend herstellt. Das könnte eine wirklich schöne Möglichkeit sein, eine typsichere Schnittstelle zwischen den beiden zu erstellen. Auf diese Weise können DOM-Ereignisse genauso behandelt werden wie Netzwerkereignisse: „online“, „click“, „user-connected“. Es sind alles nur Ereignisse, die ausgelöst werden können, oder?
Danke für den Artikel, Charles!
Warum nicht
CustomEventverwenden?Gute Frage, der Hauptgrund ist, dass dies einfach ein gängiges Muster ist und an keine spezifische Umgebung gebunden ist. Es funktioniert also sowohl im Browser als auch auf dem Server.
CustomEvent ist eine DOM-Sache. Das Pub/Sub-Modell hier kann überall verwendet werden.
Hallo Charles,
Es ist ein großartiger Artikel! Dennoch verstehe ich das Emitter-Muster nicht. Für mich sieht
getRepowie eine normale Funktion aus (https://codepen.io/kimek/pen/RdOjbX). Was sind die Vorteile der Verwendung eines Emitters dort?Im Hinblick auf das Authentifizierungsszenario: Schreibt der Emitter nicht einfach die Authentifizierungsfunktionalität neu? Ich habe Listener und Observer, aber keine Emitter :(
Der Emitter sagt: „Hey, mach jetzt diese Sache“, damit der Abonnent darauf zurückrufen kann. Im Authentifizierungsbeispiel können viele Teile unserer App für ein
logout-Ereignis *abonniert* sein und ihre eigene Logik verarbeiten. Wenn wir also daslogout-Ereignis auslösen, kümmert sich jedes Teil um sich selbst. Es ist eine Möglichkeit, an jeden Abonnenten zu senden, dass etwas passiert ist.Ich versuche nicht unhöflich zu sein, aber hast du einen Artikel geschrieben, um grundlegende Dinge wie „*Ereignis-Emitter*“ zu erklären, der sich wahrscheinlich an Anfänger in „*TypeScript*“ richtet!?
nein, nicht unhöflich, das ist eine berechtigte Kritik. Das einzige, wofür TypeScript hier wirklich verwendet wird, ist die Annotation der Form, in der die Klasse die Ereignisse speichert, und die Anzeige, welche Methoden von dieser Klasse aus verwendet werden können. Aber angesichts des aggressiven Aufschwungs der Beliebtheit von TypeScript sollte CSS-Tricks vielleicht einen Beitrag zu TypeScript für Anfänger hinzufügen?
Schöner Artikel!
Vielleicht verpasse ich etwas, aber um es einfach zu halten und dem SRP (Single Responsibility Principle) gerecht zu werden, was halten Sie davon, nur eine Art von Ereignis pro Emitter zu verwenden?
Ich meine, die Ereignisnamen zu vermeiden und stattdessen einfach ein Array von Funktionen anstelle eines Wörterbuchs basierend auf Ereignisnamen zu speichern.
Gedanken?
Es ist großartig und sehr ähnlich wie jetemit
Link
npmjs.com/package/jetemit
Eine gute Einführung in Ereignisbusse! Würde mich freuen, eine Fortsetzung mit einigen Ihrer bevorzugten Anwendungsfälle für die Ereignis-/Pubsub-Technik zu sehen.
Da
EventTargetundEventEmitternative DOM- bzw. Node.js-Konzepte/APIs sind, würden Sie es als sinnvoll erachten, dies als Abstraktion der beiden zu überarbeiten, um die bereits vorhandenen Fähigkeiten in den Umgebungen, in denen Sie arbeiten, besser zu nutzen?