Performante Webanwendungen mit AngularJS: Grundlagen und Messmethoden - JAXenter
Teil 1: Performanceprobleme messen

Performante Webanwendungen mit AngularJS: Grundlagen und Messmethoden

Dr. Christian Straube

©istockphoto.com/ktsimage

JavaScript-Frameworks wie AngularJS erlauben es, die Applikationslogik vom Server zum Client zu verschieben. Damit wird der Browser ein wichtiger Bestandteil der Laufzeitumgebung. Deswegen müssen Entwickler den clientseitig ausgeführten Applikationscode umfangreicher analysieren als bisher, da sonst Performanceprobleme drohen. Dabei helfen Scopes, Digest Cycles und andere Messmethoden.

Die Performance von Softwareapplikationen lässt sich mit verschiedenen Kennzahlen beschreiben, die sich je nach Einsatzgebiet und Applikationstyp unterscheiden. Datenbanksysteme können beispielsweise mit Lese- und Schreiboperationen pro Sekunde charakterisiert werden, High-Performance-Anwendungen aus dem wissenschaftlichen Umfeld mit Fließkommaoperationen pro Sekunde. Eine Performancekennzahl, die besonders bei Webapplikationen eine entscheidende Rolle spielt, ist die wahrgenommene Reaktionszeit auf Benutzereingaben – nicht zuletzt aufgrund der beständig steigenden Internetbandbreite, die die Erwartungshaltung der Benutzer fortwährend steigen lässt.

Noch vor wenigen Jahren zielten Maßnahmen, die Performance von Webapplikation zu steigern, hauptsächlich auf die Komprimierung der statischen Inhalte und deren Übertragung ab, beispielsweise durch gzip-Komprimierung, Zusammenfassung von HTTP-Anfragen mittels CSS Sprites oder durch Code Minifying.

Aus dem Nachladen einzelner Seitenelemente mittels Ajax haben sich so genannte Single-Page-Applikationen entwickelt. Gemeinsam mit HTML5 und damit dem „Local Data Storage“ führt diese Entwicklung dazu, dass sich die Anwendungslogik vom Server zum Client verlagert. Dementsprechend verschieben sich auch die Ansatzpunkte zur Performancesteigerung, insbesondere wenn der Server nur noch als Datenquelle über eine REST-Schnittstelle fungiert. Diese Entwicklung erfordert es, die Maßnahmen zur Performancesteigerung um clientseitige Softwareaspekte zu erweitern, insbesondere um auf die unterschiedlichen Hardwarekapazitäten auf Clientseite reagieren zu können.

Diese zweiteilige Artikelserie befasst sich mit Softwareaspekten und typischen Performanceproblemen des AngularJS-Frameworks, einem der am weitesten verbreiteten JavaScript-Frameworks für Single-Page-Applikationen. Gründe für die weite Verbreitung sind u. a. der einfache Einstieg, die Unterstützung von Funktionskapselung in Direktiven und Filtern, die Strukturierung entsprechend des MVC-Patterns und die automatische Synchronisation der Darstellung im DOM und des Datenmodells.

Artikelserie: Performanceprobleme in AngularJS meistern

Teil 1: Grundlagen und Messmethoden
Teil 2: Maßnahmen

Diese automatische Synchronisation basiert bei AngularJS auf so genannten Scopes und dem Digest Cycle, zwei Konzepten, die tief im Design des Frameworks verankert sind. Auch wenn diese Konzepte die Entwicklungsaufwendungen reduzieren und zugleich ausreichend Flexibilität für die meisten Szenarien bieten, können sie die wahrgenommene Reaktionszeit einer Webapplikation erheblich beeinträchtigen oder die Webapplikation gänzlich unbenutzbar machen, werden sie falsch eingesetzt.

Um diese Performanceprobleme erkennen und vermeiden zu können, erläutert der vorliegende erste Teil der Artikelserie relevante Grundkonzepte von AngularJS und stellt anschließend ausgewählte Werkzeuge vor, um eine adäquate Daten- und Informationsgrundlage für die Problemanalyse zur Verfügung zu haben. Darauf aufbauend wird im nächsten Teil der Artikelserie eine Reihe von Maßnahmen vorgestellt, mit denen die Performance von AngularJS-Webapplikationen verbessert und Probleme vermieden werden können.

AngularJS-Konzepte

AngularJS nutzt Scopes und den Digest Cycle als zentrale Elemente, um das Datenmodell und das User Interface (UI) im Document Object Model (DOM) synchron zu halten. Da in diesen beiden Konzepten die größten Risiken und damit auch das größten Potenziale für Performanceverbesserungen liegen, beziehen sich alle vorgestellten Maßnahmen auf diese beiden Konzepte. Ein entsprechendes Grundverständnis ist daher für einen erfolgreichen Einsatz der Maßnahmen entscheidend.

Abb. 1: Übersicht über die Grundkonzepte von AngularJS, deren Zusammenhänge und Aufgaben

Abb. 1: Übersicht über die Grundkonzepte von AngularJS, deren Zusammenhänge und Aufgaben

Der Scope und seine Struktur

Für die Datenhaltung nutzt AngularJS POJOs (Plain Old JavaScript Objects), die – gemäß allgemeinen JavaScript-Prinzipien – keine Getter und Setter, sondern nur sichtbare (public) Attribute verwenden. Diese POJOs werden in AngularJS Scopes genannt. Ein Scope besteht in AngularJS aus drei Arten von Attributen: dokumentierte AngularJS-Felder ($-Präfix), nicht dokumentierte AngularJS-Felder ($$-Präfix), von deren Verwendung abgeraten wird, und applikationsspezifische Felder (ohne Präfix). Diese Scopes bilden in AngularJS eine Baumstruktur, wobei die Wurzel rootScope ($rootScope) genannt wird.

In Abbildung 1 sind drei Scopes beispielhaft dargestellt: Das $parent-Feld ist ein Verweis auf den Eltern-Scope und kann gemäß Dokumentation verwendet werden. $$watchers ist ein Array von Funktionen, die vom Digest Cycle aufgerufen werden. Da sich diese Felder mit neuen AngularJS-Versionen ändern können, sollten sie nicht direkt verwendet werden. Die Felder myBoolVal, myStringVal und myListFilter sind applikationsspezifische Felder.

Watcher-Funktionen und der Digest Cycle

Da der Scope ein reguläres JavaScript-Objekt ist, gibt es nativ keine Möglichkeit, Veränderungen an den Datenobjekten zu erkennen. Object.observe ist Teil eines Proposals für ECMAScript 7 und wird bisher in keinem gängigen Browser unterstützt; Angular 2.0. wird auf diese Funktionalität aufbauen. Dafür verwendet AngularJS so genannte Watcher-Funktionen und den Digest Cycle.

Watcher werden für ein Element auf dem Scope definiert und geben an, was im Falle einer Änderung passieren soll. Einem Watcher wird dabei eine Expression bzw. Funktion übergeben, die das Objekt und dessen Dirty State ermittelt, sowie eine Listener-Funktion, die aufgerufen wird, wenn sich der betrachtete Wert seit dem letzten Aufruf des Watchers verändert hat. Abbildung 1 zeigt einen beispielhaften Watcher: Dieser prüft im betrachteten Scope das Attribut listFilter. Hat sich dieser Wert seit der letzten Betrachtung verändert, wird die Funktion reloadData() mit dem neuen Wert von listFilter aufgerufen. Die Liste aller Watcher-Funktionen wird im $$watchers-Array eines jeden Scopes gespeichert. AngularJS legt im Hintergrund immer dann eine Watcher-Funktion an, wenn eine AngularJS-Direktive (ng-*) verwendet, mit der Funktion $watch() ein Watcher oder mit der Funktion $on() ein Event Listener definiert oder aber eine Serverantwort auf eine $http– bzw. $resource-Anfrage geschickt wird.

Der Digest Cycle ist bei AngularJS dafür zuständig, Veränderungen im Modell zu propagieren und die Darstellung der Daten im DOM mit dem Modell synchron zu halten. Im Umkehrschluss bedeutet das, dass jede Veränderung im Modell, sei es durch Benutzereingaben in der View, Berechnungen in den Controllern oder neu geladene Daten vom Server, die Ausführung des Digest Cycles anstößt. In Abbildung 1 ist diese Situation auf der linken Seite unter „Trigger“ dargestellt.

Der Digest Cycle führt dabei pro Iteration alle Watcher-Funktionen aus, die für das Modell definiert sind, um den Dirty State zu evaluieren, ein Vorgang, der „Dirty Checking“ genannt wird. Dabei wird der Digest Cycle solange ausgeführt, bis entweder keine Listener-Funktionen in den Watchern das Datenmodell mehr verändern oder die Maximalanzahl an Iterationen durchlaufen wurden. Die Maximalanzahl an Iteration speichert AngularJS in der Variable TTL, die mittels $rootScope.digestTtl() geändert werden kann. Der Standardwert liegt bei zehn Iterationen.

