Angular-Renaissance Teil 2: Reaktives Programmieren mit Signalen

Seite 2: Signalbasierte API

Inhaltsverzeichnis

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