Angular-Renaissance Teil 2: Reaktives Programmieren mit Signalen
Mit Signalen bietet Angular 17 ein leichtgewichtiges Werkzeug, das reaktives Programmieren spürbar erleichtert.
- Rainer Hahnekamp
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.
Crashkurs Signale
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.
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.
Last-Minute-Änderungen
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.
Local Change Detection
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.
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.
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.
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.
Signalbasierte API
Neben der Local Change Detection gibt es in den Versionen 17.1 bis 17.3 eine neue Version der API, die die Kommunikation der Komponenten betrifft.
So gibt es nun statt der Dekoratoren @Input
, @Output
, @ViewChild
, @ContentChild
sowie @ViewChildren
und @ContentChildren
nur noch Funktionen. Mit Ausnahme von output
liefern diese Funktionen Signale zurück.
Das gewährleistet die Reaktivität bereits von Beginn an. Die Lifecycle Hooks OnInit
oder OnChanges
sind nicht mehr notwendig. Die beiden Signal-eigenen Funktionen computed
und effect
bieten bereits nativ Seiteneffekte und abgeleitete Werte an.
input() statt @Input
Folgendes Beispiel veranschaulicht, dass das "alte" Property Binding nicht immer trivial ist.
Export class CustomerComponent implements OnInit, OnChanges {
@Input() name = '';
@Input() customer: Customer | undefined;
constructor() {
console.log(this.customer); // runs first and will be guaranteed undefined
}
// runs once and after constructor and first run of ngOnChanges
ngOnInit(): void {
console.log(this.customer); // will be of type Customer
}
// runs second, never, or multiple times
ngOnChanges(changes: SimpleChanges): void {
if ('customer' in changes) {
runExpensiveTask(changes['customer'].currentValue);
}
}
}
Ein weiterer Aspekt betrifft die Typsicherheit. Property Binding kann durch das required[/coode]-Attribut zwingend ein Binding verlangen, allerdings ist das im Alltagsgebrauch nicht sehr nützlich. Bei einem Zugriff auf die Property im Constructor ist dieser einmal garantiert [code]undefined
. Das weiß auch TypeScript, weswegen es trotz required
Properties das undefined
einfordert.
Folgende Varianten für Property Binding sind gängig:
@Input({ required: true }) name = '';
@Input({ required: true }) name: string | undefined;
@Input({ required: true }) name!: string;
Die erste Version benötigt einen Initialwert, die zweite hat einen Union Type mit undefined
. Hier muss bei jedem Zugriff erst eine Überprüfung stattfinden, um welchen Typen es sich eigentlich handelt. Das führt zu mehr Code.
Das Ausrufezeichen in der letzten Variante ist der sogenannte Non-Nullable Assertion Operator, ein Euphemismus für "Außerkraftsetzen des Compilers". Damit ist der Typ nur eine Zeichenkette und kommt ohne Initialwert aus. Der Nachteil ist, dass der Compiler vor möglichen Zugriffen beim Wert undefined
nicht schützt und das Risiko eines Laufzeitfehlers besteht.
input
bringt hier Erleichterung. Die Regeln bleiben jedoch dieselben: Ein Zugriff auf ein Signal vor der Initialisierung führt zu einem Laufzeitfehler oder liefert ein undefined
zurück.
Das heißt, Signale oder durch computed
abgeleitete Signale finden sich im Template wieder. Dort greift Angular auf sie zu. Da Templates erst nach der Initialisierung gerendert werden, ist ein vorzeitiger Zugriff so gut wie ausgeschlossen.
Mit Input-Signalen ändert sich obiges Beispiel mit den Lifecycle Hooks zu:
export class CustomerComponent {
name = input(''); // Signal<string>
customer = input<Customer>(); // Signal<Customer | undefined>
constructor() {
// direct access before initialization will still be undefined
console.log(this.customer());
// runs with valid value
effect(() => this.customer());
}
}
Der Unterschied ist sofort erkennbar: Nicht nur, dass die Funktion input
den Decorator @Input
ersetzt, sondern es ändert sich auch die Art, wie der Zugriff erfolgt.
Bei direktem Zugriff im Constructor ist der Wert nach wie vor undefined
. Durch die Möglichkeit, die Ausgabe in einem effect()
laufen zu lassen, stellt Angular jedoch sicher, dass der Zugriff nur erfolgt, wenn die Initialisierung abgeschlossen ist – und auch danach bei jeder Änderung.
Das Beispiel hat zwei der drei möglichen Arten gezeigt. Aus TypeScript-Perspektive ändert sich bei diesen jedoch nichts. Die Sprache verlangt nach wie vor entweder einen Initialwert oder stellt auf den Union Type mit undefined
zurück.
Die dritte Art bringt jedoch eine Neuerung mit sich:
name = input.required<string>(); // Signal<string>
Die Erweiterung required
kommt ohne Initialwert aus und generiert das Signal ohne undefined
. Das ist durch einen Kompromiss möglich. Angular bietet Signal<string>
unter der Prämisse an, nicht vor der Initialisierung auf den Wert des Signals zuzugreifen.Das würde sofort einen Laufzeitfehler erzeugen.
constructor() {
// direct access before initialization will still be undefined
console.log(this.customer());
}
Kommt allerdings der Effekt zum Einsatz, gibt es keine Probleme.
constructor() {
// runs with valid value
effect(() => this.customer());
}
output() statt @Output
Während beim Property Binding die Elternkomponente Daten an die Kindkomponente übergibt, ist beim Event Binding das genaue Gegenteil der Fall: Hier benachrichtigt die Kindkomponente die Elternkomponente über ein Ereignis.
Dazu muss die Kindkomponente eine neue Property vom Typ EventEmitter
mit dem Decorator @Output
erstellen. Die eigentliche Benachrichtigung erfolgt über die emit
-Methode:
export class CustomerComponent {
name = input<string>('');
@Output nameChange = new EventEmitter<string>();
handleNameChange(newName: string) {
this.nameChange.emit(newName);
}
}
Die neue API ersetzt @Output
durch eine Funktion output
, die eine Instanz vom Typ OutputEmitterRef
zurückliefert. Diese Instanz hat auch eine Methode emit
, die dieselben Parameter wie die des EventEmitter
besitzt:
export class CustomerComponent {
name = input<string>('');
nameChange = output<string>();
handleNameChange(newName: string) {
this.nameChange.emit(newName);
}
}
Im Gegensatz zu @Output
erbt OutputEmitterRef
nicht von Subject
von RxJs. Das bedeutet einerseits, dass es die Methode next
nicht mehr gibt, die in der Vergangenheit statt emit
sehr häufig zum Einsatz kam. Zum anderen ist dies ein weiterer Schritt in der Abkopplung von RxJs. Längerfristig möchte das Angular-Team seine API nicht zwingend an RxJs binden, sondern den Benutzern die Verwendung von RxJs freistellen.
Neue Funktion model für Two-Way Binding
Beim Two-Way Binding übergibt eine Elternkomponente eine Property an die Kindkomponente, wobei diese jedoch die Möglichkeit hat, die Property auch für die Elternkomponente zu überschreiben.
Das heißt, die Elternkomponenten müssen sowohl Property- als auch Eventbindung übernehmen und die Kindkomponenten müssen neben einem input()
auch eine Property mit output
erstellen. Natürlich ist Two-Way Binding auch mit den Dekoratoren möglich.
Angular bietet in diesem Fall der Elternkomponente eine vereinfachte Syntax an, die den Namen Bananabox trägt. Die zu CustomerComponent
gehörige Elternkomponente verarbeitet mit der speziellen Syntax die name
-Property folgendermaßen:
@Component({
selector: 'app-customer',
standalone: true,
template: './customer.component.html',
imports: [],
})
export class CustomerComponent {
name = input<string>('');
nameChange = output<string>();
handleNameChange(newName: string) {
this.nameChange.emit(newName);
}
}
@Component({
selector: 'app-customers',
standalone: true,
template:
'<app-customer [name]="username" (nameChange)="username = $event" />',
imports: [CustomerComponent],
})
export class CustomerContainerComponent {
username = 'Sandra';
}
Die neue model
-Funktion vereinfacht den Code für Two-Way Binding in der Kindkomponente. Sie erstellt ein schreibbares Signal, das sich wie das von input
verhält. Bekommt es jedoch einen neuen Wert, dann löst es zugleich ein mit der Two-Way Binding kompatibles Ereignis aus. Ein explizites OutputEmitterRef
ist somit nicht mehr notwendig:
export class CustomerComponent {
name = model<string>('');
handleNameChange(newName: string) {
this.name.set(newName);
}
}
Es gibt jedoch eine Neuerung für Elternkomponenten. Diese können in der Bananabox auch ein Signal übergeben. Intern ruft das Framework automatisch die set
-Funktion auf.
Das finale und vollständig zur neuen API migrierte Beispiel sieht nun folgendermaßen aus:
@Component({
selector: 'app-customer',
standalone: true,
template: './customer.component.html',
imports: [],
})
export class CustomerComponent {
name = model<string>('');
handleNameChange(newName: string) {
this.name.set(newName);
}
}
@Component({
selector: 'app-customers',
standalone: true,
template: '<app-customer [(name)]="username" />',
imports: [CustomerComponent],
})
export class CustomerContainerComponent {
username = signal('Sandra');
}
Zusammenfassung und Ausblick
In Angular 17.0 waren die Vorteile von Signalen mit dem Einzug der Local Change Detection eher ein Nischenthema für leistungskritische Anwendungen. Mit der neuen API, die mit 17.3 formvollendet ist, profitiert nun auch wirklich jede Anwendung davon.
Obwohl die neue API sowie effect
noch in Developer Preview sind, sind sie bereits stabil und voll einsatzfähig.
Der dritte und letzte Teil wird die Änderungen in der Template-Syntax behandeln. Die Erweiterungen lösen die strukturellen Direktiven für den Kontrollfluss ab. Das prominenteste Feature von Angular 17.0 sind aber die Deferrable Views, die auch direkt in das Template als neues Syntaxelement eingebettet sind.
(mai)