zurück zum Artikel

Angular-Renaissance Teil 2: Reaktives Programmieren mit Signalen

Rainer Hahnekamp
Grünes Eisenbahnsignal

(Bild: N-sky/Shutterstock.com)

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

Nachdem sich der erste Teil dieser Artikelserie [1] 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

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

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:

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

Change Detection mit OnPush und Standard gemischt: Event auf OnPush

(Bild: Rainer Hahnekamp)

Change Detection mit OnPush und Standard gemischt: Event auf Standard

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

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.

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.

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());
}

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.

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');
}

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 [4])


URL dieses Artikels:
https://www.heise.de/-9705343

Links in diesem Artikel:
[1] https://www.heise.de/hintergrund/Angular-Renaissance-Teil-1-Server-Side-Rendering-und-Hydration-9656019.html
[2] https://www.heise.de/hintergrund/Angular-Renaissance-Teil-1-Server-Side-Rendering-und-Hydration-9656019.html
[3] https://www.heise.de/hintergrund/Angular-Renaissance-Teil-2-Reaktives-Programmieren-mit-Signalen-9705343.html
[4] mailto:mai@heise.de