Angular anschieben

Angular: Performanceoptimierung mit „OnPush“

Manfred Steyer

©Shutterstock / ESB Professional

Die Geschwindigkeit von Datenbindungen ist in Angular hoch. Angular verfügt mit OnPush über einen höchst performanten Modus, um Datenbindungen zielgerichtet zu aktualisieren. Der Einsatz von Immutables und Observables macht dies möglich.

Mit der Strategie OnPush kann Angular zielgerichtet herausfinden, welche Datenbindungen zu aktualisieren sind. Damit dieser äußerst performante Modus funktioniert, muss die Anwendung Immutables und Observables einsetzen. Die Datenbindung in Angular ist sehr schnell. Der Angular-Compiler erzeugt beispielsweise für Datenbindungen Code, der sich von der für JavaScript verantwortlichen VM im Browser besonders gut optimieren lässt. Solche sogenannten monomorphen Codestrecken erreichen ungefähr die Geschwindigkeit von nativem C-Code.

Damit sich alle JavaScript-Objekte ohne weiteres binden lassen, nutzt Angular jedoch standardmäßig Dirty Checking. Das bedeutet, dass jeweils nach dem Ausführen von Event Handlern alle Datenbindungen auf Änderungen geprüft werden. Genau hier setzt die Datenbindungsstrategie OnPush an. In diesem Modus findet Angular zielgerichtet heraus, welche Bindungen zu aktualisieren sind. Damit diese Strategie funktioniert, muss die Anwendung besondere Datenstrukturen verwenden, nämlich Immutables und Observables. In diesem Artikel gehe ich auf OnPush ein und zeige, wie sich Immutables und Observables einsetzen lassen.

Immutables

Der Name lässt es schon vermuten: Immutables sind Datenstrukturen, die nicht veränderbar sind. Ändern sich die damit beschriebenen Objekte, ersetzt die Anwendung das gesamte Immutable durch ein neues. Das bedeutet im Umkehrschluss aber auch, dass Angular nur noch Objektreferenzen und nicht alle einzelnen Properties auf Änderungen prüfen muss.

Die Methode delay in Listing 1 veranschaulicht den Umgang mit Immutables. Ihre Aufgabe ist es, für den ersten Flug eines Arrays eine Verspätung von 15 Minuten zu vermerken. Zuerst definiert das Beispiel Variablen, die auf das gesamte Array, auf den betroffenen Flug sowie auf dessen Datum verweisen. Sie nennen sich oldFlights, oldFlight und oldFlightDate.

Die danach eingerichteten Variablen repräsentieren den neuen Zustand. Das Objekt newFlightDate verweist auf das geänderte Datum. Dieses Datum übernimmt newFlight gemeinsam mit den restlichen Werten von oldFlight. Zusätzlich verweist newFlights auf ein neues Array, das sich aus dem neuen Flug und den restlichen nicht geänderten Flügen zusammensetzt.

Um an diese Flüge zu kommen, nutzt delay die Arraymethode slice. Da slice ein Array mit den ausgewählten Einträgen zurückliefert, nutzt das Beispiel an dieser Stelle den aus drei Punkten bestehende Spread-Operator von ECMAScript 2015. Er fügt an dieser Stelle die Einträge aus dem von slice gelieferten Array direkt in das übergeordnete Array ein und vermeidet somit ein Array im Array. Diese Vorgehensweise veranschaulicht auch, dass Routinen nicht geänderte Teilbäume aus alten Immutables unverändert übernehmen können.

Danach verstauen wir das neue Array in der Ausgangsvariable this.flights und ändern somit seine Objektreferenz. Die Debug-Ausgaben am Ende veranschaulichen, dass eine Anwendung nun Änderungen am Array oder an den einzelnen Flügen sehr einfach entdecken kann, ein einziger Vergleich reicht hierzu.

delay() {

  const ONE_MINUTE = 1000 * 60;

  let oldFlights = this.flights;
  let oldFlight = oldFlights[0];
  let oldFlightDate = new Date(oldFlight.date);

  let newFlightDate = new Date(oldFlightDate.getTime() + ONE_MINUTE * 15);

  let newFlight =  {
    id: oldFlight.id,
    from: oldFlight.from,
    to: oldFlight.to,
    date: newFlightDate.toISOString()
  };

  let newFlights = [
    newFlight,
    ...oldFlights.slice(1, this.fluege.length-1)
  ];

  this.flights = newFlights;

  console.debug("Array: " + (oldFlights == newFlights)); // false
  console.debug("#0: " + (oldFlights[0] == newFlights[0])); // false
  console.debug("#1: " + (oldFlights[1] == newFlights[1])); // true

}

Immutables und TypeScript

