Angular-Renaissance Teil 2: Reaktives Programmieren mit Signalen

Mit Signalen bietet Angular 17 ein leichtgewichtiges Werkzeug, das reaktives Programmieren spürbar erleichtert.

In Pocket speichern vorlesen Druckansicht 14 Kommentare lesen
Grünes Eisenbahnsignal

(Bild: N-sky/Shutterstock.com)

Lesezeit: 13 Min.
Von
  • Rainer Hahnekamp
Inhaltsverzeichnis

Nachdem sich der erste Teil dieser Artikelserie um Hydration und Server-Side Rendering drehte, geht es nun um Signale. Die Integration der Signale in Angular zog sich durch die komplette 17er-Reihe. Version 17.1, 17.2 und 17.3 brachten sukzessive neue Features, die dieser Artikel erläutert. Signale sind vermutlich das Feature, das die meiste Aufmerksamkeit erhält – und das zu Recht.

Rainer Hahnekamp

Rainer Hahnekamp ist Trainer und Berater im Expertennetzwerk von AngularArchitects.io und dort für Schulungen rund um Angular verantwortlich. Darüber hinaus gibt er mit ng-news auf YouTube einen wöchentlichen Kurzüberblick über relevante Ereignisse im Angular-Umfeld.

Angular-Renaissance – dreiteilige Serie

Signale fanden bereits in Version 16 ihren Einzug in das Framework, damals jedoch noch unter dem Status Developer Preview. Das Konzept der Signale ist ein neuer Ansatz, wie Frontend-Frameworks herausfinden, an welchen Stellen sie das DOM updaten müssen. Bei Angular stand zuvor immer der Component Tree im Mittelpunkt, die Signale ändern das jetzt.

Die Implementierung von Signalen besteht primär aus drei Methoden: signal(), computed() und effect(). Dabei handelt es sich um reaktive Strukturen. computed() und effect() können Abhängigkeiten zu einem oder mehreren Signalen erstellen. Signale, die einen Wert produzieren, agieren als Producer. Signale, die eine Abhängigkeit zu einem Producer besitzen, sind die Consumer. Es kann auch der Fall eintreten, dass ein Signal zugleich Producer und Consumer ist. Dadurch ist der gesamte State einer Anwendung in einem Graphen von Signalen abgebildet.

Wichtig ist in diesem Zusammenhang der reaktive Kontext. Signale sind nämlich nicht immer reaktiv. Sie benachrichtigen ihre Consumer nur dann, wenn der zugrundeliegende Graph innerhalb des reaktiven Kontexts läuft. Der reaktive Kontext ist die Verwendung eines Signals im Template oder aber der Zugriff auf ein Signal in effect().

Da der Begriff "Signal" mehrdeutig ist, an dieser Stelle eine kurze Klarstellung: Neben dem obigen vorgestellten Konzept der Signale liefern die Funktionen computed() sowie signal() den Datentyp [WritableSignal] beziehungsweise [Signal] zurück. Es gibt also vier Ausprägungen, die auf den Begriff Signal zutreffen: Signale als Konzept, Signale als dessen Implementierung in Angular, Signal als Datentyp und signal als Funktion.

Der Wechsel vom Component Tree zum Graphen hat die Konsequenz, dass Angular das Rendering als einen Seiteneffekt betrachtet. Benachrichtigt ein Signal eine Komponente über eine Änderung, dann startet diese Komponente das Rendering als Seiteneffekt.

Angulars Rendering mit Signalen

(Bild: Rainer Hahnekamp)

Um das neue Rendering-Modell vollständig im Framework aufzunehmen, ist noch eine spezielle Komponente notwendig, die Signalkomponente. Signalkomponenten wurde lange Zeit für Angular 17 angekündigt, dann aber auf Version 18 oder 19 verschoben.

Ferner war der Plan, die bestehende Signalfunktionalität mit Version 17 aus der Developer Preview zu entlassen. Das ist nicht beziehungsweise nur teilweise der Fall.

Lediglich die zwei Funktionen signal() und computed() sind als stabil gekennzeichnet. effect() hingegen ist nach wie vor eine Developer Preview. Der Hauptgrund ist, dass Code in effect() auf Funktionen des Angular-Frameworks zugreift. Sollten diese Funktionen des Angular-Frameworks wiederum auf Signale zugreifen, würde effect() diese zusätzlich aufnehmen und Entwickler den Überblick verlieren.

Zwar sind signal() und computed() stabil, jedoch kommt man nicht ohne effect() aus, wodurch die Signale in Summe nicht wirklich "stable" sind.

Es gibt einen Unterschied zwischen dem Signal (Typ, der signal() erstellt) in Version 16 und 17. War es in Angular 16 noch möglich, den Wert über eine mutate()-Funktion zu ändern, muss eine Signaländerung nun ausschließlich immutable erfolgen.

Zum Beispiel war in Version 16 folgender Code problemlos möglich:

const city = signal({
 name: 'Vienna',
 population: 1_982_097
})

city.mutate(value => value.name = 'Wien')

Ab Version 17 muss eine Änderung ausschließlich über set() oder update() erfolgen.

const city = signal({
 name: 'Vienna',
 population: 1_982_097
})

city.update(value => ({...value, name: 'Wien'}))
city.set({name: 'Berlin', population: 3_755_251});

Hier müssen Entwicklerinnen und Entwickler eine neue Objektreferenz erstellen, damit das Signal die Notifikationen ausschickt.

