zur Übersicht

Signale richtig deuten – Angular verändert sich

Lesedauer ca. 12 Minuten
13.12.2023

In den vergangenen zwei Jahren hat das Angular-Team offenbar einen neuen Kurs eingeschlagen. Am 8. Oktober 2021 wurde in der offiziellen GitHub Repository die erste Diskussion über Standalone-Komponenten gestartet, die als RFC (Request for Comments) vorlag. Der Beitrag war äußerst umfassend und detailliert. Die gesamte Community wurde dazu eingeladen, aktiv am Entwicklungsprozess dieses neuen Konzepts teilzunehmen.

Die Beteiligung war erfreulich, und das Feedback sowohl seitens der Community als auch des Angular-Teams war durchweg positiv. Schließlich wurde das Konzept der Standalone-Komponenten mit Angular 14 in das Framework integriert. Dies markierte den Anfang weiterer Diskussionen zu Themen wie „Signal API”, „Signal-based Components” und „Control Flow & Deferred Loading”. Auch an dieser Stelle beteiligte sich die Community intensiv.

Die RFCs brachten keine oberflächlichen Anpassungen, sondern grundlegende Veränderungen mit sich, die dennoch zügig vom Angular-Team in das Framework implementiert wurden. Die Signal-API wurde mit Angular 16 eingeführt, gefolgt von einer verbesserten Template-Syntax in Angular 17. Angular 18 wird voraussichtlich die Signal-API mit den signalbasierten Komponenten abschließen.

Aufgrund dieser Veränderungen haben unsere Xperten aus der Softwareentwicklung Angular einmal genauer unter die Lupe genommen. Im Folgenden erläutern wir, welche Auswirkungen die Neuerungen zukünftig auf die Entwicklung von Angular-Anwendungen haben werden und wie man sich bereits heute darauf vorbereiten kann.

Signal-API

Die typische Reactivity-API besteht aus zwei Hauptkonzepten: Signale und Effekte. Ein Signal enthält einen änderbaren Wert, während ein Effekt auf Änderungen dieses Werts reagiert. Für eine ausführliche Beschreibung der Umsetzung dieser API in Angular möchten wir auf die offizielle Dokumentation verweisen: angular.dev. Das Ziel unseres Beitrags soll nicht sein, die bestehende API zu erläutern, sondern fortgeschrittene Angular-Entwickelnde auf die Zukunft des Frameworks vorzubereiten.

Kurz zusammengefasst basiert die Signal-API von Angular auf drei Grundelementen:

  • signal erzeugt ein schreibbares Signal (WritableSignal).
  • computed erzeugt ein berechnetes Signal (Signal).
  • effect erzeugt einen Effekt (EffectRef).
import {computed, effect, signal} from '@angular/core';

const count = signal(1); // WritableSignal<number>
const countOdd = computed(() => count() % 2 > 0); // Signal<boolean>
effect(() => {
  console.log(${count()} is ${countOdd() ? 'odd' : 'even'});
});

await tick();
// 1 is odd

count.set(4);

await tick();
// 4 is even

Für Entwickelnde, die mit anderen Frameworks vertraut sind, dürften die Parallelen zu den entsprechenden Konzepten in Angular deutlich erkennbar sein:

  • Vue: ref, computed, watch/watchEffect
  • React: useState, useEffect
  • Svelte: $state, $derived, $effect
  • @preact/signals: signal, computed, effect (hier sogar mit denselben Bezeichnungen)

Angular erfindet somit das Rad nicht neu, sondern übernimmt bewährte Techniken aus anderen Frameworks.

RxJS

Die Einführung der Signal-API schafft eine klare Alternative zu RxJS, wobei das langfristige Ziel von Angular darin besteht, RxJS als zwingende Abhängigkeit vollständig abzuschaffen. Dies birgt zahlreiche Vorteile. Neue Nutzende müssen sich nicht mehr mit der steilen Lernkurve dieser umfangreichen Bibliothek auseinandersetzen, da sie nun sämtliche Umsetzungen mithilfe der in Angular integrierten Tools vornehmen können. Darüber hinaus entfallen potenzielle Probleme, die mit der Verwendung von RxJS verbunden sind, wie etwa „Glitches" aufgrund von inkonsistenten Daten und Speicherlecks bei vergessenen Subscriptions.

