Mit Angular lassen sich sehr einfach moderne Clients für Geschäfts- und Web-Anwendungen umsetzen. Doch wie so oft, steckt auch hier der Teufel im Detail. Wer bewährte Praktiken und Muster anwendet, kann jedoch aus den Erkenntnissen der Community lernen, ohne häufige Fehler wiederholen zu müssen.

In diesem Artikel stelle ich fünf solcher Best Practices vor, die sich in meinem Alltag als Trainer, Berater und programmierender Architekt bewährt haben. Den Quellcode der gezeigten Beispiele findet man in meinem GitHub Account.

1. Unidirectional Dataflow und Change Detection

Die Change Detection bei Single Page Applications ist wohl jenes Merkmal, das die Performance am stärksten beeinflusst. Deswegen hat das Angular-Team diesem Aspekt auch besonders viel Aufmerksamkeit gewidmet und die Idee des Unidirectional Dataflows aus React übernommen. Vereinfacht lässt sich diese Idee als Zustandsautomat darstellen:

Âbbildung - Data Binding als Zustandsautomat

Data Binding als Zustandsautomat

Beim Eintreten eines Ereignisses führt Angular den jeweiligen Event-Handler aus. Dieser kann weitere Events triggern und die stoßen ggf. weitere Event-Handler an. Erst danach arbeitet Angular die Property-Bindings ab. Im Anschluss wartet Angular auf weitere Ereignisse und beginnt wieder von vorne.

Durch die strikte Trennung von Property- und Event-Bindings muss Angular pro Durchlauf nur ein einziges Mal die Property Bindings aktualisieren. Das ist ein großer Fortschritt gegenüber dem ursprünglichen AngularJS 1.x. Dort konnte nämlich ein Property-Bindings zu Folgeänderungen führen, und in diesem Fall musste Angular erneut sämtliche Bindungen auf Änderungen prüfen und ggf. aktualisieren. Solche Zyklen verschlechtern nicht nur die Performance, sondern auch die Nachvollziehbarkeit.

Zyklen sind verboten!

Event-Bindings müssen also immer vor den jeweiligen Property-Bindings ausgeführt werden, und diese beiden Phasen dürfen auch nicht überlappen. Verletzt das Programm diese Vorschrift, reagiert Angular mitunter sehr ungemütlich.

Das nachfolgende Beispiel demonstriert diesen Umstand:

@Component([…])
export class FlightCardComponent implements OnInit {

@Input() item: Flight;
@Input() selected: boolean;
@Output() selectedChange = new EventEmitter<boolean>();

[…]

ngOnInit() {
this.selectedChange.next(true);
}

[…]
}

Die FlightCardComponent nimmt unter anderem den Boolean selected entgegen. Änderungen daran veröffentlicht sie über selectedChange. Während des ersten Property-Bindings bekommt selected seinen Initialwert. Danach führt Angular den Life-Cycle-Hook ngOnInit aus. Dieser gibt eine Änderung von selected bekannt, indem er das Ereignis selectedChange auslöst.

Genau das ist das Problem an dieser Stelle: Nach den Property-Bindings kommt hier erneut ein Event-Binding zur Ausführung. Das widerspricht dem zuvor diskutierten Zustandsautomaten.

Würde Angular das erlauben, hätten wir wieder dieselbe Situation wie bei AngularJS 1.x. Angular müsste im Kreis laufen, um zyklische Abhängigkeiten abzuarbeiten — und das wirkt sich sowohl auf die Performance, als auch auf die Nachvollziehbarkeit negativ aus.

Glücklicherweise erkennt Angular häufig solche Zyklen und mahnt uns mit einer Exception ab:

Abbildung: Abmahnung von Zyklen

Abmahnung von Zyklen

 

