Wenn Angular auf Redux trifft

Präambel

Wir hören immer mehr von der zunehmenden Nutzung des Redux-Konzepts mit Angular (2+). Als ich nach mehreren Artikeln suchte, die mich zu diesem Thema führen, stellte ich fest, dass viel zu wenige in unserer schönen Sprache Molière zugänglich sind. Der Zweck dieses Artikels ist es daher, Ihnen Redux und seine Verwendung mit einem Framework, das nicht sein Lieblingsgebiet ist, verständlich zu machen. Wenn Sie Angular verwenden und mit diesem Konzept nicht vertraut sind, sind Sie hier genau richtig.

Voraussetzungen

Dieser Artikel richtet sich an Entwickler, die es gewohnt sind, Angular zu verwenden, ohne eine klare Vorstellung davon zu haben, was Redux ist. Konzepte wie Observables, Typescript-Grundlagen und Komponenten werden als selbstverständlich vorausgesetzt. Kenntnisse in Funktionaler Programmierung sind von Vorteil.

Einführung

Redux ist eine Architektur, die darauf abzielt, den Status Ihrer Anwendung an einem Ort zu konzentrieren. Aktionen werden von Ereignissen ausgelöst, die jedes Mal den vorherigen Zustand wieder aufnehmen, einen Teil davon ändern und einen neuen zurückgeben, der zur Verwaltung Ihrer Daten verwendet wird.
Es ist nicht ganz klar? Keine Sorge, der Zweck dieses Artikels ist es, ein scheinbar kompliziertes Konzept zu entmystifizieren, das sich als einfach und logisch herausstellt.
Bevor ich zum Kern der Sache komme, möchte ich darauf hinweisen, dass Redux, das sehr oft mit React verwendet wird, in keiner Weise von letzterem abhängig ist. Es ist höchste Zeit, dass wir die Vorteile einer sehr praktischen Architektur verstehen, die mit jedem komponentenorientierten Framework / jeder Bibliothek verwendet werden kann, und Angular ist ein perfektes Beispiel.

Welche Probleme mit Angular können durch dieses neue Anwendungsdatenmanagement gelöst werden?

Variablen, die von verschiedenen Komponenten verschiedener Ebenen gemeinsam genutzt werden

In einer Angular-Komponente sind im Allgemeinen selten Variablen enthalten readonly für etwas anderes als Servicekonstanten oder Observables. Auch die direkt in der Komponente angezeigten Variablen werden von derselben Komponente modifiziert. Ein echtes Problem kann jedoch auftreten, wenn diese Variablen als Eingabe an eine Unterkomponente übergeben und von dieser Unterkomponente geändert werden. Es ist möglich und es funktioniert, weil die Datenbindung von Angular dafür gemacht ist. Es wird jedoch sehr schwierig, es zu testen, und der Code kann Nebenwirkungen haben.
Stellen Sie sich einen Baum vor wie:

Komponente0 ├── Komponente1 └── Komponente2 └── Komponente3

Wir haben eine Variable, die den Komponenten 1 und 3 gemeinsam sein soll. Also müssen wir diese Variable als Eigenschaft von Component0 deklarieren, nur damit sie sowohl in Component2 als auch in Component3 aktualisiert wird. Wir müssen eine Ausgabe in Komponente 1 einfügen, wenn wir die Variable nach einem Ereignis ändern möchten. Und machen Sie dasselbe, nicht nur in Komponente3, sondern auch in Komponente2, um mit Komponente0 hochzufahren EventEmitter.

Viel Code, um einfach eine Variable zu ändern, die an zwei Stellen auf derselben Seite verwendet wird.

Angular bietet natürlich bereits Lösungen, die in diesem Fall genutzt werden könnten. Eine davon ist die Verwendung von Services, in denen die gemeinsame Variable gespeichert werden kann. Soll sie an beide Komponenten gebunden sein – also in Komponente 1 in Echtzeit aktualisiert werden, sobald sie sich in Komponente 3 ändert –, müssten wir reaktive Programmierparadigmen, insbesondere Observables mit RxJS , verwenden . Das ist machbar, und viele Anwendungen funktionieren so, aber die Wartung kann schwierig werden, wenn dieses Muster regelmäßig an mehreren Stellen in der Anwendung wiederholt wird. Dennoch sollte diese Idee nicht völlig verworfen werden, da sie eine Grundlage für unsere Lösung bildet.

Gebundene Daten sind sehr volatil und das Debuggen kann kompliziert sein

Daten können sich ständig ändern, und wenn man nicht durchgehend im unveränderlichen Modus arbeitet, kann die Nachverfolgung von Änderungen an Objektwerten kompliziert werden. Auch die Verwaltung des Verlaufs von Aktionen und Ereignissen seit einem bestimmten Zeitpunkt ist schwierig. Redux bietet mit seinem „Zeitreise-Debugging“ eine Lösung für dieses Problem. Jedes Mal, wenn ein neuer Zustand erstellt wird, speichert Redux eine Kopie der vorherigen Version. Diese Information ist über die entsprechende Erweiterung in den Entwicklertools verfügbar.

Es kann schwierig sein, Code zu organisieren, der Variablen auf reine Weise ändert.

Hier gehen wir wirklich auf einen Teil der Erklärung der funktionalen Programmierung ein, aber eine Lösung, die dieses Paradigma bietet, ist das Konzept der „reinen Funktion“. Eine reine Funktion hat ein oder mehrere Argumente, ändert keine Variable außerhalb ihres Gültigkeitsbereichs und gibt einen Wert zurück, der immer gleich ist, mit denselben Argumenten. Es ist ganz einfach in der Theorie, und nicht immer in der Praxis.
Wenn Sie tiefer in die Erklärung und das Verständnis des funktionalen Programmierparadigmas eintauchen möchten, kann ich Ihnen einen Artikel von nur empfehlen Yoan Ribeiro zum Thema.
Darüber hinaus führt uns die Verwendung von Angular oft dazu, OOP anstelle von funktionaler Programmierung durchzuführen, in dem Sinne, dass wir die Daten der Komponente (die eine Klasse ist) ändern, die per Definition außerhalb der Methoden der Erstellung liegen.

Ein bisschen Theorie vor der Praxis

Nachdem wir nun einige Punkte angesprochen haben, die der Entwicklung mit Angular innewohnen und die mit Redux vereinfacht werden können, werden wir versuchen, das Muster gut zu verstehen, indem wir eine einfache Redux-Anwendung entwerfen.

Manchmal sehen Sie Store, manchmal State. Die beiden Wörter umfassen global die gleiche Bedeutung, nämlich den Zustand der Anwendung und ihre Datenstruktur.

Dieses Schema ist sehr einfach. Von unserer Komponente gehen wir eine Aktion senden. Wir können dies durch die einfache Tatsache übersetzen, einen Aufruf zu senden, ein Ereignis, das selbst eine Funktion aufruft.
Dann wird diese Aktion auf a angewendet Reduzierstück wer wird handeln Zustand der App. Letztendlich ist es derselbe Zustand, der von der/den Komponente(n) in der Benutzeroberfläche gelesen wird.
In zwei Sätzen und einem einfachen Diagramm könnten wir das Konzept ganz einfach zusammenfassen. Aber offensichtlich spielen die Aktionen, die Reduzierer und der Staat alle eine Rolle. Und obwohl es wie eine Menge Code und Funktionen erscheinen mag, um kleine Dinge zu tun, haben sie alle eine große Bedeutung.
Wie man sieht, wird der Fluss immer sein müssen unidirektional, und dies ist ein wichtiger Punkt, der als Eckpfeiler unserer Architektur dienen wird. Benutzeraktionen durchlaufen die Komponente, die Aktionen auslöst. Diese Aktionen werden an Reducer gesendet, unsere reinen Funktionen, die einen neuen Zustand für die Anwendung zurückgeben.
Zuvor sprachen wir über Bedenken hinsichtlich Nebenwirkungen beim Ändern von Variablen, die von mehreren Komponenten gemeinsam genutzt werden. Redux bietet auf dieser Ebene eine echte Lösung, indem es sich auf das Konzept der funktionalen Programmierung und nicht auf die Objektorientierung konzentriert. Eine Minimierung von Randeffekten, jede Komponente, was auch immer sie ist, wirkt nur auf den Zustand ein, der für sie notwendig ist. Wir werden eine Aktion zur Aktualisierung einer Eigenschaft des Staates versenden. An jeder Stelle, an der diese Eigenschaft in Form einer Variablen in einer Komponente verwendet wird, wird sie dann gebunden und daher geändert, alles in einer bestimmten Aktion, die nur diese Eigenschaft geändert hat.

Beispiel für einen Reduzierer

const reducer: Reducer<AppState> = (state: AppState, action: Action) => {
  switch (action.type) {
    case "IS_LOADING":
        return {
            ...state,
            isLoading: action.payload
        };
    default:
        return state;
  }
};

Platz machen für die Praxis

Hier ist ein etwas komplizierteres Diagramm, das einen realen Anwendungsfall im Rahmen der Entwicklung einer Anwendung übersetzt.

NDD: Eine Nutzlast ist der Name einer Variablen beliebigen Typs, die an die Aktion gesendet und vom Reduzierer verwendet wird.

Nehmen wir einen Fall, in dem wir eine Anwendung erstellen, die wie ein Blog aussieht, sich aber mit Manga befasst (um Artikel zu ändern).
Wir werden eine Komponente haben, die die Daten eines Mangas über seine ID (in der URL vorhanden) anzeigt. Stellen wir uns vor, dass der Titel des Mangas, sein Autor und die Anzahl der Bände geladen werden, wenn die Seite angezeigt wird, aber nicht seine Beschreibung. Da dieser sehr lang ist, müssen Sie auf eine Schaltfläche klicken, um ihn anzuzeigen und somit zu laden.
Wenn auf diese Schaltfläche geklickt wird, passieren mehrere Dinge. Unsere Komponente sendet eine Aktion, die die Beschreibung des Mangas über die Funktion lädt LoadMangaDescription(). Diese Funktion wird vor allem die Aktion versenden BESCHREIBUNG_IS_LOADING mit einem Nutzlast auf wahr. Wir möchten dem Benutzer zeigen, dass die Informationen geladen werden. Wir können zum Beispiel, solange dieser Wert wahr ist, einen Spinner anzeigen. Die Komponente wird daher diesen Wert aus dem State abrufen, der von dem von der Aktion aufgerufenen Reducer aktualisiert wurde BESCHREIBUNG_IS_LOADING.
Sobald wir unseren Status auf völlig synchrone Weise aktualisiert haben, um zu sagen, dass die Beschreibung geladen wird, können wir weitermachen. Die Komponente hat diese Aktion nicht nur abgesetzt, sondern auch einen Dienst aufgerufen, der die Beschreibung über einen HTTP-Aufruf abruft. Wer hier HTTP sagt, sagt asynchron, also wissen wir nicht, wann die Antwort eintrifft. Aus diesem Grund werden normalerweise Spinner angezeigt, wie in diesem Beispiel.
Wenn die Anfrage zurückkehrt, gibt der Dienst das Ergebnis an die Komponente zurück, die schließlich in der Lage sein wird, die folgenden zwei Aktionen zu starten (durch Verbindung mit dem Ergebnis per Promise oder Subscription). Die Komponente kann daher die Aktion erneut versenden BESCHREIBUNG_IS_LOADING diesmal mit einer Nutzlast bei falsch.
Aber das ist nicht alles. Das Ziel unserer Operation ist es, aus der Komponente die Beschreibung des Mangas abzurufen. Es ist in Ordnung, während des Ladens einen Spinner einzuschalten und ihn zu entfernen, sobald die Daten empfangen wurden, aber diese Daten müssten trotzdem verwendet werden. Dazu senden wir eine zweite Aktion nach der ersten, die das Laden auf false setzt, was sein wird LADEN_DESCRIPTION. Diese Aktion enthält in der Nutzlast die Daten, die wir an den Reduzierer senden möchten, und finden sie dann im Zustand. Es sind diese Daten, die dann auf der Benutzeroberfläche angezeigt werden, die der Komponente bereitgestellt wird.
Den Produktcode für dieses Beispiel finden Sie hier Github-Repository um ein tieferes Verständnis für die Verwendung von Redux jenseits von Theorie und Schaltplänen zu erlangen. Es ist relativ einfach und entspricht einem NgRedux-Startup, mit vor allem den Essentials, damit die Architektur funktioniert.

Die Epen

Ein weiteres Konzept von Redux ist das von Epics. Im vorherigen Fall möchten wir beispielsweise nicht verpflichtet sein, festzulegen, dass wir nach dem Aufruf des Dienstes und dem Absenden der Aktion auch wollen, dass der Loader durch das Absenden einer neuen Aktion verschwindet. Wenn wir die Aktion, die die Daten füllt, an mehrere Stellen senden müssten, müssten wir den Code duplizieren, damit der Loader verschwindet. Hier kommen die Epen ins Spiel. Ein Epic ist eine Funktion, die von einem Reducer ausgelöst wird und die einen Aktionsstrom ausführt, um einen anderen Aktionsstrom zurückzugeben. In unserem Fall würde es genügen zu sagen, dass jedes Mal die Aktion LADEN_DESCRIPTION versendet wird, wollen wir auch die Aktion versenden BESCHREIBUNG_IS_LOADING wobei die Payload auf false gesetzt ist. Einfach und effizient, keine Codeduplizierung oder zusätzliche Fälle zu verwalten.
Weitere Informationen zum Konzept von Epics und ihren Anwendungsfällen:
- https://medium.com/kevin-salters-blog/epic-middleware-in-redux-e4385b6ff7c6
- https://redux-observable.js.org/docs/basics/Epics.html

Angular-Redux

Bis dahin bleibt noch eine Frage offen. Wir wissen, wie wir Ereignisse auf der Komponente abhören müssen, um unsere Aktionen auszulösen. Wir wissen, wie wir einen Dienst daran anschließen, wenn wir asynchrone Daten benötigen, und wie wir unsere Aktionen effizient an Reduzierer senden, damit Zustandsänderungen von Daten nicht kollidieren. Abgesehen davon, dass, nachdem wir unseren Status aktualisiert haben, das Abrufen der Elemente immer noch etwas unklar ist.
Woher wissen Sie, wann eine Variable ihren Wert geändert hat? Und wie nutzt man diesen neuen Wert in unserer Komponente?
Bibliotheken lieben es, solche Fragen zu beantworten Angular-Redux. Dies ist eine Middleware, die es uns ermöglicht, statisch oder dynamisch zu agieren und Elemente aus unserem Zustand abzurufen. Hier ist Beispielcode, um unsere Manga-Beschreibung dynamisch abzurufen.

import { select } from '@angular-redux/store';
import { Observable } from 'rxjs/Observable';
import { MangaState } from '../store/state';
@Component({
    selector: 'app-manga',
})
export class MangaComponent {
    @select(['manga']) readonly manga: Observable;
    constructor() {}
}

Entnommen aus dem Code aus dem Repository oben und vereinfacht, ermöglicht uns dieser Code einen Überblick über das Abrufen unserer Daten aus dem Store.
Das erste Schlüsselwort in der Zeile ist die @auswählen Dies ist ein Dekorateur, der von angle-redux bereitgestellt wird und der es uns ermöglicht, unser Manga-Element aus dem Store abzurufen und es zu einem Observable zu machen. Was der Typ unserer Variablen entspricht, wobei als Eingabe die MangaState-Schnittstelle verwendet wird, die dem Typ unseres Objekts entspricht.
Von dort aus ist es einfach, die Daten in HTML anzuzeigen. Es muss jedoch eine kleine Änderung vorgenommen werden, um das Observable zu verwalten.

<div *ngIf="manga | async; let manga">
    Nom du manga : {{ manga.name }}
    Auteur du manga : {{ manga.author }}
    <div>LOADING DESCRIPTION</div>
    <div>{{ manga.description }}</div>
</div>

Wie Sie sehen, muss das Observable asynchron verarbeitet werden. Es handelt sich um einen Datenstrom, der zu jedem Zeitpunkt feste Werte enthalten kann. Um diese Werte im HTML anzuzeigen, benötigen Sie jedoch eine Async- Pipe , die automatisch den letzten Wert abruft. Alternativ können Sie nach der Pipe eine lokale Variable erstellen, sodass Sie nicht für jeden anzuzeigenden Wert eine Async-Pipe benötigen (was zwar möglich wäre). Daher zeigt ` {{ manga.name }}` tatsächlich die `name`-Eigenschaft der erstellten lokalen Variable `manga` an und nicht die Variable des Observables selbst.

Feedback aus Erfahrung

Nachdem ich Angular-Anwendungen von Grund auf mit und ohne Redux entwickelt habe, kann ich sagen, dass man etwas Übung braucht, um „Redux-Denken“ zu entwickeln, aber es geht recht schnell. Ähnlich wie beim Wechsel von einer objektorientierten zu einer funktionalen Sprache muss man sich an eine andere Art des Programmierens gewöhnen. Die Mühe lohnt sich aber definitiv, und die Kombination aus Redux, Typisierung und Tests ist ein Erfolgsrezept für eine extrem robuste, leicht zu debuggende und wartungsfreundliche Anwendung . Die Implementierung dauert natürlich länger, da ein Store sorgfältig entworfen werden muss, um die spezifischen Anforderungen der Anwendung zu erfüllen. Die Trennung von Daten auf verschiedene Reducer kann ebenfalls ein Schlüsselfaktor für die Wartung der Anwendung sein, je nachdem, wie diese Reducer von verschiedenen Seiten wiederverwendet werden. Redux ist eine offene Architektur, in die man sogar auf die eigenen Bedürfnisse zugeschnittene Muster integrieren kann, beispielsweise den Ersatz von Epics durch ein Dispatcher-System, das die Verteilung von Aktionen übernimmt.

Fazit

Wir sehen, dass eine Architektur wie Redux mit jeder Art von Anwendung zusammenarbeiten kann. Sie müssen nur die Nützlichkeit des Musters für die Verwaltung von Daten und den globalen Status Ihrer Anwendung vollständig verstehen.
Sie müssen nur an Extras denken, um es in Angular zu integrieren, z. B. Angular-Redux, um die Bindung zwischen Komponentenvariablen und dem globalen Zustand zu verwalten.
Die Verwendung von Typescript bekommt seine volle Bedeutung, insbesondere über die Typisierung des Stores und der darin enthaltenen Daten, die in den Reducern sowie den Actions und sogar den Observables in den Components zu finden sind (siehe Code aus dem Repository). Wir haben daher auf alle Codes eine Sicherheit durch das Tippen gebracht Fehler möglichst vermeiden.
Ein weiterer wichtiger Punkt: Redux ist keine Zauberei. Natürlich müssen Sie sich darüber im Klaren sein, dass Redux nichts anderes ist als eine Architektur, die Ihrer Anwendung hinzugefügt wird, aber es beseitigt nicht auf magische Weise alle Musterprobleme, die Ihre Anwendung möglicherweise bereits hat. Auch wenn die Bibliothek eine starke Lösung für die Verwaltung von Anwendungsdaten bietet, bleibt die Tatsache bestehen, dass ihre Implementierung Zeit erfordert. Wir müssen sorgfältig über die Struktur der Daten und ihre Normalisierung nachdenken, da dies einen erheblichen Einfluss auf die Architektur der Aktionen, des Speichers und der Reducer haben wird. Sie müssen sich die Zeit nehmen, diese Daten sowie die Funktionen, die sie ändern, einzugeben und zu testen, damit eine Anwendung gesund, funktionsfähig und ohne Überraschungen bleibt.
geschrieben von: William Barranco