Obwohl RxJS weiterhin in der aktuellen Entwicklung unverzichtbar ist, insbesondere da es in den meisten für Angular entwickelten Bibliotheken genutzt wird, bietet das von Angular bereitgestellte Paket @angular/core/rxjs-interop die Möglichkeit, die RxJS-Observables in einer Signal-Umgebung zu verwenden. Dadurch lassen sich die Vorteile der neuen Herangehensweise optimal nutzen.

Signalbasierte Komponenten

Signalbasierte Komponenten wurden eingeführt, um eine nahtlose Integration von Signalen in Angular-Anwendungen zu ermöglichen. Durch das Setzen des Parameters signals auf true im @Component-Decorator wird diese neue Funktionalität aktiviert. Die Besonderheit dabei ist, dass signalbasierte Komponenten die Verwendung von Member-Decorators wie @Input, @Output, @ViewChild etc. eliminieren. Stattdessen werden diese durch entsprechende Funktionen wie input, output, viewChild usw. ersetzt. Dabei bleiben alle Optionen wie required, alias und transform im @Input-Decorator in ihren Äquivalenten erhalten.

Beispiel für eine signalbasierte Komponente:

@Component({
  signals: true,
  selector: 'my-temperature',
  template: `
    <p>C: { { celsius() }}</p>
    <p>F: { { fahrenheit() }}</p>
  `,
})
export class MyTemperatureComponent {
  celsius = input<number>({required: true});

  fahrenheit = computed(() => this.celsius() * 1.8 + 32);
}

Es ist wichtig zu beachten, dass Angular Decorator zur Compile-Zeit verarbeitet und das JavaScript-Bundle tatsächlich keine Decorator enthält. Die neue signalbasierte API folgt dem gleichen Ansatz. Angular verarbeitet input, output, viewChild usw. zur Compile-Zeit, um die Anwendungsstruktur zu verstehen. Das bedeutet, dass diese API nicht wie herkömmliche Funktionen im gesamten Code verwendet werden kann, sondern nur innerhalb der Komponenten funktioniert.

Change Detection

Die Change Detection von Angular basiert entweder auf zone.js oder erfordert die manuelle Verwendung der ChangeDetectorRef-Instanz innerhalb einer Komponente. Mit der Einführung der signalbasierten Komponenten entfällt jedoch die Notwendigkeit, die changeDetection-Einstellung zu konfigurieren. Die signalbasierten Komponenten ermöglichen es Angular, automatisch Zustandsänderungen zu erkennen, sobald sich der Wert eines im Template verwendeten Signals ändert. Diese Automatisierung eliminiert effektiv das unnötige Neu-Rendern von Komponentenbäumen und führt insbesondere bei großen Anwendungen zu einer wesentlichen Verbesserung der Leistung.

Es ist bereits möglich, Signale innerhalb von Komponenten und ihren Templates zu verwenden. Wenn die Change-Detection-Strategie auf OnPush gesetzt ist, reagiert Angular nahtlos auf entsprechende Änderungen.

Input

In signalbasierten Komponenten werden Eingabeparameter als Signale durch die Funktion input definiert. Diese Signale sind schreibgeschützt und repräsentieren stets den aktuellen, von außen in die Komponente eingebundenen Wert.

zone-basiert

@Input()
count: number = 0;

signalbasiert

count = input<number>(0); // Signal<number>

Für die Verwendung von Signalen als Eingabeparameter kann bereits heute ein Workaround angewendet werden. Dazu werden ein Getter und ein Setter für den @Input-Decorator deklariert, um das entsprechende Signal zurückzugeben und dessen Wert zu ändern.

@Input()
set count(v: number) {
  this.#count.set(v);
}
get count(): Signal<number> {
  return this.#count.asReadonly();
}
#count = signal<number>(0);

Output

Die Klasse EventEmitter ist eine Unterklasse von Subject aus der Bibliothek RxJS. Die initiale Umsetzung der output-Funktion soll weiterhin dieselbe EventEmitter-Instanz zurückgeben. Daher bleibt die Verwendung der Ausgabeparameter nahezu unverändert.

zone-basiert

@Output()
itemSelected = new EventEmitter<string>();

