Die Ansprüche von Anwendern an die Performance von Anwendungen sind über die letzten Jahre stetig gewachsen. Für alle Apps – ob nativ oder im Web – gilt: Nur was sich flüssig bedienen lässt, nutzen Anwender auch gerne. Dieser Artikel beschreibt die Stellschrauben in Angular, die die Laufzeitperformance Ihrer Anwendung maßgeblich beeinflussen.
Eine der zentralen Funktionen des Single-Page-Application-Frameworks Angular ist seine Unterstützung für Data Binding: Das Framework kümmert sich darum, dass Daten aus der Komponentenklasse an der gewünschten Stelle in der View angezeigt werden. Bei einer Änderung des Datenmodells aktualisiert das Framework automatisch die View und hält diese damit synchron. Dieser Prozess nennt sich Change Detection. Jeder Angular-Komponente ist ein Change Detector zugeordnet. Bei jeder Änderung innerhalb der Anwendung werden diese einmal durchlaufen, unidirektional von oben nach unten (siehe Abb. 1).
Dieser Prozess läuft scheinbar magisch im Hintergrund. Wohl die wenigsten Angular-Entwickler haben irgendeine Berührung mit dem Prozess – bis sie auf Performanceprobleme stoßen. Denn die Change Detection hängt mit der Laufzeitperformanz von Angular-Apps direkt zusammen.
Um eine Änderung im Datenmodell zu erkennen, verwendet Angular eine Bibliothek namens Zone.js. Diese Bibliothek ist quelloffen und wird ebenfalls vom Angular-Team herausgegeben. Nach eigenen Angaben ist die Bibliothek ein Meta-Monkey-Patch und stellt einen asynchronen Ausführungskontext zur Verfügung. Unter einem Monkey Patch versteht man die Manipulation von Systemfunktionen zur Laufzeit: Zone.js überschreibt viele Browserfunktionen, durch die sich der Zustand innerhalb der Webanwendung ändern könnte, mit einer eigenen Implementierung. So wird etwa die Methode setTimeout(), die zur verzögerten Ausführung einer Funktion verwendet werden kann, durch eine von Zone.js bereitgestellte, kompatible Implementierung ersetzt. Die Bibliothek ruft intern die Browserimplementierung auf und erlangt Kenntnis darüber, wann eine asynchrone Operation gestartet und beendet wird. Das wiederum versteht man unter dem asynchronen Ausführungskontext. Gleiches gilt auch für die Funktion setInterval(), viele Browserereignisse wie click oder mousemove und weitere Browserfunktionen.
Zone.js ist ein integraler Bestandteil des Angular-Frameworks und wird daher mit jedem Projekt mitinstalliert. Noch bevor Angular initialisiert wird, startet zunächst Zone.js und erstellt den globalen Ausführungskontext (Zone). Mit dem Start von Angular wird von dieser Zone die sogenannte NgZone abgezweigt. Darin laufen alle Ereignisse, die durch den Quelltext innerhalb der Angular-Anwendung ausgelöst werden. Wann immer eine asynchrone Operation innerhalb der NgZone beendet wurde und keine weiteren Aufgaben mehr anstehen, löst das Framework einen Change-Detection-Zyklus aus (siehe Abb. 2).
Das Verhalten des Frameworks ist sehr nützlich, kann in bestimmten Fällen aber auch gegen die Entwickler arbeiten: Performanceprobleme treten in Angular typischerweise beim besonders häufigen Aufruf der Change Detection in Kombination mit lang andauernden Change-Detection-Zyklen auf. Zu solchen Szenarien kommt es etwa bei Verwendung sogenannter High-Frequency-Events wie mousemove oder scroll: Registriert sich die Angular-Anwendung auf eines dieser Ereignisse, wird die Change Detection für fast jeden Pixel einer Mausbewegung oder eines Scrollvorgangs ausgelöst. Noch schlimmer verhält es sich bei der Verwendung der Methode requestAnimationFrame(), die für 2D- oder 3D-Visualisierungen genutzt wird und die entsprechend der Bildschirmwiederholfrequenz aufgerufen wird – auf vielen Geräten also 60 mal pro Sekunde.
Die Dauer eines Change-Detection-Zyklus wiederum hängt von der Anzahl der Data Bindings ab und wie schnell die dahinterliegenden Werte abgerufen werden können. Entwickler sollten darauf achten, nur so viele Bindings wie nötig zu verwenden. Grids sollten etwa Virtual-Scrolling-Mechanismen nutzen, um Bindings nur auf die sichtbaren Zeilen zu beschränken. Weiterhin sollten Entwickler vermeiden, die View auf rechenintensive Getter oder Methoden zu binden, da diese für jeden Change-Detection-Zyklus aufgerufen würden. Im Idealfall sollten die Bindings direkt auf ein Feld binden, da reine Leseoperationen sehr schnell durchgeführt werden können.
Checks sparen mit OnPush
Eine weitere Möglichkeit, die Change-Detection-Dauer in der Anwendung zu verkürzen, bietet die Change-Detection-Strategie OnPush. Normalerweise werden im Rahmen eines Zyklus sämtliche Komponenten geprüft. Mithilfe von OnPush kann die Ausführung der Change Detection auf die Änderung von Eingabeparametern einer Komponente eingeschränkt werden oder imperativ erfolgen. Ansonsten nimmt die Komponente nicht am Zyklus teil (siehe Abb. 3).
Um diese Strategie einzusetzen, wird der Komponenten-Decorator um die Eigenschaft changeDetection erweitert. Dieser wird der Wert ChangeDetectionStragegy.OnPush zugewiesen:
Die Komponente wird fortan nur noch nach templateseitigen Änderungen seiner Input-Properties Teil der Change Detection sein, in diesem Beispiel also bei Änderung des gebundenen Wertes für foo:
Dabei gilt es zu beachten, dass Angular aus Performancegründen intern den zuvor gesetzten Wert mit dem aktuellen vergleicht (oldValue === newValue). Nur wenn der Vergleich false zurückgibt, wird das Binding aktualisiert. Werden Objekte eingereicht, muss sich zum Auslösen der Change Detection zwingend die Referenz ändern, da der Vergleich andernfalls true zurückgibt und kein Update geschieht. In diesem Fall können Immutable-Bibliotheken helfen, die für jede Änderung immer eine neue Objektinstanz zurückgeben.
Können darüber hinaus weitere Ereignisse auftreten, die zu einer Aktualisierung oder Oberfläche führen sollen (etwa ein Abruf von Daten über einen Service), müssen Entwickler die Komponente imperativ wieder zum Teil der Change Detection werden lassen. Das geht über die Schnittstelle ChangeDetectorRef, die sich per Dependency Injection anfordern lässt und Zugriff auf den Change Detector der jeweiligen Komponente gewährt:
Dies geschieht über die Methode markForCheck(), die aufgerufen werden muss, sobald sich eine Änderung auf einem anderen Weg ergeben kann. Hierbei ist jedoch Vorsicht geboten: Vergessen Entwickler, die Methode aufzurufen, so kann es passieren, dass View und Model nicht mehr synchron sind.
Perfect Match: OnPush & Async-Pipe
Um dieser Unhandlichkeit zu begegnen, hat das Angular-Team die Async-Pipe eingeführt. Diese Pipe nimmt Observables oder Promises entgegen und ruft bei einer Änderung automatisch die Methode markForCheck() auf. Darüber hinaus kümmert sich die Pipe auch darum, sich zum Beispiel von einem Observable wieder ordnungsgemäß zu deregistrieren. Der Code von oben kann unter Verwendung der Async-Pipe folgendermaßen gekürzt werden:
Im Template bindet der Entwickler die View dann lediglich auf das Observable-Feld und wendet darauf die Async-Pipe an.
Bei Verwendung von Observables oder Promises in Kombination mit der Async-Pipe kann es dann nicht mehr dazu kommen, dass View und Model auseinanderlaufen. Dank OnPush wird die Komponente nur noch geprüft, wenn sich wirklich eine Änderung ergeben hat. In Kombination mit der Async-Pipe eignet sich OnPush auch hervorragend als Standardstrategie.
Aus der Change Detection komplett aussteigen
Außerdem können Komponenten auch komplett aus der Change Detection aussteigen. In diesem Fall werden sie beim Abarbeiten des Zyklus grundsätzlich übersprungen. Dazu kann auf dem ChangeDetectorRef die Methode detach() aufgerufen werden. Dieses Vorgehen eignet sich vor allem dann, wenn eine Komponente temporär ausgeblendet wird, aber nicht komplett zerstört werden soll. Änderungen, die währenddessen am Datenmodell vorgenommen werden, werden in der View nicht nachgeführt. Bei Bedarf kann über die Methode detectChanges() eine lokale Change Detection nur für die aktuelle Komponente und ihre Kindkomponenten durchgeführt werden. Um schließlich wieder an der regulären Change Detection teilzunehmen, wird auf derselben Schnittstelle die Methode reattach() aufgerufen.
Dauer des Change-Detection-Zyklus messen
Um flüssig zu wirken, sollte ein einzelner Change-Detection-Zyklus deutlich kürzer sein als 16 Millisekunden, also die Dauer eines einzelnen Frames bei 60 fps. Das Angular-Team empfiehlt, den Zyklus kürzer als drei Sekunden zu halten. Um festzustellen, ob eine Anwendung in dieser Hinsicht überhaupt ein Problem aufweist, gibt Angular Entwicklern ein Werkzeug an die Hand, das die Dauer des Change-Detection-Zyklus stoppt. Das Werkzeug muss jedoch erst aktiviert werden. Dazu wird die Datei main.ts, die den Bootstrapping-Prozess der Angular-Anwendung definiert, folgendermaßen angepasst:
Anschließend stehen die Debug-Tools zur Laufzeit der Anwendung auf der Entwicklerkonsole zur Verfügung. Die Konsole ist in den Entwicklertools des jeweiligen Webbrowsers zu finden, die in den meisten Fällen über die Tastenkombination F12 und das Anwählen der Registerkarte Console geöffnet werden können. Dort kann dann die Methode ng.profiler.timeChangeDetection() aufgerufen werden. Das Tool misst die durchschnittliche Dauer eines Change-Detection-Zyklus: Es läuft mindestens 500 Millisekunden oder fünf Zyklen lang (je nachdem, was zuletzt erreicht ist). Das Ergebnis wird anschließend auf der Konsole ausgegeben. Damit erhalten Entwickler ein Indiz, ob etwaige Performanceprobleme durch eine lange Zyklusdauer hervorgerufen werden könnten. Im Falle einer Blanko-Angular-Anwendung mit wenigen Bindings liegt die Dauer eines Change-Detection-Zyklus bei einem Bruchteil einer Millisekunde (siehe Abb. 4).
Nachdem nun Techniken gezeigt wurden, die die Dauer des Change-Detection-Zyklus reduzieren können, werden als nächstes Methoden vorgestellt, um die Anzahl dieser Zyklen zu reduzieren.
Change-Detection-Zyklen zusammenlegen
Angular 9 bringt einen neuen Modus mit sich, der in speziellen Fällen dazu beitragen kann, mehrere Change-Detection-Zyklen zu einem zusammenzulegen. Im folgenden Beispiel löst sowohl ein Klick auf die Schaltfläche als auch einer auf das umschließende Element ein Ereignis aus. Normalerweise würde es hier zu zwei Change-Detection-Zyklen kommen.
In den meisten Fällen dürfte es hier jedoch genügen, einen einzigen Zyklus nach dem Ablauf beider Ereignishandler auszuführen. Dafür kann seit Angular 9 beim Starten der Anwendung in der Datei main.ts im Konfigurationsobjekt der Methode bootstrapModule() die Eigenschaft ngZoneEventCoalescing mit dem Wert true hinterlegt werden.
Opt-out: Zone temporär deaktivieren
Wenn Entwickler die Zone temporär umgehen möchten, können sie auf die Schnittstelle NgZone zurückgreifen. Diese lässt sich über die Dependency Injection des Frameworks anfordern. Auf der Schnittstelle finden sich unter anderem die Methoden runOutsideAngular() und run(). Beide Methoden nehmen eine Funktion entgegen, die dann außerhalb respektive innerhalb der NgZone ausgeführt wird.
Performancekritischer Code sollte außerhalb der Angular-Zone ausgeführt werden. So führt etwa der Aufruf von requestAnimationFrame() nicht mehr zu einem Change-Detection-Zyklus (siehe Abb. 5).
Änderungen, die außerhalb der NgZone durchgeführt werden, werden umgekehrt allerdings nicht mehr von Angular erkannt. Es kann also dazu kommen, dass die View vom tatsächlichen Stand im Datenmodell abweicht. Muss die View basierend auf einem außerhalb der Zone ermittelten Ergebnis aktualisiert werden, so kann über die Methode run() die NgZone auch wieder betreten und Aktualisierungen durchgeführt werden.
Zyklen sparen: Zone-Patches deaktivieren
Entwickler haben jedoch nicht immer die Möglichkeit, über die gezeigte Methode aus der NgZone auszusteigen. Insbesondere, wenn eine 2D- oder 3D-Visualisierung durch eine Drittanbieterbibliothek gesteuert wird, kann beispielsweise der Aufruf von requestAnimationFrame() nicht mit einem runOutsideAngular() umschlossen werden. In diesen Fällen haben Entwickler jedoch die Möglichkeit, Zone-Patches gezielt abzuschalten. Dies erfolgt in der Datei polyfills.ts, die noch vor der Ausführung von Angular geladen wird. Hier wird auch die Bibliothek Zone.js importiert. Bevor dies geschieht, sind folgende Zeilen zu finden, die standardmäßig auskommentiert sind:
Die erste Zeile deaktiviert den Zone-Patch für die schon mehrfach genannte Methode requestAnimationFrame(). Das eignet sich für den Fall, wenn die Methode von einer Drittanbieterbibliothek aufgerufen wird und die Aufrufe keinen Change-Detection-Zyklus nach sich ziehen sollen. Die zweite Zeile deaktiviert die Patches von DOM-Ereignishandlern wie onclick. In der dritten Zeile haben Entwickler schließlich die Möglichkeit, bestimmte Ereignisse zu ignorieren. Hier sind bereits die beiden kritischen Ereignisse scroll und mousemove eingetragen. Die angegebenen Ereignisse erreichen die NgZone also nicht und führen nicht mehr zu einem Change-Detection-Zyklus. Doch auch in diesen drei Fällen ist wieder Vorsicht geboten: Wird ein Patch deaktiviert, ist er nun für die komplette Anwendung abgeschaltet. Auch Angular-Ereignishandler, die auf ein mousemove reagieren, führen bei Verwendung der dritten Zeile nicht mehr zu einem Change-Detection-Zyklus. In einem solchen Handler vorgenommene Änderungen würden in der View also nicht nachgeführt.
Zone komplett abschalten
Darüber hinaus ist es auch möglich, Zone.js komplett abzuschalten. Das bedeutet im Umkehrschluss, dass Entwickler bei jeglichen Änderungen selbst die Change Detection auslösen müssen. In Angular geht das mithilfe der Schnittstelle ApplicationRef, die sich über die Dependency Injection des Frameworks anfordern lässt:
Um die Zone für die Anwendung zu deaktivieren, wird in der Datei main.ts im Konfigurationsobjekt für die Methode bootstrapModule() der Eigenschaft ngZone der Wert noop zugewiesen. Die Zone-Implementierung wird dann gegen einen Dummy ausgetauscht, der keine Change Detection auslöst.
In durch Angular hervorgerufene Performanceprobleme kann eine derart konfigurierte Anwendung praktisch nicht laufen, umgekehrt ergibt sich jetzt aber die Gefahr, dass Entwickler das Aufrufen der Change Detection vergessen könnten und die View damit eventuell die Synchronisation mit dem Datenmodell verliert. Für Anwendungen mit höchsten Ansprüchen an die Performance könnte dies aber eine interessante Option sein.
Zusammenfassung: So bleiben Angular-Apps fast and fluid
Entwickler sollten darauf achten, den Change-Detection-Zyklus so kurz wie möglich zu halten. Dies gelingt durch das Reduzieren der Bindinganzahl auf das erforderliche Minimum, das Vermeiden von Data Bindings auf rechenintensive Getter oder Funktionen, die Verwendung der Change-Detection-Strategie OnPush oder der ChangeDetectorRef-Schnittstellen. Darüber hinaus sollten so wenige Zyklen wie nötig ausgelöst werden, durch das temporäre oder vollständige Deaktivieren der Zone beziehungsweise ihrer Patches.
Angular-Entwickler sollten diese Mechanismen unbedingt beherrschen, da bei falscher Handhabung View und Model auseinanderlaufen könnten. Wer diese Grundregeln beachtet, dürfte mit seiner App in keine Performanceprobleme laufen.
Gratis E-Book Angular zum Download
Mehr Experten-Tipps für die Arbeit mit Angular finden Sie in unserem E-Book. Jetzt kostenlos downloaden.
- Was Sie 2022 für die Entwicklung von modernen Progressive Web Apps wissen sollten - 20. Dezember 2021
- Angular-Performance: So zünden Sie den Turbo - 18. August 2020
- Progressive Web Apps mit Angular - 28. Oktober 2019