State Management und die Reaktion auf State-Veränderungen sind ein wichtiges Thema in Frontend-Applikationen. Aktuell setzt Angular dafür auf die Eigenentwicklung Zone.js. Mit Angular Signals wurde dieses Jahr ein vielversprechendes alternatives Reaktivitätssystem vorgestellt.

Als neue „reactive primitives“ bieten Signals eine neue Möglichkeit, auf Änderungen am State zu reagieren und die UI feingranular zu aktualisieren. Damit lässt sich die Change Detection deutlich effizienter durchführen. Das Konzept ist allerdings nicht neu: Preact.js, Solid.js und Vue.js verwenden bereits seit Jahren ähnliche Konstrukte.

Als Entwickler stellen sich mir jetzt vor allem drei Fragen:

  • Wie funktionieren Signals und was sind ihre Vorteile?
  • Wie werden Signals eingesetzt?
  • Welche Auswirkungen habe sie auf Angular?

Diese Fragen möchte ich im Artikel klären.

Wie funktionieren Angular Signals?

Signals sind Wrapper um eine Variable. Sowohl Lesen als auch Schreiben von Werten der Variablen wird durch Funktionsaufrufe auf den Wrapper umgesetzt. Durch die lesenden Methodenaufrufe kann der Wrapper eine Liste der Konsumenten pflegen. Wird der Wert des Signals geändert können so alle vom Signal abhängigen Konsumenten informiert werden. Wichtig: Sowohl das Lesen als auch das Schreiben eines Signals ist immer synchron!
Angular stellt einen readOnly- und einen writable-Typen zur Verfügung. Um Konsumenten eines Signals auf das Lesen von Werten zu beschränken, gibt es die Möglichkeit von einem writableSignal ein readOnly-Signal zu generieren.

const readOnlySignal: Signal<number> = signal(0);
readOnlySignal() // 0 (= Der Getter)
readOnlySignal.set(2) // error

const writableSignal: WritableSignal<number> = signal(0);
writableSignal.set(2) // 2
writableSignal.update((previousValue) => {return previousValue + 1}) // 3
const readOnlySignal2: Signal<number> = writableSignal. asReadonly();