selectItem(item: string): void {
  this.itemSelected.emit(item);
}

signalbasiert

itemSelected = output<string>(); // EventEmitter<string>

selectItem(item: string): void {
  this.itemSelected.emit(item);
}

Template

Die Signale sind Funktionen und müssen daher in den Templates entsprechend verwendet werden. Es erfolgt kein automatisches Entpacken durch Angular.

@Component({
  /*...*/
  template: `<div>{ { user().name }}</div>`,
})
export class MyExampleComponent {
  user = input<User>({required: true}); // Signal<User>
}

Ein aktuelles Problem im Umgang mit Signalen in Templates besteht darin, dass der gleiche Rückgabetyp bei aufeinanderfolgenden Funktionsaufrufen nicht durch den Type Guard von TypeScript garantiert werden kann. Das erschwert beispielsweise einen Nullability Check des Signalwerts im Template, um diesen als nicht null zu verwenden. Eine elegante Lösung hierfür steht derzeit noch aus. Wir empfehlen daher, einen Verweis auf den Signalwert innerhalb eines if-Blocks zu erstellen und diesen Verweis im Inhalt des Blocks zu verwenden.

@Component({
  /*...*/
  template: `
    @if (user(); as user) {
      <div>{ { user.name }}</div>
    } @else {
      <div>No user is available.</div>
    }
  `,
})
export class MyExampleComponent {
  user = input<User>(); // Signal<undefined | User>
}

Two-way Binding

Die herkömmliche bidirektionale Bindung zwischen übergeordneten und untergeordneten Komponenten erfordert etwas Boilerplate Code. Signalbasierte Komponenten hingegen vereinfachen diesen Prozess erheblich durch die Einführung der model-Funktion. Diese Funktion erzeugt eine WritableSignal-Instanz, die die Änderungen ihres Werts nach außen automatisch weiterleitet.

zone-basiert

@Input()
checked: boolean = false;

@Output()
checkedChange = new EventEmitter<boolean>();

changeChecked(v: boolean) {
  this.checked = v;
  this.checkedChange.emit(v);
}

toggle(): void {
  this.changeChecked(!this.checked);
}

check(): void {
  this.changeChecked(true);
}

uncheck(): void {
  this.changeChecked(false);
}

signalbasiert

checked = model<boolean>(false); // WritableSignal<boolean>

toggle(): void {
  this.checked.update((v) => !v);
}

check(): void {
  this.checked.set(true);
}

uncheck(): void {
  this.checked.set(false);
}

Es ist wichtig zu beachten, dass die „banana-in-a-box“-Syntax mit den Signalen nicht funktioniert. Solange das Angular-Team keine Alternative dafür anbietet, bleibt vorerst die Nutzung der ausführlicheren Variante erforderlich.

zone-basiert

<my-example [(checked)]="value" />

signalbasiert

<my-example [checked]="value()" (checkedChange)="value.set($event)" />

Queries

Das Grundprinzip der Queries bleibt in den signalbasierten Komponenten erhalten. Die Äquivalente zu @ViewChild und @ContentChild geben ihr Ergebnis in einem Signal zurück. Dadurch entfällt die Notwendigkeit, einen Setter zu definieren, falls zusätzliche Kontrolle bei den dynamischen Queries gewünscht ist. Die Äquivalente zu @ViewChildren und @ContentChildren geben ihr Ergebnis ebenfalls in einem Signal zurück, wobei statt der komplexen Klasse QueryList ein einfaches Array verwendet wird. Das reduziert die Komplexität erheblich und eliminiert die Abhängigkeit von zusätzlichen Observables.

zone-basiert

@ContentChildren(MyExampleItemComponent)
items!: QueryList<MyExampleItemComponent>;

@ViewChild('input')
input?: ElementRef<HTMLInputElement>;

signalbasiert

// Signal<Array<MyExampleItemComponent>>
items = contentChildren(MyExampleItemComponent);

// Signal<undefined | ElementRef<HTMLInputElement>>
input = viewChild<ElementRef<HTMLInputElement>>('input');