Für den Digest Cycle sind zwei Funktionen von Bedeutung, die $digest() und die $apply()-Funktion. Die $digest()-Funktion ruft alle Watcher-Funktionen des angegebenen Scopes und aller Child-Scopes auf, um zu prüfen, ob sich eines der Bindings bzw. dessen Values geändert hat. Die $apply()-Funktion führt Änderungen am Datenmodell, d. h. dem Scope, außerhalb des AngularJS Lifecycles durch. Dafür nimmt die Funktion einen Ausdruck oder eine Funktion entgegen, die die Veränderungen beschreiben, wendet diese Veränderung auf dem Scope an und propagiert die Veränderung über die $digest()-Funktion im RootScope. Im Gegensatz zur $digest()-Funktion, die auf jeder Tiefe des Scope-Baums angewandt werden kann, kann die $apply()-Funktion nur auf dem RootScope aufgerufen werden.

Datengrundlage für Maßnahmenplanung und -bewertung schaffen

Das effiziente und zielorientierte Erkennen und Beseitigen von Performanceproblemen erfordert eine Datengrundlage, die die Istsituation in ausreichendem Maße und Detailgrad beschreibt und die Analyse von Auswirkungen der durchgeführten Maßnahmen erlaubt. Die Bandbreite an vorhandenen Werkzeugen zur Erzeugung dieser Datengrundlage ist sehr umfangreich. Je nach Einsatzzweck und Problemstellung eignen sich Werkzeuge aus verschiedenen Gebieten bzw. eine Kombination aus mehreren Werkzeugen. Dementsprechend kann keine allgemein gültige Empfehlung ausgesprochen werden, welches Werkzeug wann einzusetzen ist. Stattdessen wird im Folgenden ein Überblick über die Werkzeuge der Google Chrome DevTools (kurz DevTools) gegeben, da diese frei von Lizenzgebühren, für jeden zugänglich sind und sich nahtlos in die übliche Entwicklungsumgebung von Webapplikationen eingliedern lassen.

Die gleichen Argumente können auf die Firefox Developer Edition in Version 40 angewandt werden, die im August 2015 erschienen ist. Beide Werkzeug-Suiten bieten eine ähnliche Funktionalität, beispielsweise den DOM-Inspektor, den Debugger oder die Netzwerkanalyse. Das Erscheinungsbild der Firefox Developer Edition ist etwas moderner und arbeitet flüssiger als die der DevTools und ist zudem an einigen Stellen etwas ausgearbeiteter. Dafür fehlen bestimmte Funktionalitäten und sie sind etwas weniger stabil als die der DevTools. Die Wahl für eine der beiden Werkzeug-Suites muss daher individuell getroffen werden, die nachfolgenden Erläuterungen sind für beide anwendbar.

Nützliche Code-Snippets

In DevTools kann beliebiger JavaScript-Code als „Code-Snippet“ abgespeichert und im Kontext einer geöffneten Webapplikation ausgeführt werden. Das heißt insbesondere, dass geladene Bibliotheken wie jQuery genutzt und Variablen mit aktuellen Belegungen ausgegeben werden können, ohne den eigentlichen Applikationscode anpassen zu müssen. Die Mächtigkeit und gleichzeitige minimale Intrusiveness der Code-Snippets machen sie zu einem idealen Werkzeug, um den AngularJS-Scope oder Eigenschaften des Digest Cycle zu untersuchen. Die nachfolgende Auflistung zeigt ausgewählte Code-Snippets, die speziell für die Analyse von AngularJS-Applikationen geeignet sind.

Watcher zählen

Wie im Abschnitt zu den AngularJS-Grundlagen erläutert, werden in einem Digest-Cycle-Durchlauf alle registrieren Watcher-Funktionen ausgeführt. Listing 1 zeigt, wie die registrierten Watcher ausgegeben und gezählt werden können.

(function inspectWatchers () {
  var numWatchers = 0;
  // Jedes Element mit einem eigenen Scope   
  // wird von AngularJS mit der CSS-Klasse  
  // '.ng-scope' versehen
  var elems =    
    document.querySelectorAll('.ng-scope');
  var numElems = elems.length;
  var tmpScope, index = 0;
  for (index; index < numElems; index++) {
    tmpScope = angular.element(elems[index]).scope();
    numWatchers += (tmpScope.$$watchers) ? tmpScope.$$watchers.length : 0;
  }
  // Ausgabe der Elemente, die einen eigenen 
  // Scope haben ...
  console.log(elems);
  // ... und deren Anzahl
  console.log(numWatchers);
}());