const writableSignal2: WritableSignal<number[]> = singal([0]); // [0]
const writableSignal2.mutate((val) => val.push(1); // [0,1] Änderung In-Place

Effects

Effects sind Methoden die ein oder mehrere Signals konsumieren und jedes Mal durchgeführt werden, wenn eines der Signals seinen Wert ändert. Ein typischer Anwendungsfall für Effects ist das Loggen von Werten.
Achtung: Im Gegensatz zu Signals sind Effects immer asynchron!

loggingEffect = effect(() => console.log(writableSignal()))

Computed Angular Signals

Diese Signalart konsumiert beliebig viele Signals und generiert daraus einen Rückgabewert. Ändert sich eines der verwendeten Signals wird der Wert erneut berechnet und alle Konsumenten des Computed Signals werden benachrichtigt. Computed Signals bieten keine Methode zum direkten Setzen eines Wertes an.
Computed Signals sind immer asynchron.

computedSignal: Signal<number> = computed(() => this.readOnlySignal() + 1);

Signals, Effects und Computed Signals sind ab Angular 16 als Developer Preview verfügbar.

Auswirkungen auf Angular

Um die Vorteile von Angular Signals bezüglich Reaktivität nachvollziehen zu können, ist es hilfreich zu verstehen, wie Change Detection in Angular derzeit funktioniert. Dazu hier ein kleiner Exkurs:

Wie funktioniert Change Detection in Angular?

Change Detection beschreibt den Prozess, die UI synchron mit den Änderungen am Model der Applikation zu halten. Framework-agnostisch wird dieser Mechanismus als Reaktivität bezeichnet. Angular stellt dafür einen Ablauf zur Verfügung, der nach einer Änderung am Model angeworfen wird: Der Change Detection Cycle (CDC).

Beginnend bei der Root-Komponente werden alle Komponenten sequenziell bis zum letzten Blatt durchlaufen. Dabei werden die von der UI referenzierten Werte auf Änderungen untersucht und gegebenenfalls die UI aktualisiert. Angular geht dabei sehr performant vor. So können mehrere tausend Checks in Millisekunden durchgeführt werden. Dennoch können CDCs in größeren Anwendungen zu einem Performanceproblem werden.

Was triggert einen CDC?

Angular verwendet Zone.js, um beim Hochfahren bestimmte Browser-APIs so abzuändern, dass sie neben ihrer eigentlichen Funktion zusätzlich einen CDC anwerfen. Das Vorgehen, Funktionalität zur Laufzeit zu ändern, wird als Monkey Patching bezeichnet.

Ermöglicht wird dies durch Zonen von Zone.js, die Tasks in gesonderten Execution Contexts ausführen und Hooks anbieten, wenn bestimmte Events, wie das vollständige Ausführen aller Tasks, eintreffen. Vor allem bei asynchronen Tasks, wie der Kommunikation mit einem Webserver über HTTP, ist die Ausführung innerhalb von Zonen essenziell. Durch die Hooks kann auf die Events reagiert und ein CDC angeworfen werden.

Schwächen der aktuellen Lösung

Das Triggern eines Zone-Hooks macht lediglich eine Aussage darüber, dass sich etwas geändert haben könnte. Es liefert aber keine Aussage darüber, ob sich überhaupt etwas geändert hat. Und wenn doch, dann wird nicht klar, was sich ändert. Das wird erst im Verlauf des daraufhin ausgelösten CDC festgestellt, der alle Komponenten durchlaufen muss.

Hier gibt es also Optimierungspotential und das ist der Punkt an dem Signals aufsetzen. Mit der On-push-Change-Detection-Strategie lässt sich zwar die Anzahl der zu durchlaufenden Komponenten verringern, das Grundproblem, dass deutlich mehr Abgleiche stattfinden als notwendig, bleibt aber bestehen.

Um es auf den Punkt zu bringen: Es werden CDCs ausgelöst, die nicht notwendig sind, weil sich nichts geändert hat. CDCs überprüfen jedes Mal unnötig große Teile der Applikation.

Diese Probleme werden durch Signals gelöst.

Angular Signals als alternatives Reaktivitätssystem

Die Kombination aus Signals und Effects ermöglicht es Angular, ein alternatives Reaktivitätssystem zu etablieren:

  • Ein Signal beinhaltet einen Wert, der in der UI angezeigt und aktualisiert werden soll.
  • Die UI verwendet eine Referenz auf das Signal.
  • Ein Effekt beobachtet das Signal und aktualisiert jedes Mal die UI, wenn sich der Wert des Signals ändert.

Die Synchronisierung der UI mit dem State der Signals verrichtet Angular im Hintergrund.

Da Zone.js und Signals sehr unterschiedliche Annahmen bezüglich des Data Flows in Applikationen treffen, wird es zukünftig die Möglichkeit geben auf Komponentenbasis zu entscheiden, ob sie auf Zone.js oder Signals basieren. Beide Komponententypen sollen problemlos in derselben Applikation miteinander interagieren können. So können Entwicklungsteams Komponente für Komponente auf Signals umstellen. Laut Aussage eines Maintainer/Authors des RFC hat Google aktuell keine Pläne, den Support von Zone.js in Angular einzustellen (Kommentar von April 2023).

In reinen Signal-Komponenten wird die Change Detection damit deutlich effizienter. Sie wird nur noch dann durchgeführt, wenn sich ein Signal, dass in der UI referenziert wird, ändert. Während ein Zone.js-Hook triggern kann, ohne dass es eine Änderung am State gab, ist dies bei Signals nicht mehr möglich.

Des Weiteren ist bekannt, welcher Teil der UI aktualisiert werden muss. Angular bleibt bei dem Konzept, dass nicht individuelle Bindings sondern Bereiche aktualisiert werden. Die Bereiche sind aber wesentlich kleiner als beim Durchlauf eines Zone.js-CDC. Dafür unterteilt Angular Komponenten in kleinere Teile, sogenannte Views, und rendert nur die betroffenen Views neu.

Bei einer Mischung von Zone.js- und Signal-Komponenten in einer Applikation werden die Signal-Komponenten beim Durchlaufen des Zone.js-CDC ausgenommen und separat abgehandelt.

Nutzung von Angular Signals in Komponenten

Um die genannten Vorteile von Signals nutzen zu können, ist für die Zukunft angedacht, dass Komponenten entweder als Zone-basierte oder als Signal-basierte Komponente definiert werden. Der aktuelle Vorschlag besteht darin, ein neues Attribut zur @Component-Annotation hinzuzufügen. Signals sind aber nicht auf Signal-basierte Komponenten beschränkt, sondern können auch in Zone-basierten Komponenten verwendet werden.

@Component({
  signals: true,
  …
})

Die Verknüpfung des Templates mit einem Signal funktioniert über den Aufruf des Getters:
component.ts

count = signal(0);

component.html

<p>Count {{ count() }}</p>

Aktuell ist es in der Regel keine gute Idee, im Template Methodenaufrufe zu referenzieren, da diese dann bei jedem CDC erneut ausgeführt würden. Bei Signals gibt es dieses Problem nicht. Im Gegenteil: Es ist explizit erlaubt (und notwendig), da sonst das Signal selbst und nicht der aktuelle Wert des Signals referenziert wird.

Ob Nicht-Signal-Bindings erlaubt sein werden und wie damit umgegangen werden wird, ist Bestandteil der aktuellen Debatte. Zum aktuellen Zeitpunkt (Oktober 2023) sind Signal-basierte Komponenten noch in keiner Angular-Version verfügbar. Sie sollen frühestens mit Angular 18 zur Verfügung stehen.

Angular Signals und RxJS

Signals sollen RxJs nicht ersetzen, sondern ergänzen. Dafür wurde ein neues Paket zur Core Library von Angular hinzugefügt: @angular/core/rxjs-interop
Darüber werden die Funktionen toObservable und toSignal zur Verfügung gestellt, die es erlauben, die beiden Konstrukte in das jeweils andere umzuwandeln.

toObservable

  countSignal = signal(0);
  count$ = toObservable(this.count);

Dabei wird im Hintergrund ein Effect gestartet, der bei jeder Änderung des Signals dafür sorgt, dass das Observable den Wert ausgibt:

toSignal

  interval$ = interval(1000);
  intervalSignal: Signal<number | undefined> = toSignal(this.interval$);

Dabei gilt:

  • Wenn ein Observable einen Wert emitted, wird der Setter des Signals aufgerufen
  • Wenn ein Observable einen Error emitted, wird der Error von Angular geworfen
  • Beim Emitten von „Complete“ bleibt der letzte Wert des Signals erhalten

Da das Observable keinen initialen Wert besitzt, bis der erste Wert emitted wird, muss „undefined“ in den Typen aufgenommen werden. Um die Typisierung auf <number> umzustellen, kann ein initialer Wert bei der Umwandlung mitgegeben werden:

  intervalSignal2: Signal<number> = toSignal(this.interval$, {initialValue: 0});

Achtung: Angular Signals sind keine Streams

Signals geben, im Gegensatz zu Observables, nicht jeden Zwischenwert aus, der ihnen zugewiesen wird. Gibt es mehrere Zuweisungen nacheinander, so werden diese synchron ausgeführt und die Konsumenten erst im Anschluss benachrichtigt. Dadurch entsteht ein Verhalten, das für den Observable-erfahrenen Entwickler auf den ersten Blick überraschend sein kann:

const writableSignal: WritableSignal<number> = signal(0);
loggingEffect = effect(() => console.log(writableSignal()))
writableSignal.set(1);
writableSignal.set(2);
writableSignal.set(3);

Da das Setzen der Werte synchron, das Update durch den Effect aber asynchron stattfinden, werden erst der initale Wert 0 und anschließend der finale Wert 3 geloggt. Das gleiche Verhalten ist auch bei Computed Signals der Fall. Dieser Batch-Verarbeitung sollten wir uns bewusst sein, wenn wir mit Signals arbeiten.

Einsatz im State Management

Signals sind prädestiniert dafür, im Kontext State Management eingesetzt zu werden. Eine eigene Lösung für kleinere Projekte kann auch heute schon über einen State Service implementiert werden. Angular selbst wird dabei keine eigene State Library anbieten. Die verbreiteten State-Management-Bibliotheken NgRx und NGXS haben bereits Diskussionen über den Einsatz von Signals gestartet.
https://github.com/ngrx/platform/discussions/3796
https://github.com/ngxs/store/discussions/1977

Fazit

Angular arbeitet mit Signals an einem lange ersehntem System, dass das Thema Reaktivität für Entwickler vereinfacht und gleichzeitig die Performance der Change Detection steigert. Damit wird es zudem möglich, sich von der Abhängigkeit der Bibliothek Zone.js zu lösen. Einen Zwang zum Wechsel wird es aber nicht geben. Google hat aktuell keine Pläne, den Support von Zone.js in Angular einzustellen (Kommentar im RFC von April 2023).

Aktuell ist der Einsatz von Signals aber noch mit Vorsicht zu genießen: Zwar kann ab Angular 16 mit Signals als Developer Preview experimentiert werden, das bedeutet aber auch, dass die API sich noch mit Breaking Changes ändern kann. Dazu kommt, dass das Herzstück des neuen Reaktivitätssystems, die Signal-basierten Komponenten, erst frühestens mit Angular 18 zur Verfügung stehen werden. Ohne diese wird aber die bessere Performanz nicht erreicht, da unter der Haube weiterhin das alte Change Detection System verwendet wird.

Signals und die auf ihnen basierenden Komponenten sehen vielversprechend aus und haben gute Chancen darauf, fester Bestandteil der Zukunft von Angular zu werden. Solange sie nicht veröffentlicht und ihre Auswirkungen noch nicht vollständig bekannt sind, muss ein abschließendes Urteil aber noch auf sich warten lassen. Man wird sich also noch etwas gedulden müssen, bis es richtig losgeht.

Quellen

Offizielles RFC
https://github.com/angular/angular/discussions/49685

Javascript Execution Contexts
https://www.freecodecamp.org/news/how-javascript-works-behind-the-scene-javascript-execution-context/

Zone.js gepatchte APIs
https://github.com/angular/angular/blob/main/packages/zone.js/STANDARD-APIS.md

Angular.io Signals
https://angular.io/guide/signals

RFC Signal API
https://github.com/angular/angular/discussions/49683

Alle Beiträge von Alexander Möller

Fullstack Developer bei OPITZ CONSULTING

Schreibe einen Kommentar