Der Name dieser Exception und die von ihrer gebotenen Information ist ein wenig gewöhnungsbedürftig. Um sie zu verstehen, muss man sich vor Augen halten, wie Angular den Zyklus entdeckt. Hierfür ist Angular den Abhängigkeitsbaum nämlich zweimal durchgegangen: Einmal für die eigentliche Datenbindung und ein weiteres Mal zum Entdecken eventueller Zyklen. Bei diesem Kontrolldurchgang prüft Angular lediglich, ob sich die gebundenen Werte geändert haben. Falls dem so ist, kann nur ein Zyklus dafür verantwortlich sein.

Im betrachteten Fall hat Angular demnach entdeckt, dass unser Zyklus den Wert selected von undefined auf true geändert hat.

Da sich der Kontrolldurchgang auf die Performance auswirkt, findet er auch nur im Debug-Modus statt. Im Produktionsmodus prüft Angular nicht gegen Zyklen. Insofern muss dieser Fehler bereits im Debug-Modus entdeckt und umschifft werden.

 

Zyklen verhindern

Nun stellt sich die Frage, wie sich die gezeigte Exception verhindern lässt. Hierzu gibt es zwei Strategien: Eine einfache und eine richtige. Die einfache Strategie sieht vor, dass die Anwendung Angular austrickst, sodass es den Zyklus nicht erkennt. Das gelingt unter anderem über einen Timeout, der das Auslösen des Ereignisses erst nach dem Kontrolldurchgang stattfinden lässt:

setTimeout(() => this.selectedChange.next(true), 0);

Ein erneutes Anstoßen der Change Detection hat einen ähnlichen Effekt.

Auch wenn das einfach ist, ändert es nichts am eigentlichen Problem: Es liegt ein Zyklus vor, und der wirkt sich negativ auf Performance und Nachvollziehbarkeit aus.

Die einzig richtige Lösung besteht darin, die Änderung bereits in der Parent-Komponente durchzuführen. Es gibt ja eigentlich auch gar keinen Grund, warum die Anwendung zuerst einen falschen Wert an die Komponente weitergeben soll. Die Umsetzung dieser Refaktorierung ist nicht immer einfach. In vielen Fällen bietet es sich an, die entsprechenden Code-Strecken in einen Service auszulagern und diesen in der Parent-Komponente zu konsumieren.

2. OnPush

Standardmäßig überprüft Angular beim Aktualisieren der Property-Bindings sämtliche Komponenten auf Änderungen. Gerade, wenn es viele Property-Bindings gibt und es diese häufig zu prüfen gilt, können Performance-Probleme entstehen.

Eine Lösung hierfür ist die Change-Detection-Strategie OnPush, die man so aktiviert:

@Component({
[…]
changeDetection: ChangeDetectionStrategy.OnPush

})
export class FlightCard {
[…]
@Input() flight;
}

Aktiviert eine Komponente diesen Modus, überprüft Angular sie nur in bestimmten Fällen auf Änderungen:

  • An die Komponente übergebene Daten ändern sich
  • Eine async-Pipe benachrichtigt die Komponente darüber, dass ein gebundenes Observable einen neuen Wert veröffentlicht hat
  • Der Benutzer löst in der Komponente ein UI-Event aus
  • Die Anwendung stößt die Change Detection manuell an

Der erste Punkt hört sich einfacher an, als er sich tatsächlich gestaltet. Im OnPush-Modus prüft Angular nämlich nur, ob sich die Objektreferenz der übergebenen Werte geändert hat. Beim Aufruf

<flight-card [flight]="myFlight" …></flight-card>

muss die Anwendung myFlight demnach durch ein neues Objekt ersetzen, damit Angular diese Änderung berücksichtigt. Die Prüfung der einzelnen Eigenschaften entfällt und das steigert die Performance. Hierzu kommt häufig der Spread-Operator (…) zum Einsatz:

const myFlight: Flight = { ...myOldFlight, date: newValue };

Der betrachtete Fall erzeugt eine flache Kopie von myOldFlight und aktualisiert den Wert von date mit newDate. Da diese Vorgehensweise das ursprüngliche Objekt nicht ändert (mutiert), sondern klont, ist hier auch von Immutables die Rede.

3. Smart und Dump Components

Betrachtet man die FlightCardComponent im ersten Code-Beispiel wird klar, dass sie lediglich Daten präsentiert und keine Kenntnis über den aktuellen Anwendungsfall hat. Es handelt sich dabei um eine sogenannte Dumb Component, also eine dumme Komponente.

Solche Dumb Components haben eine wichtige Eigenschaft: Sie sind wiederverwendbar. Egal welcher Anwendungsfall Flüge präsentieren muss, die Anwendung kann auf die FlightCardComponent zurückgreifen.

Natürlich brauchen wir auch Komponenten, die den Anwendungsfall steuern, mit Services und auch mit dem Backend kommunizieren. Diese Steuerung platziert die Anwendung idealerweise in einer einzigen Komponente pro Anwendungsfall. Hierbei ist von Smart Components die Rede. Im hier betrachteten Beispiel nennt sich diese FlightSearchComponent:

Abbildung - Smart und Dumb Components im Zusammenspiel

Smart und Dumb Components im Zusammenspiel

 

Typischerweise sind Smart Components weiter oben im Komponentenbaum angesiedelt, und darunter befinden sich idealerweise nur noch Dumb Components. Diese Vorgehensweise führt nicht nur zu wiederverwendbaren Dumb Components, sondern hat noch weitere Vorteile: Dadurch, dass die Steuerung der Geschäftslogik in einer Smart Component isoliert ist, ergibt sich eine bessere Nachvollziehbarkeit. Auch die Performance kann sich verbessern, da die Smart Component auch für die Serverzugriffe verantwortlich ist und somit mit einem einzigen Request auch die Daten für ihre Dumb Components abrufen kann.

Außerdem verhindert die konsequente Trennung zwischen Smart und Dumb Components Zyklen. Würden hingegen mehrere Smart Components zusammenspielen, bestünde die Gefahr, dass diese sich, wie im ersten Code-Block gezeigt, gegenseitig korrigieren — und das hat Zyklen zur Folge.

4. Modul-Schnitt

Um die Komplexität großer Angular-Anwendungen in den Griff zu bekommen, bietet es sich an, diese in verschiedene Angular-Module zu untergliedern. Dabei hat es sich bewährt, zwischen drei Arten von Modulen zu unterscheiden:

Abbildung - Typische Modulstruktur einer Angular-Anwendung

Typische Modulstruktur einer Angular-Anwendung

 

Demnach erhält jedes Feature der Anwendung ein eigenes Modul. Ein Feature in diesem Sinne ist zum Beispiel ein Anwendungsfall.

Zusätzlich hat sich hier die aus der Psychologie bekannte 7+/-2 Regel bewährt. Ihr zufolge kann ein durchschnittlicher Mensch lediglich 7+/-2 Elemente zu einem Zeitpunkt im Überblick behalten. Hat ein Modul also mehr als 7+/-2 Einträge, empfiehlt es sich, diese auf mehrere Module aufzuteilen. Glücklicherweise geht das häufig sehr einfach, denn viele Anwendungsfälle zerfallen ganz natürlich in untergeordnete Anwendungsfälle. Beispielsweise könnte man Flüge buchen in Flüge suchen, Passagierdaten bekannt geben und Zahlungsdaten bekannt geben untergliedern.

Jene Anwendungsteile, die in mehreren Feature Modulen benötigt werden, kommen in ein Shared Module. Beispiele dafür sind technische Aspekte, wie Authentifizierung oder Logging, sowie fachliche Komponenten, wie Anwendungsfall-übergreifende Komponenten.

Von den Shared Modulen kann es auch mehrere geben. Es empfiehlt sich, zusammengehörige Aspekte in einem Shared Module unterzubringen, dabei jedoch auch auf die erwähnte 7+/-2 Regel zu achten.

Ganz vorne bleibt noch ein AppModule übrig. Es ist lediglich eine Art Briefumschlag um die einzelnen Feature Module herum. Außerdem beherbergt dieses Modul die AppComponent, welche die Shell der Anwendung repräsentiert.

 

5. Fassaden und State-Management

Zur weiteren Strukturierung hat es sich bewährt, jedem Anwendungsfall einen Angular-Service zu spendieren. Diese sogenannte Fassade koordiniert alle anderen Services, die etwas zum Anwendungsfall beitragen. Die Komponenten, die den Anwendungsfall realisieren, müssen somit nur mit dieser einen Fassade kommunizieren:

Abbildung - Fassaden orchestrieren weitere Services für einen bestimmten Anwendungsfall

Fassaden orchestrieren weitere Services für einen bestimmten Anwendungsfall

Die Fassade speichert auch Zustände, die im Rahmen des Anwendungsfalls anfallen. Das kann über normale Properties erfolgen, aber auch über Subjects, die sie als Observables veröffentlicht. Letzteres hat den Vorteil, dass sich Interessenten über Zustandsänderungen informieren lassen können.

Spannend wird es, wenn nun viele Komponenten von vielen Fassaden Daten benötigen und wenn sich Fassaden gegenseitig benachrichtigen müssen:

Abbildung - Schwer nachvollziehbare Zugriffe und Zyklen

Schwer nachvollziehbare Zugriffe und Zyklen

Es ist unschwer zu erkennen, dass das zu wenig nachvollziehbaren Kontroll- und Datenflüssen führt. Um diesen Umstand zu erkennen, bieten sich State-Management-Bibliotheken an. Die wohl populärste im Umfeld von Angular ist NGRX, welche das Redux-Muster implementiert.

Während bei diesen Bibliotheken der Teufel im Detail steckt, ist das Grundprinzip rasch erklärt, denn im Wesentlichen bieten diese Lösungen einen sogenannten Store, der den gesamten Anwendungszustand zentral verwaltet:

Abbildung - Zentralisiertes State Management

Zentralisiertes State Management

Dieser Store lässt sich mit einer In-Memory Datenbank vergleichen. Sein Einsatz verhindert Redundanzen und Inkonsistenzen sowie die Notwendigkeit für Benachrichtigungen zwischen Komponenten und Services, welche zu Zyklen führen.

Leider sind State-Management-Lösungen nicht selten komplex und führen eine ganze Menge weiterer Building-Blocks ein. Aber auch hier punktet die Idee der Fassaden, zumal sie die Details der Zustandsverwaltung vor den Komponenten verbergen. Das erlaubt es, State-Management-Bibliotheken schrittweise im Nachhinein einzuführen. Während anfangs ein Anwendungsfall mit einem als Observable veröffentlichen Subject auskommt, kann die Fassade später um ausgewählte Teile der State Management Bibliothek ergänzt werden.

Zusammenfassung

Zur Verbesserung der Performance und Nachvollziehbarkeit zwingt uns Angular, auf Zyklen zu verzichten. Dies gilt es bereits bei der Struktur der Anwendung zu berücksichtigen.

Der Einsatz von OnPush hilft Angular, zielgerichtet jene Komponenten, die es zu aktualisieren gilt, zu identifizieren. Damit dieser Modus funktioniert, müssen wir die zu bindenden Informationen als Observables und Immutables darstellen.

Die Unterscheidung zwischen Smart und Dumb Components hilft beim Schaffen wiederverwendbarer Code-Strecken und wirkt sich positiv auf die Performance sowie auf die Nachvollziehbarkeit aus.

Dank der Unterteilung in Feature und Shared Modules lässt sich die Übersicht wahren. Zusätzlich bietet sich der Einsatz der 7+/-2 Regel an. Wird ein Modul zu groß, wird es aufgesplittet.

Fassaden verstecken die Komplexität hinter einem Anwendungsfall und erlauben die schrittweise Einführung von State-Management-Bibliotheken.

Manfred Steyer
Letzte Artikel von Manfred Steyer (Alle anzeigen)

Große Auswahl an günstigen Domain-Endungen – schon ab 0,08 € /Monat
Jetzt Domain-Check starten