Wer davon enttäuscht war, dass das Angular-Team in letzter Minute effect() auf den Status einer Developer Preview gesetzt hat, erhält noch ein Zuckerl, mit dem nicht zu rechnen war: Die Local Change Detection mittels OnPush, die jedoch nur in Verbindung mit einem Signal funktioniert.

Ein Frontend-Framework kann auf unterschiedliche Arten darüber informiert werden, dass ein Rendering stattfinden muss. Bis Signalkomponenten erscheinen, ist bei Angular dafür die Change Detection verantwortlich. Mit dem bestehenden Komponentenmodell weiß das Framework nicht, wann und wo sich der State ändert und ein DOM-Update notwendig ist.

Aus diesem Grund läuft im Hintergrund die Bibliothek zone.js. Auch zone.js wird mit Erscheinen der Signalkomponenten Geschichte sein. Die Bibliothek weiß über jedes DOM-Ereignis Bescheid und auch darüber, wann asynchrone Tasks beendet sind. Bei beiden Ereignissen könnte ein Update notwendig sein.

Daher löst zone.js die Change Detection aus. Angular geht dafür über den Komponentenbaum hinweg durch jede Komponente und überprüft, ob es eine Änderung gab. Falls ja, aktualisiert es den entsprechenden Teil im DOM.

Change Detection mit Standardeinstellungen

(Bild: Rainer Hahnekamp)

Dieser Vorgang ist nicht sehr effizient. Wenn beispielsweise ein Event Handler lediglich eine Ausgabe an die Konsole schickt, ändert sich der State nicht und die Change Detection läuft somit umsonst.

Es gibt nun eine zusätzliche Komponenteneinstellung zum Setzen der Change Detection auf OnPush. Dann überprüft die Change Detection diese Komponente und ihre Kinder nur dann, wenn sie als "dirty" markiert ist.

Unterschiedliche Prozesse können diese Markierung setzen. Ein paar Beispiele:

  • Ein DOM-Event tritt auf, das ein Event Handler behandelt.
  • Eine Elternkomponente übergibt Daten mit einer neuen Objektreferenz an die Kindkomponente.
  • Im Template wird die async-Pipe gesetzt.
  • Ein Signal löst im Template einen neuen Wert aus.

Die Markierung "dirty" findet jedoch nicht nur in der eigentlichen Komponente statt, sondern muss sich auch auf die Elternkomponenten beziehen. Sollte für eine Elternkomponente auch OnPush gelten, würde die Change Detection nicht zum Kind vordringen.

Befindet sich eine Komponente sehr tief im Component Tree, überprüft Angular – trotz OnPush – auch die Elternkomponenten.

Change Detection mit OnPush und Standard gemischt: Event auf OnPush

(Bild: Rainer Hahnekamp)

Change Detection mit OnPush und Standard gemischt: Event auf Standard

(Bild: Rainer Hahnekamp)

Angular 17.0 führt nun die Local Change Detection ein, bei der die Change Detection die Elternkomponente überspringt.

Damit die Local Change Detection funktioniert, muss erstens die State-Änderung in einem Signal stattfinden. Zweitens müssen alle Elternkomponenten OnPush besitzen.

Local Change Detection bei Signalwechsel

(Bild: Rainer Hahnekamp)

Folgender Code stellt ein minimales Beispiel dar:

@Component({
 template: `
     <div>
         <mat-table [dataSource]="dataSource">
         <!-- code für HTML –->
         </mat-table>
         <div class="flex items-center">
             @if (lastUpdate) {
                 <app-timer [lastUpdate]="lastUpdate"></app-timer>
             }
             <button (click)="refresh()">Refresh</button>
         </div>
     </div>
 `,
 standalone: true,
 imports: [MatTableModule, TimerComponent],
 changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListComponent implements OnInit {
 lastUpdate: Date | undefined
 dataSource = new MatTableDataSource<Holiday[]>([]);
 displayedColumns = ['title', 'description'];

 ngOnInit() {
   this.refresh()
 }

 refresh() {
   fetch('https://api.eternal-holidays.net/holiday').then(res => res.json()).then(value => {
     this.lastUpdate = new Date();
     this.dataSource.data = value;
   });
 }
}


@Component({
 selector: 'app-timer',
 template: `<span>Last Updated: {{ lastUpdateInSeconds() }}</span>`,
 standalone: true,
 changeDetection: ChangeDetectionStrategy.OnPush,
 imports: [DatePipe, DecimalPipe, AsyncPipe]
})
export class TimerComponent {
 @Input() lastUpdate = new Date();
 lastUpdateInSeconds = signal(0)

 constructor() {
   setInterval(() => {
     this.lastUpdateInSeconds.set((new Date().getTime() - this.lastUpdate.getTime()) / 1_000);
   }, 1000);
 }
}

Im Listing ist die Komponente TimerComponent ein Kind der ListComponent. TimerComponent zeigt die vergangenen Sekunden seit dem letzten Update an. Dazu aktualisiert die Komponente den Wert von lastUpdateInSeconds im Sekundentakt.

Obwohl beide Komponenten die Property OnPush besitzen, würde die Change Detection in Angular 16 jedes Mal auch die ListComponent überprüfen. Ab Angular 17 findet die Überprüfung nur in der TimerComponent statt. Das allerdings nur deswegen, weil lastUpdateInSeconds() ein Signal ist. Sollte es eine normale Property der Klasse sein, würde die Local Change Detection nicht greifen.