Die Auflistung der Watcher wird in der DevTools-Konsole ausgegeben. Ein Mouseover hebt das Element im DOM hervor, zu dem der Watcher gehört. So kann untersucht werden, wo es besonders viele Watcher gibt und wie diese mit der Ausgabe im DOM zusammenhängen. Auch wenn es im Einzelfall auf die eingesetzte Konfiguration des Clients ankommt, hat sich als grober Richtwert eine Maximalanzahl von 2 000 Watcher-Funktionen für eine Seite etabliert.

Abb. 2: Zuordnung eines Watchers zu einem DOM-Element

Abb. 2: Zuordnung eines Watchers zu einem DOM-Element

Digest-Cycle-Aufrufe tracken

Listing 2 zeigt in der DevTools-Konsole an, wann eine neue Digest-Cycle-Ausführung gestartet wurde und zählt die bisherigen Ausführungen. Mit diesem Snippet können somit zwei Aspekte betrachtet werden: Zum einen wird deutlich, welche Aktivitäten den Digest Cycle starten. Neben Veränderungen am Datenmodell über Benutzereingaben gehört dazu beispielsweise auch das HTML-Rendering beim Scrollen. Zum anderen kann ermittelt werden, wie stark die Listener-Funktionen in den Watchern die Ausführungszyklen des Digest Cycles beeinflussen. Wird eine Digest-Cycle-Ausführung angestoßen und folgen zehn Ausführungen, so bedeutet dies, dass sich das Datenmodell erst nach zehn Iterationen stabilisiert hat.

(function trackSingleDigestCycleExecution() {
  // DOM-Element ermitteln, über das Zugriff 
  // auf den RootScope erlangt werden kann
  var element = angular.element($('body'));
  var scope = element.scope() || 
    element.isolateScope();
  console.assert(scope, 'Scope konnte nicht extrahiert werden');
  var $rootScope = 
    element.injector().get('$rootScope');
  var count = 0;
  $rootScope.$watch(function () {
    count += 1;
      console.log('Aufruf Nr. ', count);
  });
  var $q = element.injector().get('$q');
}());

Ausführungsdauer eines Idle Digest Cycles messen

Listing 3 ruft die $apply()-Funktion ohne Parameter auf dem rootScope auf, um die Ausführungszeit eines Idle Digest Cycles zu messen, also einer Digest-Cycle-Ausführung ohne Veränderungen im Datenmodell. Neben einer reinen Zeitmessung erstellt das Snippet auch ein CPU-Profil für die Ausführung. Mit diesen Werten kann analysiert werden, wie teuer die Ausführung der Watcher-Funktionen ist, d. h. insbesondere der Dirty State Expressions: Je mehr Watcher und je komplexer die Dirty-Check-Ausdrücke, desto länger dauert die Ausführung. Eine detaillierte Betrachtung erlaubt der Callstack, der mit dem CPU-Profil erstellt wird.

(function timeForIdleApplyCycle () {
angular.element('body').injector().invoke(function timeApply($rootScope) {
  // CPU-Profil erstellen/starten
  console.profile('IDC Ausführung');
  // Manuelle Zeitmessung starten
  var start = performance.now();
  // Digest Cycle mit $apply auf
  // Root-Scope starten
  $rootScope.$apply();
  // Manuelle Zeitmessung beenden
  var used = performance.now() - start;
  console.log('IDC Ausführung in ms:', used);
  // CPU-Profil beenden
  console.profileEnd();
});
}());

Ausgabe des Scope-Baums

Wie im Abschnitt zu den AngularJS-Konzepten erläutert, wird in jedem Digest Cycle der Dirty State des Scopes analysiert. Je komplexer der Scope ist, umso aufwendiger sind die Überprüfungen, die sich in einer längeren Ausführungszeit eines Idle Digest Cycle niederschlagen können. Listing 4 gibt den Teilbaum des Scopes in der DevTools-Konsole aus, der zu dem gewählten Element im DOM gehört. Das Element kann entweder über einen Selektor, oder über den Elementinspektor ausgewählt werden. Innerhalb des Scopes kann dann entlang der Baumstruktur über $parent und $childScopes navigiert werden.

Abb. 3: Flame-Chart zur Darstellung der Ausführungszeit und Aufruftiefe

Abb. 3: Flame-Chart zur Darstellung der Ausführungszeit und Aufruftiefe