Ein möglicher Workaround für Queries als Signale könnte ähnlich dem Ansatz für Eingabeparameter umgesetzt werden. Ein Setter für @ViewChild und @ContentChild kann mehrmals aufgerufen werden, während ein Setter für @ViewChildren und @ContentChildren nur einmal aufgerufen wird und weitere Änderungen über ein Observable propagiert werden. Es ist auch möglich, auf einen Getter zu verzichten, indem darauf geachtet wird, dass das erstellte Signal stets nur schreibgeschützt verwendet wird.

items = signal<Array<MyExampleItemComponent>>([]);
@ContentChildren(MyExampleItemComponent)
set _items(v: QueryList<MyExampleItemComponent>) {
  this.items.set(v.toArray());
  v.changes.subscribe(() => {
    this.items.set(v.toArray());
  });
}

input = signal<undefined | ElementRef<HTMLInputElement>>(undefined);
@ViewChild('input')
set _input(v: undefined | ElementRef<HTMLInputElement>) {
  this.input.set(v);
}

Host-Binding

Das Angular-Team hat noch keine endgültige Entscheidung darüber getroffen, wie mit den beiden Decorator @HostListener und @HostBinding verfahren werden soll. Es liegt noch keine Spezifikation vor, die eine signalbasierte Alternative vorschreibt. Derzeit wird empfohlen, vorübergehend auf die host-Option der @Directive- und @Component-Decorator zurückzugreifen.

Lifecycle Hooks

Die signalbasierten Komponenten bieten Unterstützung für acht Lifecycle Hooks. Aufgrund ihrer ursprünglichen Abhängigkeit von zone.js werden diese Hooks in den signalbasierten Komponenten durch die Einführung von Signalen und Effekten überflüssig. Die signalbasierten Komponenten werden weiterhin die Methoden ngOnInit und ngOnDestroy unterstützen, obwohl deren Verwendung optional ist. Die Initialisierung kann effektiv im Klassenkonstruktor erfolgen. Die Bereinigung, die durchgeführt werden muss, wenn die Instanz zerstört wird, kann entweder im onDestroy-Callback der injizierten DestroyRef-Instanz oder im onDispose-Callback innerhalb einer effect-Methode platziert werden. Falls zusätzliche Kontrolle über den Rendering-Prozess innerhalb einer Komponente gewünscht ist, bietet Angular seit der aktuellen Version (v17) drei dafür vorgesehene Hooks an: afterNextRender, afterRender und afterRenderEffect, die als Developer Preview zur Verfügung stehen.

Signale in der Praxis

Um zu verdeutlichen, wie sehr die Signale unserer Leben einfacher gestalten werden, sehen wir uns nun ein Beispiel an. Als Anforderung brauchen wir eine Komponente, die als ein einziger Eingabeparameter einen Boolean erwartet. Wenn dieses „wahr“ ist, dann startet die Komponente einen Timer, der jede Sekunde zwei zufällige Zahlen generiert und ihre Summe berechnet.

Zunächst implementieren wir das Beispiel auf herkömmliche Weise unter Verwendung der RxJS-Funktionalitäten.

export class MyExampleComponent implements OnDestroy {
  constructor() {
    this.subscription.add(
      this.active$
        .pipe(switchMap((active) => (active ? interval(1000) : EMPTY)))
        .subscribe(() => {
          this.n1$.next(genSomeRandomInteger());
          this.n2$.next(genSomeRandomInteger());
        }),
    );
  }

  @Input()
  set active(v: boolean) {
    this.active$.next(v);
  }
  active$ = new BehaviorSubject<boolean>(false);

  n1$ = new BehaviorSubject<number>(0);

  n2$ = new BehaviorSubject<number>(0);

  sum$ = combineLatest([
    this.n1$.pipe(distinctUntilChanged()),
    this.n2$.pipe(distinctUntilChanged()),
  ]).pipe(
    auditTime(0),
    map(([n1, n2]) => n1 + n2),
    share(),
  );

  subscription = new Subscription();

  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }
}

Trotz der geringen Komplexität der Aufgabe ist die Komponente unnötig umfangreich und unübersichtlich geworden. Der @Input-Decorator erfordert einen Setter, um den Wert des Subjects zu setzen. Um überflüssige Berechnungen der Summe zu verhindern, muss vor jedem abhängigen Observable ein distinctUntilChanged-Operator platziert werden. Die einfache Berechnung der Summe reicht nicht aus; zusätzlich ist auch auditTime erforderlich, um „Glitches” zu vermeiden, sowie share, um sicherzustellen, dass das Observable nur einmal initialisiert wird. Im ngOnDestroy-Hook müssen außerdem alle Subscriptions aufgeräumt werden. Es gibt viele Aspekte zu beachten, die nicht sofort intuitiv sind.