Als Ergänzung zum Spread-Operator für Arrays, den wir im letzten Abschnitt betrachtet haben, hat TypeScript im Laufe der Zeit weitere Spracherweiterungen bekommen, die die Arbeit mit Immutables vereinfachen. Bei der einen handelt es sich um den Access Modifier readonly:

export class Flight {
  readonly id: number;
  readonly from: string;
  readonly to: string;
  readonly date: string;
}

Die damit markierten Eigenschaften lassen sich nur im Konstruktor sowie beim Erzeugen eines Objekts über ein Objekt-Literal setzen:

let flight: Flight = { id: 7, from: 'Graz', to: 'Hamburg', date: '2018-12-24T17:00:00.000+01:00' };
// flight.id = 8; // Fehler, weil readonly

Bei einem späteren Aktualisieren solcher Eigenschaften meldet der Compiler einen Fehler.

Ein weiteres Sprachmerkmal, das beim Einsatz von Immutables hilfreich ist, ist der Spread-Operator für Objekte. Statt

  let newFlight =  {
    id: oldFlight.id,
    from: oldFlight.from,
    to: oldFlight.to,
    date: newFlightDate.toISOString()
  };

können Sie damit

let newFlight =  {
    ...oldFlight,
    date: newFlightDate.toISOString()
  };

schreiben, um zum einen alle Eigenschaften aus oldFlight zu übernehmen und zum anderen ein neues Datum zu vergeben.

Immutables und Datenbindung

Wie eingangs erwähnt, traversiert Angular standardmäßig nach dem Ausführen der Event Handler den gesamten Komponentenbaum. Dabei aktualisiert das Framework alle geänderten Property Bindings. Kommen Immutables zum Einsatz, kann Angular jedoch die vielen Teilbäume erkennen, die nicht von Änderungen betroffen sind, und sie zur Steigerung der Leistung außen vor lassen.

Abbildung 1 veranschaulicht das. Dabei wird davon ausgegangen, dass die Methode in Listing 1 das Array flights sowie einen Flug darin aktualisiert hat. Dank Immutables kann Angular ohne Aufwand prüfen, ob die über Property Bindings weitergereichten Daten geändert wurden. Wie oben beschrieben ist hierzu pro Objekt lediglich ein einziger Vergleich notwendig. Im betrachteten Fall erkennt Angular, dass sich lediglich einer der weitergereichten Flüge geändert hat, und betrachtet nur den davon betroffenen Teilbaum. Alle anderen Teilbäume ignoriert es. Da das SPA-Framework bei diesem Vorgehen im besten Fall lediglich einem einzigen Pfad durch den Baum folgenden muss, birgt das bei größeren Komponentenbäumen ein enormes Potenzial in sich.

Abb. 1: Immutables in Angular

Abb. 1: Immutables in Angular

Damit eine Komponente in den Genuss dieses Verfahrens kommt, ist die changeDetection-Eigenschaft im Component-Dekorator auf OnPush zu setzen (Listing 2).

import { Flug } from '../entities/flug';
import { Input, Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'flug-card',
  template: require('./flug-card.component.html'),
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlugCard {
  @Input() item: Flug;
}

Das führt dazu, dass Angular davon ausgeht, dass hinter allen eingehenden Property Bindings Immutables stehen. Stellt Angular durch Vergleich der Objektreferenzen beim Property Binding fest, dass die zuletzt ausgeführten Event Handler diese Objekte nicht verändert haben, schließt es den gesamten Teilbaum von der weiteren Betrachtung aus.

Observables

Neben Immutables unterstützt Angular auch Observables zur Optimierung der Performance. Diese Objekte benachrichtigen Angular, wenn es eine neue Version eines gebundenen Objekts gibt. Komponenten können somit von der Änderungsverfolgung ausgeschlossen werden, bis sie eine solche Nachricht erhalten.

Abbildung 2 veranschaulicht dieses Vorgehen. Hier sind die einzelnen Flüge durch Observables repräsentiert. Um diesen Umstand hervorzuheben, hat sich die Nutzung eines Dollarzeichens als Suffix eingebürgert. Die beiden Card-Komponenten nutzen nach wie vor die Strategie OnPush. Da sich die Referenz auf die übergebenen Observables nicht ändert, schließt Angular sie von der Änderungsverfolgung aus. Allerdings kann die Anwendung Angular anweisen, beim Empfang einer neuen Version des Fluges die Datenbindung zu aktualisieren. Ähnlich wie beim Einsatz von Immutables prüft Angular in diesem Fall auch sämtliche übergeordnete Komponenten auf Änderungen, zumal die Daten ja per Definition von oben nach unten fließen und somit auch schon weiter oben zum Einsatz kommen können.

Abb. 2: Gebundene Observables in Angular

Abb. 2: Gebundene Observables in Angular

Ein ähnliches Gedankenexperiment hierzu veranschaulicht Abbildung 3. Dort wird jedoch das Array mit den Flügen durch ein Observable repräsentiert. Nutzt nun zusätzlich die FlightSearch-Komponente die Strategie OnPush, schließt Angular auch sie zunächst von der Änderungsverfolgung aus. Meldet das Observable eine neue Version des Arrays, gleicht Angular den Zustand von FlightSearch mit dem UI ab. Dasselbe gilt auch hier für sämtliche übergeordnete Komponenten, die in der Abbildung nicht ersichtlich sind. Wie mit den untergeordneten Card-Komponenten zu verfahren ist, hängt von ihrer Change-Detection-Strategie ab. Kommen hier Immutables mit OnPush zum Einsatz, betrachtet Angular – wie oben beschrieben – nur den Teilbaum mit Änderungen. Ansonsten iteriert es sämtliche untergeordnete Teilbäume.

Abb. 3: Zusammenspiel von Immutables und Observables

Abb. 3: Zusammenspiel von Immutables und Observables

Datenbindung mit Observables

Um das zuletzt beschriebene Vorgehen zu veranschaulichen, verwaltet das hier beschriebene Beispiel die abgerufenen Flüge mit einem BehaviorSubject:

import { ReplaySubject } from 'rxjs/ReplaySubject'
[...]
private flightsSubject = new BehaviorSubject<Flight[]>([]);
public flights$ = flightsSubject.asObservable();

Dabei handelt es sich um ein Observable, das die zu versendenden Daten direkt über Methoden entgegennimmt und an einen oder mehrere Interessenten weiterleitet. Darüber hinaus merkt sich das BehaviorSubject den zuletzt versendeten Wert, sodass ein neuer Interessent diesen sofort empfangen kann. Den Initialwert nimmt es über den Konstruktor entgegen.

Damit nicht jeder Konsument selbst Daten über das Subject versenden kann, ist es privat. Lediglich ein davon abgeleitetes Observable flights$, das nur lesenden Zugriff gewährt, wird als öffentliche Eigenschaft bereitgestellt.

Neue Versionen des Flugarrays versendet das Beispiel mit der Methode next:

this.flightsSubject.next(newFlights);

Mit der async Pipe lässt sich das Observable in Datenbindungsausdrücken nutzen. Dazu trägt sie sich als Interessent beim Observable ein und kümmert sich beim Eintreffen neuer Daten um die Aktualisierung der GUI:

<div *ngFor="let flight of flights$ | async">
  <flight-card [item]="flight" […]></flight-card>
</div>    

Fazit

Dank OnPush muss Angular nicht mehr sämtliche Komponenten auf Änderungen prüfen, sondern im besten Fall nur einen einzigen Pfad von der Wurzel bis zur betroffenen Stelle im Komponentenbaum näher betrachten. Das erhöht die Datenbindungsperformance drastisch.

Damit OnPush möglich ist, muss die Anwendung jedoch auf Immutables und Observables setzen. Während schon eine dieser Datenstrukturen reicht, um OnPush prinzipiell aktivieren zu können, ergänzen sich diese beiden Konzepte prima: Observables weisen auf jene Komponente hin, in der die erste Änderung entdeckt wurde, und mit Immutables kann sich Angular am Komponentenbaum entlang weiter nach unten hangeln und dabei erkennen, welche Kindkomponenten ebenfalls zu aktualisieren sind.

Etwas mühsam gestaltet sich anfangs die Arbeit mit Immutables: Anstatt Eigenschaften zu ändern muss man sich einen neuen Programmierstil antrainieren, der den Austausch aller geänderten Objekte zur Folge hat.

Wer sich unsicher ist, ob dieser Mehraufwand gerechtfertigt ist, kann OnPush auch nur für ausgewählte Komponenten aktivieren, die es wert sind, optimiert zu werden. In einem solchen Fall sind auch nur dort die genannten Datenstrukturen nötig. Wer hingegen immer die bestmögliche Performance erreichen will, nutzt OnPush durchgängig. Bibliotheken wie NgRx, die zum einen beim Einsatz von Observables unterstützen und zum anderen von vornherein die Nutzung von Immutables erzwingen, können dabei helfen.

Geschrieben von
Manfred Steyer
Manfred Steyer
Manfred Steyer ist selbstständiger Trainer und Berater mit Fokus auf Angular 2. In seinem aktuellen Buch "AngularJS: Moderne Webanwendungen und Single Page Applications mit JavaScript" behandelt er die vielen Seiten des populären JavaScript-Frameworks aus der Feder von Google. Web: www.softwarearchitekt.at
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: