Erfolgreich mit RxJS Teil 1 – Ohne den Verstand zu verlieren
Janis Krasemann
10 Minuten
Wer eine moderne, erfolgreiche Webanwendung inklusive asynchroner Kommunikation mit einem Backend bauen möchte, kommt an RxJS eigentlich nicht vorbei. Wenn dabei die Wahl des Frontend Frameworks auf Angular fällt, ist RxJS sogar automatisch gezwungenermaßen Teil der Anwendung, da viele zentrale Features von Angular (wie z.B. der HttpClient) RxJS verwenden.
Wer sich schon einmal eingehender mit dem Thema auseinander gesetzt hat, weiß: RxJS ist komplex. Sehr komplex. Ein bisschen vergleichbar mit regulären Ausdrücken: Sehr mächtig, und die Funktionalität "von Hand" nachzubauen weil man das Werkzeug nicht beherrscht ist ungleich aufwändiger. Entwickler:innen kennen oft nur die absoluten Basics, weswegen sie komplexe, asynchrone Abläufe oft suboptimal umsetzen, indem mehrfach ineinander verschachtelte subscribe Aufrufe verwendet werden:
// User-Daten laden
fetchUser$().subscribe((user) => {
let cart, favorites;
// Warenkorb des aktuellen Users laden
fetchShoppingCart$(user.id).subscribe((data) => {
// wenn die Favoriten bereits geladen sind, zeige Erfolgsmeldung an
if (favorites) {
displaySuccessMessage(user, data, favorites);
}
cart = data;
});
// Favoriten des aktuellen Users laden
fetchFavorites$(user.id).subscribe((data) => {
// wenn der Warenkorb geladen ist, zeige jetzt endlich die Erfolgsmeldung an...?
if (cart) {
displaySuccessMessage(user, cart, data);
}
favorites = data;
});
});
Im Beispiel wird zunächst der aktuelle User geladen, anschließend der Warenkorb und die Favoriten für die erhaltene User-ID. Da es sich bei allen Operationen um asynchrone REST-Aufrufe handelt, geben die jeweiligen Funktionsaufrufe ein Observable zurück. Im Codebeispiel soll außerdem eine Erfolgsmeldung angezeigt werden, sobald alle Daten vorliegen – in diesem Fall gelöst über zwei lokale Variablen, die dann jeweils im inneren subscribe
abgefragt werden. Dadurch wird der Code eher unleserlich, und es ist vielleicht auch nicht 100%ig klar ob es nicht Fälle geben könnte in denen displaySuccessMessage
gar nicht aufgerufen wird.
Observables bzw. Funktionen, die Observable zurückgeben, sind oft zu erkennen an dem $
am Ende des Funktions- oder Variablennamen. Das ist aber allerhöchstens eine Konvention und muss demnach nicht immer so sein. In meinen Codebeispielen sind Observables wie beschrieben am $
zu erkennen.
Eine weitere Herausforderung: Gefühlt existieren für jeden Use Case mindestens drei mögliche Operatoren die dann mehr oder weniger das gleiche tun – Stackoverflow ist voll von Fragen wie “rxjs combinelatest vs forkjoin vs merge difference”. Hier kann man schnell den Überblick verlieren – mir selbst ist das in der Vergangenheit einigermaßen häufig passiert. Dagegen hilft dieser Blog Post, der eine Übersicht über die wichtigsten RxJS Operatoren gibt und warum man diese verwenden kann oder sogar sollte. Hierbei unterscheide ich zwischen:
- Must-Have Operatoren: solche die man sofort und idealerweise ohne Blick in die Doku einsetzen können sollte
- Must-Know Operatoren: solche die man kennen sollte, die aber nicht so oft zur Anwendung kommen, dass man dafür nicht mal eben in der Doku nachlesen kann
Grundlagen
RxJS wird auf der offziellen Dokumentationsseite als “Lodash für Events” beschrieben. Mit anderen Worten, ist RxJS eine Bibliothek zum Entwickeln asynchroner, Event-basierter Programme. Der Kern von RxJS ist das Observable, also ein “überwachbares” Objekt, welches eine Sequenz von Werten ausgeben kann. Sogenannte Observer reagieren auf neue Werte der besagten Sequenz. Diese können mithilfe von Operatoren, welche teilweise von Array-Methoden inspiriert sind, weiter manipuliert werden.
Subscription
Um eine Aktion auszuführen wenn ein Observable einen Wert ausgibt, wird eine Subscription benötigt. Hierbei wird auf dem Observable die subscribe
-Funktion aufgerufen, der gelieferte Callback gibt die Implementierung der Subscription an; heutzutage wird so ein Callback meist als Arrow-Function umgesetzt. Immer wenn die Observable-Sequenz einen Wert ausgibt, erhält jede Subscription diesen Wert und führt den übergebenen Callback aus. Optional können auch Callbacks für Fehlerfälle und die Completion des Observables angegeben werden. Completion bedeutet, dass das Observable "fertig" ist und keine weiteren Werte mehr ausgeben wird. Ein Observable beendet entweder mit dem Fehlerzustand oder wenn es complete ist.
Operator
RxJS unterscheidet zwischen Pipeable Operators und Creation Operators. Pipeable Operators sind pure Funktionen, die ein Observable als Input erhalten und auch wieder ein Observable als Output herausgeben. Sie werden beispielsweise verwendet, um Werte herauszufiltern, zu modifizieren oder Seiteneffekte auszuführen.
Dafür wird die pipe
-Funktion eines Observables ausgeführt, in das dann ein oder mehrere Operatoren hineingegeben werden. Ähnlich der Pipe in Unix Betriebssystemen können mehrere Operatoren hintereinander geschaltet werden, um die herausgegebenen Werte des Observables, auf das pipe
aufgerufen wurde, nach und nach weiter zu modifizieren. Pipeable Operators spielen eine große Rolle bei der Verwendung von APIs großer Bibliotheken wie NGRX, NGXS und natürlich Angular.
Creation Operators hingegen werden verwendet, um ein Observable zu erzeugen. Wir werden sowohl Operatoren besprochen, welche Observables aus nicht-Observables erzeugen, als auch solche, welche aus einem oder mehreren Observables ein neues Observable kombinieren.
Must-Have Pipeable Operators
Hier kommen die Operatoren die alle Entwickler:innen mit RxJS-Bezug kennen und ohne Probleme anwenden können sollten:
map / mapTo
map
funktioniert so wie man es auch von der gleichnamigen Array-Funktion erwartet: Es nimmt einen Wert aus der Sequenz des Observables entgegen und gibt einen neue Wert zurück (mit dem ein folgender Operator dann weiterarbeiten kann).
this.httpClient
.put(baseUrl + '/shopping-cart', {
id: 4711,
amount: 2,
})
.pipe(map((response) => response.statusCode === 201))
.subscribe((success) => {
if (success) {
console.log('Der Warenkorb wurde erfolgreich aktualisiert!');
} else {
console.log('Das hat leider nicht geklappt');
}
});
Dieses Beispiel aktualisiert den Warenkorb mit dem Produkt mit ID 4711; das Produkt wurde zwei Mal in den Warenkorb gelegt. Anschließend wird aus der Response der Status ausgelesen. Wir erwarten in diesem Fall einen Status Code 201, ansonsten ist etwas schief gegangen. Hier hätte man sich den map
Aufruf auch theoretisch sparen können, bei komplexeren Operationen die nicht nur ein Property auslesen ist ein map
Aufruf aber immer zu bevorzugen, da es expliziter ist und deutlich macht, dass der Rest des Objekts (bzw. in diesem Fall der Response) für den weiteren Verlauf nicht mehr benötigt wird.
Manchmal interessiert uns der eingehende Wert gar nicht. Man kann den Parameter von map
dann theoretisch einfach ignorieren, sprechender ist in so einem Fall aber, wenn man stattdessen mapTo
verwendet.
tap
Um innerhalb der Observable-Kette einen Seiteneffekt auszuführen, kann tap
verwendet werden. In diesem Codebeispiel wird via tap
der Request gecached:
const url = `${baseUrl}/shopping-cart/${shoppingCartId}`;
if (this.cacheService.has(url)) {
return this.cacheService.get(url);
}
this.httpClient
.get(url)
.pipe(
tap((response) => this.cacheService.put(url, response)),
map((response) => response.data.shoppingCart)
)
.subscribe((shoppingCart) =>
console.log(`Das ist gerade im Warenkorb: ${shoppingCart}`)
);
tap
ist auch zum Debuggen ab und an sehr nützlich, da man bei komplexen asynchronen Abläufen nicht immer ausschließlich mit Breakpoints arbeiten kann (ich finde da verliert man schnell den Überblick was wann passiert). Ein einfaches tap(console.log)
vor oder nach einem Operator kann dann Abhilfe schaffen.
mergeMap
Im Gegensatz zu tap
und map
, welche meiner Meinung nach relativ intuitiv anwendbar sind, ist mergeMap
ein Operator, mit dessen Anwendung viele Entwickler*:innen Probleme haben. Das liegt wahrscheinlich daran, dass im mentalen Modell oft das Vorhaben lautet "ich möchte den Wert des Observables auf den Wert eines anderen Observables mappen" - also wird map
verwendet.
Stellen wir uns aus dem Beispiel oben also vor, es soll anschließend der aktualisierte Warenkorb geladen und angezeigt werden. Man will ja den Wert aus dem initialen Observable auf einen Wert eines weiteren Requests mappen, also könnte ein erster Entwurf wie folgt aussehen:
const fetchShoppingCart$ = (shoppingCartId) =>
this.httpClient.get(
`${baseUrl}/shopping-cart/${response.data.shoppingCartId}`
);
this.httpClient
.put(baseUrl + '/shopping-cart', {
id: 4711,
amount: 2,
})
.pipe(map((response) => fetchShoppingCart$(response.data.shoppingCartId)))
.subscribe((shoppingCart) => {
console.log(`Das ist gerade im Warenkorb: ${shoppingCart}`);
});
Das Problem: shoppingCart
ist dann nicht - wie vielleicht zunächst erwartet - das Objekt, das direkt den Warenkorb repräsentiert, sondern ein Observable, welches einen Warenkorb ausgibt. Hier ist nochmal ein bisschen Terminologie wichtig:
- das Observable, welches durch
httpClient.put
erzeug wird, ist ein outer Observable - das Observable aus
fetchShoppingCart$()
ist ein inner Observable, also ein Observable, welches innerhalb der Pipe in einem Operator zurückgegeben wird - das daraus entstehende Observable (d.h. das, worauf anschließend
subscribe
ausgeführt wird) ist ein sogenanntes Higher Order Observable, da es wiederum selbst ein Observable zurück gibt
Da wir aber eigentlich mit dem eigentlichen Wert aus dem fetchShoppingCart$()
-Aufruf weiter arbeiten wollen (wie das console.log
ja auch schon suggeriert), ist map
nicht der richtige Operator. Um den Wert des inneren Observables zu erhalten, kommt deshalb stattdessen mergeMap
zum Einsatz. Man spricht hier auch von flattening (zu deutsch etwa "flach klopfen"). Der Rest des Codes bleibt gleich:
this.httpClient
.put(baseUrl + '/shopping-cart', {
id: 4711,
amount: 2,
})
.pipe(
mergeMap((response) =>
this.httpClient.get(
`${baseUrl}/shopping-cart/${response.data.shoppingCartId}`
)
)
)
.subscribe((shoppingCart) => {
console.log(`Das ist gerade im Warenkorb: ${shoppingCart}`);
});
Wenn ihr eine einzige Information aus diesem Artikel mitnehmt, dann am besten diesen Unterschied zwischen map
und mergeMap
. Dazu noch eine Faustregel: Wenn im Callback von map
ein weiteres inneres Observable zurückgegeben wird, sollte in den meisten Fällen stattdessen mergeMap
angewendet werden.
Weitere Ressourcen
Wie angesprochen, muss man bei der RxJs-Programmierung oft mal in die Dokumentation schauen. Folgende Ressourcen haben mir in meiner täglichen Arbeit dabei geholfen:
Offizielle Dokumentation
Die offizielle RxJS-Dokumentation bietet einen Überblick über die zentralen Konzepte sowie selbstverständlich eine Referenz über alle existierenden Operatoren. Desweiteren finden sich hier natürlich ein Getting Started sowie Installationshinweise und Informationen über Breaking Changes in der API bei Versionssprüngen. Die Referenz der Operatoren kann an manchen Stellen etwas unübersichtlich sein, weswegen ich meistens auf andere Ressourcen zurückgreife.
Learn RxJS
Die Seite Learn RxJS von Brian Troncone ist meine erste Anlaufstelle, wenn ich nachlesen will, was ein bestimmter Operator tut oder den richtigen Operator für meinen Use Case suche. Die Aufteilung der Operatoren nach Kategorie sowie die Markierung der meistgenutzten Operatoren mit einem Stern sind extrem hilfreich. Hinweise bei einzelnen Operatoren verweisen zudem auf verwandte Operatoren und geben Tipps, wann diese evtl. die bessere Alternative wären.
RxMarbles
Marble-Diagramme sind die perfekte Visualisierungsart für RxJS-Operatoren. RxMarbles nutzt diesen Diagrammtyp für interaktive, animierte Erklärungen aller Operatoren. Auf die eigentliche Dokumentation der Operatoren wird hier verzichtet, die muss man dann weiterhin von anderen Quellen beziehen.
Rx Visualizer
Wenn die einzelnen Beispiele aus RxMarbles nicht reichen, kann man via Rx Visualizer den Verlauf eines angegebenen Observables visualisieren. Kann sehr nützlich sein, um ein vorliegendes oder in der Entwicklung befindliches Observable besser zu verstehen. Oft muss der Code hierfür aber noch deutlich angepasst werden, z.B. indem zusätzliche Delays eingebaut werden.
Code-Kommentare
Hiermit sind die eigenen Code-Kommentare gemeint. Ist der richtige Operator gefunden und der RxJS-Code geschrieben, kann sehr sauberer Code enstehen. Durch die hohe Einstiegshürde bei RxJS und die vielen verschiedenen Operatoren haben Entwickler:innen aber oft Probleme, diesen Code zu lesen, geschweige denn zu warten. Wenn ihr die Kommentare nicht für die Junior-Entwickler:innen in eurem Team schreibt, dann schreibt sie wenigstens für euer Zukunfts-Ich! (Das explizite Aufschreiben der Intention hilft außerdem oft, eigene Fehler zu finden.)
Abschluss
Ich hoffe, ich konnte ein bisschen Klarheit bezüglich der Grundlagen von RxJs und der ersten besprochenen Operatoren geben. mergeMap
war hier sicherlich der wichtigste und komplizierteste. Im Laufe der nächsten Wochen wird es noch einen zweiten Teil geben, in dem noch einige weitere wichtige Operatoren näher beleuchtet werden.
Hier geht es weiter zu "Erfolgreich mit RxJS Teil 2".
Diesen Artikel teilen über: