Reaktive Anwendung ohne Redux / NgRx



Heute werden wir eine reaktive Winkelanwendung ( Github-Repository ) im Detail analysieren, die vollständig auf der OnPush- Strategie basiert . Eine andere Anwendung verwendet reaktive Formulare, was für eine Unternehmensanwendung recht typisch ist.

Wir werden Flux, Redux, NgRx nicht verwenden und stattdessen die Funktionen nutzen, die bereits in Typescript, Angular und RxJS verfügbar sind. Tatsache ist, dass diese Tools keine Wunderwaffe sind und selbst einfachen Anwendungen unnötige Komplexität verleihen können. Wir werden ehrlich von einem der Autoren von Flux , dem Autor von Redux und dem Autor von NgRx gewarnt .

Diese Tools bieten unseren Anwendungen jedoch sehr schöne Funktionen:

  • Vorhersagbarer Datenfluss;
  • Unterstützung von OnPush durch Design;
  • Die Unveränderlichkeit der Daten, das Fehlen akkumulierter Nebenwirkungen und andere angenehme Dinge.

Wir werden versuchen, die gleichen Eigenschaften zu erzielen, ohne jedoch zusätzliche Komplexität einzuführen.

Wie Sie am Ende des Artikels sehen werden, ist dies eine ziemlich einfache Aufgabe. Wenn Sie die Details von Angular und OnPush aus dem Artikel entfernen, gibt es nur wenige einfache Ideen.

Der Artikel bietet kein neues universelles Muster, sondern teilt dem Leser nur einige Ideen mit, die bei aller Einfachheit aus irgendeinem Grund nicht sofort in den Sinn kamen. Die entwickelte Lösung widerspricht oder ersetzt auch nicht Flux / Redux / NgRx. Sie können angeschlossen werden, wenn dies wirklich notwendig ist .

Für ein bequemes Lesen des Artikels ist ein Verständnis der Begriffe Smart, Presentation und Container Components erforderlich.

Aktionsplan


Die Logik der Anwendung sowie die Reihenfolge der Präsentation des Materials können in Form der folgenden Schritte beschrieben werden:

  1. Separate Daten zum Lesen (GET) und Schreiben (PUT / POST)
  2. Ladezustand als Stream in Containerkomponente
  3. Verteilen Sie den Status an eine Hierarchie von OnPush-Komponenten
  4. Benachrichtigen Sie Angular über Komponentenänderungen
  5. Eingekapselte Datenbearbeitung

Um OnPush zu implementieren, müssen alle Möglichkeiten zum Ausführen der Änderungserkennung in Angular analysiert werden. Es gibt nur vier solcher Methoden, und wir werden sie im gesamten Artikel nacheinander betrachten.

Also lass uns gehen.

Teilen Sie Daten zum Lesen und Schreiben


In der Regel verwenden Frontend- und Backend-Anwendungen typisierte Verträge (ansonsten warum überhaupt Typoskript?).

Das Demo-Projekt, das wir in Betracht ziehen, hat kein echtes Backend, enthält jedoch eine vorbereitete Beschreibungsdatei swagger.json . Darauf basierend werden vom Dienstprogramm sw2dts Typoskriptverträge generiert.

Generierte Verträge haben zwei wichtige Eigenschaften.

Zum einen werden Lesen und Schreiben mit unterschiedlichen Verträgen durchgeführt. Wir verwenden eine kleine Konvention und beziehen uns auf Leseverträge mit dem Suffix „State“ und schreiben Verträge mit dem Suffix „Model“.

Indem wir die Verträge auf diese Weise trennen, teilen wir den Datenfluss in der Anwendung. Von oben nach unten wird ein schreibgeschützter Zustand durch die Komponentenhierarchie weitergegeben. Um die Daten zu ändern, wird ein Modell erstellt, das anfänglich mit Daten aus dem Status gefüllt ist, aber als separates Objekt vorhanden ist. Am Ende der Bearbeitung wird das Modell als Befehl an das Backend gesendet.

Der zweite wichtige Punkt ist, dass alle Statusfelder mit einem schreibgeschützten Modifikator markiert sind. So erhalten wir Immunitätsunterstützung auf Typoskript-Ebene. Jetzt können wir den Status im Code nicht versehentlich ändern oder mit [(ngModel)] daran binden. Beim Kompilieren der Anwendung im AOT-Modus wird eine Fehlermeldung angezeigt.

Ladezustand als Stream in Containerkomponente


Zum Laden und Initialisieren des Status verwenden wir normale Winkeldienste. Sie sind für die folgenden Szenarien verantwortlich:

  • Ein klassisches Beispiel ist das Laden über HttpClient unter Verwendung des ID-Parameters, den die Komponente vom Router erhält.
  • Initialisieren eines leeren Status beim Erstellen einer neuen Entität. Wenn die Felder beispielsweise Standardwerte haben oder initialisiert werden sollen, müssen Sie zusätzliche Daten vom Backend anfordern.
  • Neustart eines bereits geladenen Status, nachdem der Benutzer einen Vorgang ausgeführt hat, bei dem Daten in das Backend geändert werden.
  • Neustart des Status durch Push-Benachrichtigung, z. B. beim gemeinsamen Bearbeiten von Daten. In diesem Fall führt der Dienst den lokalen Status und den vom Backend erhaltenen Status zusammen.

In der Demo-Anwendung werden die ersten beiden Szenarien als die typischsten betrachtet. Außerdem sind diese Szenarien einfach und ermöglichen die Implementierung des Dienstes als einfache zustandslose Objekte und lassen sich nicht von der Komplexität ablenken, die in diesem Artikel nicht behandelt wird.

Ein Beispiel für einen Dienst finden Sie in der Datei some-entity.service.ts .

Es bleibt, um den Dienst über DI in der Containerkomponente und im Ladezustand zu erhalten. Dies geschieht normalerweise folgendermaßen:

route.params .pipe( pluck('id'), filter((id: any) => { return !!id; }), switchMap((id: string) => { return myFormService.get(id); }) ) .subscribe(state => { this.state = state; }); 

Bei diesem Ansatz treten jedoch zwei Probleme auf:

  • Sie müssen das erstellte Abonnement manuell abbestellen, da sonst ein Speicherverlust auftritt.
  • Wenn Sie die Komponente auf die OnPush-Strategie umstellen, reagiert sie nicht mehr auf das Laden von Daten.

Async Rohr kommt zur Rettung. Er hört direkt auf das Observable und meldet sich bei Bedarf von ihm ab. Wenn Sie eine asynchrone Pipe verwenden, löst Angular jedes Mal automatisch eine Änderungserkennung aus, wenn Observable einen neuen Wert veröffentlicht.

Ein Beispiel für die Verwendung einer asynchronen Pipe finden Sie in der Vorlage für die Komponente some-entity.component .

Im Komponentencode haben wir die wiederholte Logik in benutzerdefinierte RxJS-Operatoren entfernt, das Skript zum Erstellen eines leeren Status hinzugefügt, beide Statusquellen mit dem Zusammenführungsoperator zu einem Stream zusammengeführt und ein Formular zum Bearbeiten erstellt, das wir später erläutern werden:

 this.state$ = merge( route.params.pipe( switchIfNotEmpty("id", (requestId: string) => requestService.get(requestId) ) ), route.params.pipe( switchIfEmpty("id", () => requestService.getEmptyState()) ) ).pipe( tap(state => { this.form = new SomeEntityFormGroup(state); }) ); 

Dies ist alles, was in der Containerkomponente durchgeführt werden musste. Und wir haben im Sparschwein die erste Möglichkeit eingerichtet, die Änderungserkennung in der OnPush-Komponente aufzurufen - die asynchrone Pipe. Es wird uns mehr als einmal nützlich sein.

Verteilen Sie den Status an eine Hierarchie von OnPush-Komponenten


Wenn Sie einen komplexen Zustand anzeigen müssen, erstellen wir eine Hierarchie kleiner Komponenten - so gehen wir mit Komplexität um.

In der Regel werden Komponenten in eine der Datenhierarchie ähnliche Hierarchie unterteilt, und jede Komponente erhält über die Eingabeparameter ihre eigenen Daten, um sie in der Vorlage anzuzeigen.

Da wir alle Komponenten als OnPush implementieren werden, lassen Sie uns einen Moment abschweifen und diskutieren, was es ist und wie Angular mit OnPush-Komponenten funktioniert. Wenn Sie dieses Material bereits kennen, können Sie zum Ende des Abschnitts scrollen.

Während der Kompilierung der Anwendung generiert Angular für jede Komponente einen speziellen Klassenänderungsdetektor, der sich alle in der Komponentenvorlage verwendeten Bindungen „merkt“. Zur Laufzeit beginnt die generierte Klasse, gespeicherte Ausdrücke mit jeder Änderungserkennungsschleife zu überprüfen. Wenn die Überprüfung ergab, dass sich das Ergebnis eines Ausdrucks geändert hat, zeichnet Angular die Komponente neu.

Standardmäßig weiß Angular nichts über unsere Komponenten und kann nicht bestimmen, welche Komponenten davon betroffen sind, z. B. das gerade ausgelöste setTimeout oder eine AJAX-Anforderung, die beendet wurde. Daher ist er gezwungen, die gesamte Anwendung buchstäblich auf jedes Ereignis in der Anwendung zu überprüfen - selbst ein einfacher Fensterlauf löst wiederholt die Änderungserkennung für die gesamte Hierarchie der Anwendungskomponenten aus.

Hier liegt eine potenzielle Ursache für Leistungsprobleme - je komplexer die Komponentenvorlagen sind, desto schwieriger sind die Überprüfungen des Änderungsdetektors. Und wenn viele Komponenten vorhanden sind und häufig Überprüfungen durchgeführt werden, dauert die Änderungserkennung einige Zeit.

Was tun?

Wenn die Komponente nicht von globalen Effekten abhängt (es ist übrigens besser, Komponenten auf diese Weise zu entwerfen), wird ihr interner Zustand bestimmt durch:


Wir werden den zweiten Punkt vorerst verschieben und annehmen, dass der Status unserer Komponente nur von den Eingabeparametern abhängt.

Wenn alle Eingabeparameter der Komponente unveränderliche Objekte sind, können wir die Komponente als OnPush markieren. Vor dem Ausführen der Änderungserkennung prüft Angular, ob sich die Verknüpfungen zu den Eingabeparametern der Komponente seit der vorherigen Überprüfung geändert haben. Wenn sie sich nicht geändert haben, überspringt Angular die Änderungserkennung für die Komponente selbst und alle untergeordneten Komponenten.

Wenn wir also unsere gesamte Anwendung gemäß der OnPush-Strategie erstellen, werden wir von Anfang an eine ganze Klasse von Leistungsproblemen beseitigen.

Da der Status in unserer Anwendung bereits unveränderlich ist, werden unveränderliche Objekte auch in die Eingabeparameter von untergeordneten Komponenten übertragen. Das heißt, wir sind bereit, OnPush für untergeordnete Komponenten zu aktivieren, und diese reagieren auf Statusänderungen.
Dies sind beispielsweise die Komponenten readonly-info.component und nested-items.component

Lassen Sie uns nun sehen, wie Sie die Änderung des Status von Komponenten im OnPush-Paradigma implementieren.

Sprechen Sie mit Angular über Ihren Zustand


Präsentationsstatus - Dies sind die Parameter, die für das Erscheinungsbild der Komponente verantwortlich sind: Ladeanzeigen, Sichtbarkeitsflags von Elementen oder Zugänglichkeit für den Benutzer der einen oder anderen Aktion, die aus drei Feldern auf eine Zeile geklebt werden, vollständiger Name des Benutzers usw.

Jedes Mal, wenn sich der Präsentationsstatus einer Komponente ändert, müssen wir Angular benachrichtigen, damit die Änderungen auf der Benutzeroberfläche angezeigt werden können.

Abhängig von der Quelle des Status der Komponente gibt es verschiedene Möglichkeiten, Angular zu benachrichtigen.

Präsentationsstatus, berechnet basierend auf Eingabeparametern


Dies ist die einfachste Option. Wir setzen die Berechnungslogik für den Präsentationsstatus in den ngOnChanges-Hook. Die Änderungserkennung startet von selbst durch Ändern der @ Input-Parameter. In der Demo ist dies readonly-info.component .

 export class ReadOnlyInfoComponent implements OnChanges { @Input() public state: Backend.SomeEntityState; public traits: ReadonlyInfoTraits; public ngOnChanges(changes: { state: SimpleChange }): void { this.traits = new ReadonlyInfoTraits(changes.state.currentValue); } } 

Alles ist sehr einfach, aber es gibt einen Punkt, der beachtet werden sollte.

Wenn der Präsentationsstatus der Komponente komplex ist und insbesondere wenn einige ihrer Felder auf der Grundlage anderer berechnet werden, die auch durch die Eingabeparameter berechnet werden, legen Sie den Status der Komponente in eine separate Klasse, machen Sie sie unveränderlich und erstellen Sie ngOnChanges bei jedem Start neu. Ein Beispiel in einem Demo-Projekt ist die ReadonlyInfoComponentTraits- Klasse. Mit diesem Ansatz schützen Sie sich vor der Notwendigkeit, abhängige Daten zu synchronisieren, wenn sie sich ändern.

Gleichzeitig ist es erwägenswert: Vielleicht hat die Komponente einen schwierigen Zustand, weil sie zu viel Logik enthält. Ein typisches Beispiel ist der Versuch in einer Komponente, Darstellungen für verschiedene Benutzer anzupassen, die sehr unterschiedliche Arbeitsweisen mit dem System haben.

Native Komponentenereignisse


Für die Kommunikation zwischen Anwendungskomponenten verwenden wir Ausgabeereignisse. Dies ist auch die dritte Möglichkeit, die Änderungserkennung auszuführen. Angular geht vernünftigerweise davon aus, dass sich etwas in seinem Zustand geändert haben könnte, wenn eine Komponente ein Ereignis generiert. Daher wartet Angular auf alle Komponentenausgabeereignisse und löst die Änderungserkennung aus, wenn sie auftreten.

Im Demo-Projekt ist es vollständig synthetisch, aber ein Beispiel ist die Komponente submit-button.component , die ein formSaved- Ereignis auslöst . Die Containerkomponente abonniert dieses Ereignis und zeigt eine Warnung mit einer Benachrichtigung an.

Verwenden Sie Ausgabeereignisse für den beabsichtigten Zweck, dh erstellen Sie sie für die Kommunikation mit übergeordneten Komponenten und nicht, um die Änderungserkennung auszulösen. Andernfalls ist es wahrscheinlich, dass Sie sich nach Monaten und Jahren nicht daran erinnern, warum dieses Ereignis für niemanden hier unnötig ist, und es löschen und alles kaputt machen.

Änderungen an intelligenten Komponenten


Manchmal wird der Status einer Komponente durch eine komplexe Logik bestimmt: Asynchrones Aufrufen des Dienstes, Herstellen einer Verbindung zu einem Web-Socket, Überprüfen der Ausführung von setInterval, aber Sie wissen nie, was noch. Solche Komponenten werden als intelligente Komponenten bezeichnet.

Je weniger intelligente Komponenten in der Anwendung keine Containerkomponenten sind, desto einfacher ist es im Allgemeinen, zu leben. Aber manchmal kann man nicht ohne sie auskommen.

Die einfachste Möglichkeit, den Status einer intelligenten Komponente mit der Änderungserkennung zu verknüpfen, besteht darin, sie in eine Observable umzuwandeln und die oben bereits beschriebene asynchrone Pipe zu verwenden . Wenn die Quelle der Änderungen beispielsweise ein Serviceabruf oder ein reaktiver Formularstatus ist, handelt es sich um eine vorgefertigte Observable. Wenn der Status aus etwas Komplexerem gebildet wird, können Sie fromPromise , Websocket , Timer und Intervall aus der Zusammensetzung von RxJS verwenden. Oder generieren Sie selbst einen Stream mit Subject .

Wenn keine der Optionen geeignet ist


In Fällen, in denen keine der drei bereits untersuchten Methoden geeignet ist, haben wir immer noch eine kugelsichere Option - ChangeDetectorRef direkt verwenden. Wir sprechen über die DetectChanges- und MarkForCheck-Methoden dieser Klasse.

Eine umfassende Dokumentation beantwortet alle Fragen, sodass wir uns nicht mit ihrer Arbeit befassen werden. Beachten Sie jedoch, dass die Verwendung von ChangeDetectorRef auf Fälle beschränkt sein sollte, in denen Sie klar verstehen, was Sie tun, da dies immer noch die interne Angular-Küche ist.

Für die ganze Zeit haben wir nur wenige Fälle gefunden, in denen diese Methode erforderlich sein könnte:

  1. Manuelle Arbeit mit Änderungserkennung - wird bei der Implementierung von Komponenten auf niedriger Ebene verwendet und ist nur der Fall, „Sie verstehen klar, was Sie tun“.
  2. Komplexe Beziehungen zwischen Komponenten - zum Beispiel, wenn Sie eine Verknüpfung zu einer Komponente in einer Vorlage erstellen und diese als Parameter an eine andere Komponente übergeben müssen, die sich höher in der Hierarchie oder sogar in einem anderen Zweig der Komponentenhierarchie befindet. Klingt kompliziert? So ist es. Und es ist besser, solchen Code einfach umzugestalten, da dies nicht nur bei der Erkennung von Änderungen zu Schmerzen führt.
  3. Die Besonderheiten des Verhaltens von Angular selbst - Wenn Sie beispielsweise einen benutzerdefinierten ControlValueAccessor implementieren , kann es vorkommen, dass der Steuerwert von Angular asynchron geändert wird und die Änderungen nicht auf den gewünschten Änderungserkennungszyklus angewendet werden.

Als Beispiele für die Verwendung in der Demoanwendung gibt es die Basisklasse OnPushControlValueAccessor , die das im letzten Absatz beschriebene Problem löst. Ebenfalls im Projekt gibt es einen Erben dieser Klasse - eine benutzerdefinierte Optionsfeldkomponente .

Jetzt haben wir alle vier Möglichkeiten zum Ausführen von Änderungserkennungs- und OnPush-Implementierungsoptionen für alle drei Komponententypen erläutert: Container, Smart, Präsentationsoptionen. Wir kommen zum letzten Punkt - der Bearbeitung von Daten mit reaktiven Formularen.

Eingekapselte Datenbearbeitung


Reaktive Formen haben eine Reihe von Einschränkungen, aber dies ist immer noch eines der besten Dinge, die im Angular-Ökosystem passiert sind.

Zuallererst verkörpern sie die gute Zusammenarbeit mit dem Staat und bieten alle notwendigen Werkzeuge, um reaktiv auf Veränderungen zu reagieren.

Tatsächlich ist das reaktive Formular eine Art Mini-Store, der die Arbeit mit dem Status zusammenfasst: Daten und Status deaktiviert / gültig / ausstehend.

Es bleibt uns überlassen, diese Kapselung so weit wie möglich zu unterstützen und zu vermeiden, dass Präsentationslogik und Logik der Form vermischt werden.

In der Demo-Anwendung sehen Sie einzelne Formularklassen , die die Besonderheiten ihrer Arbeit zusammenfassen: Validierung, Erstellen untergeordneter Formgruppen, Arbeiten mit dem deaktivierten Status von Eingabefeldern.

Wir erstellen das Stammformular in der Containerkomponente zum Zeitpunkt des Ladens des Status und bei jedem Neustart des Status wird das Formular neu erstellt. Dies ist keine Voraussetzung, aber auf diese Weise können wir sicher sein, dass in der Formularlogik keine akkumulierten Effekte aus dem vorherigen geladenen Zustand übrig bleiben.

Innerhalb des Formulars selbst erstellen wir die Steuerelemente und „schieben“ die Daten, die von ihnen stammen, und konvertieren sie vom Staatsvertrag in den Modellvertrag. Die Struktur der Formulare entspricht so weit wie möglich den Verträgen der Modelle. Infolgedessen bietet uns die value-Eigenschaft des Formulars ein vorgefertigtes Modell zum Senden an das Backend.