Nun setzen wir dieselbe Komponente auf signalbasierte Weise um.

export class MyExampleComponent {
  constructor() {
    effect((onCleanup) => {
      if (this.active()) {
        const timerId = setInterval(() => {
          this.n1.set(genSomeRandomInteger());
          this.n2.set(genSomeRandomInteger());
        }, 1000);
        onCleanup(() => {
          clearInterval(timerId);
        });
      }
    });
  }

  active = input<boolean>(false);

  n1 = signal<number>(0);

  n2 = signal<number>(0);

  sum = computed<number>(() => this.n1() + this.n2());
}

Der Code ist erheblich kürzer und klarer geworden.

Wir können einen Schritt weiter gehen und ein Composable definieren, um die Logik innerhalb der Komponente noch verständlicher zu gestalten. Composables sind unter Entwickelnden, die mit anderen Frameworks vertraut sind, bereits bekannt und beliebt. Sie bieten eine elegante Möglichkeit, Logik in Funktionen zu kapseln und an verschiedenen Stellen im Code wiederzuverwenden. Dies trägt zur Schaffung klarer, modularer und wartbarer Codebasen bei.

Den Code für das Einrichten des Timers können wir als separate Funktion definieren. Diese Funktion soll so generisch wie möglich sein und erwartet daher die innerhalb des Zeitintervalls auszuführende Logik als Callback.

export function useInterval(
  active: Signal<boolean>,
  ms: number,
  callback: {(): void},
): void {
  effect((onCleanup) => {
    if (active()) {
      const timerId = setInterval(callback, ms);
      onCleanup(() => {
        clearInterval(timerId);
      });
    }
  });
}

Dieses Composable können wir nun im Konstruktor der Komponente verwenden, um unseren Code noch eleganter zu gestalten.

constructor() {
  useInterval(this.active, 1000, () => {
    this.n1.set(genRandomInteger(1, 6));
    this.n2.set(genRandomInteger(1, 6));
  });
}

Das vorgestellte Beispiel verdeutlicht, wie die Einführung der neuen signalbasierten Komponenten die Entwicklung von Anwendungen beschleunigt und effizienter gestaltet. Darüber hinaus eröffnet die Signal-API, vor allem durch den Composable-Ansatz, einen neuen Weg für zahlreiche hilfreiche Bibliotheken, die das Entwickeln von Anwendungen noch zusätzlich verbessern werden.

Fazit

Die Einführung signalbasierter Komponenten stellt einen bedeutenden Meilenstein in der Entwicklung von Angular dar und zeichnet sich ab als der kommende Standard im Framework, vergleichbar mit der Etablierung der Standalone-Komponenten. Der Übergang zu diesem neuen Ansatz mag für viele Entwickelnde eine anspruchsvolle Aufgabe sein, da er eine Umstellung der Denkweise und eine Einarbeitung in innovative Konzepte erfordert. Es ist essenziell zu betonen, dass die Spezifikation der Signale und verwandter Konzepte noch nicht finalisiert ist. Entwickelnde haben die Möglichkeit, den aktuellen Fortschritt signalbasierter Komponenten im Entwicklungszweig des Angular Repositorys zu verfolgen.

Für alle, die nicht auf offizielle Veröffentlichungen warten möchten, bieten diverse Workarounds die Möglichkeit, die neuen Konzepte bereits jetzt zu integrieren. In Bezug auf bestehende Projekte mag der Wechsel zu signalbasierten Komponenten als herausfordernd erscheinen, jedoch erweist sich der Einsatz dieser bei der Entwicklung neuer Projekte bereits heute als überlegenswert. Diese Vorgehensweise ermöglicht Entwickelnden, frühzeitig von den Vorzügen der neuen Funktionalitäten zu profitieren und sich mit den zukünftigen Standards von Angular vertraut zu machen.

Quellen

https://angular.dev/guide/signals
https://github.com/angular/angular/discussions
https://github.com/angular/angular/tree/signals