Die Ausgabe kann zudem verwendet werden, um die Ergebnisse aus dem Memory-Profiling mit konkreten Werten zu betrachten: Identifiziert das Memory-Profiling beispielsweise Memory-Leaks, kann das entsprechende Scope-Element betrachtet werden (Listing 4).

angular.element(document.querySelector(".main-header")).scope();
// Alternativ kann ein Element mit dem Inspector ausgewählt werden.
// Das im Element Inspector gewählte Element 
// kann mittels $0 referenziert werden
angular.element($0).scope();

Scope-Werte verändern

Mit Listing 5 kann der Wert einer Scope-Variablen zur Laufzeit verändert werden. Dies ermöglicht beispielsweise, die Ausführung des Digest Cycles mit verschiedenen Belegungen zu analysieren oder die Ausgabe des Datenmodells im DOM zu evaluieren. Zu beachten ist, dass das Snippet den entsprechenden Scope zum Zeitpunkt der Ausführung mittels call by value und nicht call by reference kopiert. Wird der betrachtete Scope von AngularJS verändert, z. B. weil der Lifecycle des betrachteten Scopes beendet oder der Inhalt von AngularJS verändert wird, wird diese Änderung nicht in die Variable propagiert.

// Scope des gewählten Elements in einer
// Variable ablegen...
var scope = angular.element($0).scope();
// ... um schreibend darauf zugreifen zu können
scope.myObjekt.attribute = 'new value';
// Veränderung im Scope propagieren (s. o.)
scope.$digest();

Performance- und Memory-Profiling

Mit den Google Chrome DevTools kann ein CPU-, Memory- und Netzwerkprofil für die Ausführung einer Webapplikation erstellt werden. Im Nachfolgenden werden das CPU- und das Memory-Profiling näher betrachtet, da diese die Analyse der Digest-Cycle-Ausführung (Listing 3) und der Scope-Komplexität (Listing 4) ermöglichen. Das Netzwerk-Profiling wird nicht betrachtet, da es sich auf die Übertragung der Inhalte und nicht deren Verarbeitung auf der Clientseite bezieht und damit nicht im Fokus des Artikels liegt. Ebenfalls nicht betrachtet wird Batarang, eine Erweiterung für die Google Chrome DevTools zur Analyse von AngularJS-Applikation; obwohl Batarang lange Zeit eines der am häufigsten eingesetzten Werkzeuge für die AngularJS-Analyse war, können bestehende Fehler die Analyseergebnisse verzerren.

Ein JavaScript-CPU-Profil besteht aus einem Call Tree und einem Flame Chart. Der Call Tree gibt Auskunft über die Aufrufreihenfolge der JavaScript-Funktionen, das Flame Chart visualisiert die Aufrufreihenfolge über die Zeit. Ein Beispiel: Ein Block entspricht dem Aufruf einer JavaScript-Funktion, wobei die Blocklänge die Ausführungszeit und die Blockfarbe die JavaScript-Funktion angibt. Die vertikale Position beschreibt die Aufrufreihenfolge, d. h. der obere Block hat die darunterliegenden Blöcke aufgerufen. Mit einem Klick auf einen Codeblock kann direkt in die Codestelle gesprungen werden, die zu diesem Zeitpunkt ausgeführt wurde. Das Flame Chart in Abbildung 3 weist beispielsweise bei zwölf Sekunden auf eine tiefe Aufrufhierarchie hin.

Ein Memory-Profil zeigt die Verteilung des Speicherverbrauchs auf die Objekte einer JavaScript-Applikation und deren Beziehungen. So zeigt die Shallow Size die Größe des Speichers, den das Objekt selbst hält, insbesondere für Metainformationen und so genannte Immediate Values. Die Retained Size zeigt die Größe des Speichers, der beim Löschen des Objekts und aller dabei freigegebenen Referenzen frei werden würde, d. h. welcher Speicher vom Garbage Collector freigegeben werden könnte. Wie in Listing 4 erwähnt, kann dieses Memory Profile genutzt werden, um die Komplexität des Scopes im Speicher zu analysieren und Memory Leaks zu identifizieren.

Geschrieben von
Dr. Christian Straube
Dr. Christian Straube
Dr. Christian Straube arbeitet seit seiner Promotion zur kennzahlenbasierten Analyse von IT-Infrastrukturen an der Ludwig-Maximilians-Universität in München bei Zühlke Engineering. Als Projektleiter und Software Engineer modelliert, formalisiert und strukturiert er IT-Probleme, um Kundenprojekte erfolgreich umzusetzen. Erfahren Sie mehr auf seinem Blog.
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.