Wenn sich in Zukunft der Status oder die Modellstruktur ändert, wird genau an der Stelle, an der Felder hinzugefügt / entfernt werden müssen, ein Typoskript-Kompilierungsfehler angezeigt, was sehr praktisch ist.

Wenn die Status- und Modellobjekte eine absolut identische Struktur haben, entfällt durch die in Typoskript verwendete strukturelle Typisierung die Notwendigkeit, eine bedeutungslose Zuordnung voneinander zu erstellen.

Insgesamt ist die Formlogik in Komponenten von der Präsentationslogik isoliert und lebt „von selbst“, ohne die Komplexität des Datenflusses unserer gesamten Anwendung zu erhöhen.

Das ist fast alles. Es gibt noch Grenzfälle, in denen wir die Formularlogik nicht vom Rest der Anwendung isolieren können:

  1. Formänderungen, die zu einer Änderung des Präsentationsstatus führen, z. B. die Sichtbarkeit eines Datenblocks in Abhängigkeit vom eingegebenen Wert. Wir implementieren es in der Komponente, indem wir Formularereignisse abonnieren. Sie können dies durch die unveränderlichen Eigenschaften tun, die zuvor besprochen wurden.
  2. Wenn Sie einen asynchronen Validator benötigen, der das Backend aufruft, erstellen wir AsyncValidatorFn in der Komponente und übergeben es an den Formularkonstruktor, nicht an den Service.

Somit bleibt die gesamte „Grenzlogik“ an der prominentesten Stelle - in den Komponenten.

Schlussfolgerungen


Lassen Sie uns zusammenfassen, was wir haben und welche anderen Punkte es für das Studium und die Entwicklung gibt.

Zuallererst zwingt uns die Entwicklung der OnPush-Strategie dazu, Datenflussanwendungen sorgfältig zu entwerfen, da wir jetzt Angular und nicht ihm die Spielregeln diktieren.

Diese Situation hat zwei Konsequenzen.

Erstens bekommen wir ein angenehmes Gefühl der Kontrolle über die Anwendung. Es gibt keine Magie mehr, die „irgendwie funktioniert“. Sie wissen genau, was zu einem bestimmten Zeitpunkt in Ihrer Bewerbung passiert. Die Intuition entwickelt sich allmählich weiter, sodass Sie den Grund für den gefundenen Fehler verstehen können, noch bevor Sie den Code öffnen.

Zweitens müssen wir jetzt mehr Zeit mit dem Entwerfen der Anwendung verbringen, aber das Ergebnis wird immer die „direkteste“ und daher einfachste Lösung sein. Dies verringert die Wahrscheinlichkeit einer Situation, in der die Anwendung mit zunehmender Größe zu einem Monster von enormer Komplexität wird, die Entwickler die Kontrolle über diese Komplexität verloren haben und die Entwicklung nun eher wie mystische Riten aussieht, erheblich auf Null.

Kontrollierte Komplexität und das Fehlen von „Magie“ verringern die Wahrscheinlichkeit einer ganzen Klasse von Problemen, die beispielsweise durch zyklische Datenaktualisierungen oder akkumulierte Nebenwirkungen entstehen. Stattdessen haben wir es mit Problemen zu tun, die bereits während der Entwicklung auftreten, wenn die Anwendung einfach nicht funktioniert. Und zwangsläufig müssen Sie dafür sorgen, dass die Anwendung einfach und klar funktioniert.

Wir haben auch gute Auswirkungen auf die Leistung erwähnt. Mit sehr einfachen Tools wie profiler.timeChangeDetection können wir jetzt jederzeit überprüfen, ob unsere Anwendung noch in gutem Zustand ist.

Auch jetzt ist es eine Sünde, nicht zu versuchen , NgZone zu deaktivieren . Erstens können Sie beim Start der Anwendung nicht die gesamte Bibliothek laden. Zweitens wird eine ganze Menge Magie aus Ihrer Anwendung entfernt.

Hier beenden wir unsere Geschichte.

Wir bleiben in Kontakt!

Source: https://habr.com/ru/post/de426819/


All Articles