Ein Angular-Bildschirm besteht aus Komponenten: einer Klasse für den Zustand, einer Vorlage (HTML) für die Darstellung und einem Stylesheet. Der Zustand liegt in Signals; ändern sie sich, aktualisiert Angular genau die Stellen der Vorlage, die sie lesen. Diese Lektion baut daraus die erste Kladde-Seite: die Kontaktliste aus festen Daten mit einem Filterfeld. Die API kommt in 0202. Sie arbeiten im Projekt aus 0101 (web/kladde-angular, Präfix kl).

Stand der Prüfung. Der Code der Lektion (Dienst, Komponente, Vorlage; dazu je ein Test für Liste und Wurzel, hier nicht abgedruckt, 0401) lief in einem Projekt mit Angular 22.2, TypeScript 6.0.3 und Node 24.22.3: ng build Exit-Code 0, ng test --no-watch Exit-Code 0 (2 Dateien, 2 Tests bestanden, Vitest 5.1.0); mit .startsWith statt .includes im Filter Exit-Code 1. Die Snippets zu effect und linkedSignal übersetzten in der Klasse der nächsten Lektion (ng build, Exit-Code 0; die Tests liefen mit dem effect grün); sie gehören nicht zur Lösung. Ebenfalls ausgeführt: fehlendes imports (NG8001) und fehlendes track (NG5002) brachen den Bau mit Exit-Code 1. Nicht ausgeführt: ein Browser, ein Screenreader, die Bedienung mit der Tastatur; die Sätze dazu sind Folgerungen aus dem HTML, nicht Beobachtungen. Aussagen über Angular stammen von angular.dev (Seiten zu Komponenten, Bindungen, Signals, Steuerfluss, Zoneless, Style Guide, am Schreibtag gelesen); Zitate sind im Original englisch.

Die Komponente

Der Kopf der Komponente KontaktListe (aus ng generate component kontakte/kontakt-liste, 0101):

TypeScript
@Component({
  imports: [],
  selector: 'kl-kontakt-liste',
  styleUrl: './kontakt-liste.scss',
  templateUrl: './kontakt-liste.html',
})
export class KontaktListe {}

Der Selektor ist der Elementname in anderen Vorlagen: <kl-kontakt-liste />. templateUrl und styleUrl verweisen auf Dateien neben der Klasse („relative to the directory in which the component resides“); kurze Vorlagen dürfen mit template: in die Klasse. Komponenten sind standalone: Wer eine andere Komponente in seiner Vorlage benutzt, trägt sie in imports ein. Die Seite „Components“ sagt: „In Angular versions before 19.0.0, the standalone option defaults to false“; heute brauchen Sie dazu nichts zu schreiben, und die CLI erzeugt keine NgModules. Die Wurzel App (0101) holt die Liste mit imports: [KontaktListe] in ihre Vorlage. Vergessen Sie den Eintrag in imports, bricht der Bau mit NG8001: 'kl-kontakt-liste' is not a known element ab (Exit-Code 1); der Fehler kommt vor dem Browser.

Bindungen

Die Vorlage ist HTML mit vier Ergänzungen (Seite „Binding dynamic text, properties and attributes“):

Schreibweise Richtung Beispiel
{{ ausdruck }} Klasse nach Text {{ kontakt.name }}
[eigenschaft]="ausdruck" Klasse nach Eigenschaft des Elements oder der Komponente [value]="filter()"
[attr.name]="ausdruck" Klasse nach Attribut; null oder undefined entfernt es [attr.aria-label]="beschriftung()"
(ereignis)="aufruf" Element nach Klasse (input)="filterGeaendert($event)"

Zu {{ }} sagt die Seite: „Expression values are converted to a string“ und „null and undefined render as an empty string“. [class.aktiv]="ausdruck" schaltet eine Klasse, [style.height.px]="ausdruck" einen Stil. Die Vorlage kann nur lesen, was die Klasse sichtbar macht: Der Style Guide empfiehlt protected für Mitglieder, die nur die Vorlage braucht, und readonly für Unveränderliches.

Signals: signal, computed, effect, linkedSignal

Ein Signal ist, nach der Seite „Signals“, „a wrapper around a value that notifies interested consumers when that value changes“. Man liest es, indem man es aufruft:

TypeScript
const anzahl = signal(0);
console.log(anzahl());        // lesen
anzahl.set(3);                // ersetzen
anzahl.update((v) => v + 1);  // aus dem alten Wert berechnen

computed leitet einen Wert aus anderen Signals ab: const doppelt = computed(() => anzahl() * 2). Die Seite hebt hervor, dass ein computed lazy und gemerkt ist (die Ableitung läuft beim ersten Lesen, danach kommt der Wert aus dem Zwischenspeicher, bis sich eine Abhängigkeit ändert), dass es nur lesbar ist (doppelt.set(3) ist ein Übersetzungsfehler) und dass man darin „safely perform computationally expensive derivations … such as filtering arrays“ darf. Nur Signals, die bei der Berechnung wirklich gelesen werden, zählen als Abhängigkeit. Für die Kladde: Der Filter ist ein signal, die sichtbare Liste ein computed aus Kontakten, Filter und Sortierung.

effect führt Code aus, wenn sich gelesene Signals ändern. Die Seite sagt, man nehme ihn „for those specific use cases“, in denen man auf Änderungen reagiert und eine Programmoberfläche anspricht, die nichts von Signals weiß; Ableitungen gehören nicht hinein („Synchronous or asynchronous derivations are recommended when we want to react to state changes“: also computed oder resource). Ein vertretbarer Fall in der Kladde ist, den Filter im sessionStorage des Browsers zu spiegeln (im Konstruktor oder Feld der Klasse):

TypeScript
constructor() {
  effect(() => sessionStorage.setItem('kl-filter', this.filter()));
}

Die Faustregel: Wert aus Werten ist computed; Reaktion nach außen ist effect.

linkedSignal ist ein beschreibbares Signal, das von anderen abhängt. Die Seite „Dependent state with linkedSignal“: „The linkedSignal function lets you create a signal to hold some state that is intrinsically linked to some other state.“ Ihr Beispiel ist eine Auswahl, die auf den ersten Eintrag zurückfällt, wenn sich die Liste ändert; für die Kladde:

TypeScript
protected readonly ersterTreffer = linkedSignal(() => this.treffer()[0]);

Steuerfluss in der Vorlage

Die Vorlage hat eingebaute Blöcke (Seite „Control flow“):

HTML
@if (filter() !== '') {
  <button type="button" (click)="filter.set('')">Filter löschen</button>
}

@for (kontakt of treffer(); track kontakt.id) {
  <li>{{ kontakt.name }}</li>
} @empty {
  <li>Keine Kontakte gefunden.</li>
}

@switch (sortierung()) {
  @case ('name') { Name }
  @case ('company') { Firma }
}
  • @if hat @else if und @else; @if (ausdruck; as name) legt das Ergebnis in eine Variable.
  • @for braucht track (ohne bricht der Bau mit NG5002: @for loop must have a "track" expression ab): Der Ausdruck „allows Angular to maintain a relationship between your data and the DOM nodes“. Nehmen Sie, wie die Seite rät, eine Eigenschaft, die jeden Eintrag eindeutig kennzeichnet (hier id). $index taugt nur für Listen, die sich nie ändern, der Eintrag selbst als Schlüssel ist laut Seite der letzte Ausweg. @empty läuft bei leerer Sammlung; continue und break gibt es nicht.
  • @switch vergleicht mit ===, ohne Durchfallen; aufeinanderfolgende @case teilen sich einen Block.

Dienste mit inject()

Ein Dienst ist eine Klasse mit eigener Aufgabe, die Komponenten sich holen. Die CLI erzeugt ihn mit @Service() (0101); der Style Guide empfiehlt, ihn mit der Funktion inject zu holen statt über Konstruktor-Parameter:

TypeScript
private readonly daten = inject(KontaktDaten);
protected readonly kontakte = signal(this.daten.liste());

Für Sie als .NET-Entwicklerin: Das entspricht der Konstruktorinjektion, mit einem Singleton im Anwendungsbereich als Voreinstellung (@Service stellt nach der Seite die Klasse „at the root injector“ bereit).

OnPush und zoneless

Zwei Begriffe aus Angular-Texten und was sie für Ihren Code heißen. Die Seite „Skipping component subtrees“ sagt: „OnPush is the default change detection strategy in Angular (since v22)“; eine OnPush-Komponente wird nur geprüft, wenn eine Eingabe sich ändert, ein Ereignis in ihr auftritt, sie ausdrücklich zur Prüfung markiert wird oder ein Signal sich ändert, das ihre Vorlage liest. Zoneless (Seite „Zoneless“, Standard seit Fassung 21) heißt: Angular verlässt sich auf genau diese Meldungen und nicht auf die Bibliothek zone.js. Beides passt zu Signals. Ihre Regel für den Kurs: Zustand in Signals halten, nichts in Klassenfelder schreiben, das die Vorlage liest, ohne dass es ein Signal ist. Dann aktualisiert sich die Anzeige.

Barrierefreiheit in dieser Seite

Das Filterfeld hat ein <label for> mit der passenden id: Ein Platzhalter ist keine Beschriftung. Dass sich die Trefferzahl beim Tippen ändert, erfährt ein Screenreader noch nicht; 0103 löst das mit aria-live.

Übung 1

Die Kontaktliste aus festen Daten mit Filterfeld. Im Projekt aus 0101:

  1. Legen Sie src/app/kontakte/contact-summary.ts an: eine interface ContactSummary mit id: number, name, company und tags: string[] (so heißen die Felder der API; Stand K2 prüfen, ob id bei Ihnen eine Zahl ist).
  2. Erzeugen Sie den Dienst KontaktDaten (ng generate service kontakte/kontakt-daten) mit einer Methode liste(), die vier erfundene Kontakte zurückgibt.
  3. Schreiben Sie die Komponente KontaktListe: ein Signal kontakte aus dem Dienst, ein Signal filter, ein computed treffer (Filter nach Name oder Firma, ohne Rücksicht auf Groß- und Kleinschreibung), ein Signal sortierung (Name oder Firma) mit einer Taste zum Wechseln.
  4. Die Vorlage: Überschrift, beschriftetes Feld, Taste „Filter löschen“ nur bei nicht leerem Filter, die Liste mit @for und @empty, die Sortierung mit @switch im Beschriftungstext der Taste.
  5. Hängen Sie <kl-kontakt-liste /> in app.html und KontaktListe in imports von App. Ersetzen Sie die Prüfung in app.spec.ts (Hello, kladde-angular, 0101) durch expect(el.querySelectorAll('li').length).toBe(4). Bauen Sie und starten Sie npx ng serve (die API brauchen Sie hier nicht).

So prüfen Sie: Vier Zeilen erscheinen; „muster“ lässt zwei übrig (Groß- und Kleinschreibung egal), „zzz“ zeigt „Keine Kontakte gefunden.“; die Taste „Filter löschen“ erscheint und verschwindet; die Sortierung wechselt. npx ng build und npx ng test --no-watch enden mit 0. Bedienen Sie alles nur mit Tab, Leertaste und Eingabetaste.

Lösung 1

src/app/kontakte/contact-summary.ts:

TypeScript
export interface ContactSummary {
  id: number;
  name: string;
  company: string;
  tags: string[];
}

src/app/kontakte/kontakt-daten.ts (Namen und Firmen sind erfunden):

TypeScript
import { Service } from '@angular/core';
import { ContactSummary } from './contact-summary';

@Service()
export class KontaktDaten {
  liste(): ContactSummary[] {
    return [
      { id: 1, name: 'Anna Beispiel', company: 'Beispiel AG', tags: ['kunde'] },
      { id: 2, name: 'Ben Muster', company: 'Muster GmbH', tags: ['kunde', 'wichtig'] },
      { id: 3, name: 'Clara Probst', company: 'Probst & Söhne', tags: [] },
      { id: 4, name: 'Dario Test', company: 'Muster GmbH', tags: ['lieferant'] },
    ];
  }
}

src/app/kontakte/kontakt-liste/kontakt-liste.ts:

TypeScript
import { Component, computed, inject, signal } from '@angular/core';
import { KontaktDaten } from '../kontakt-daten';

@Component({
  imports: [],
  selector: 'kl-kontakt-liste',
  styleUrl: './kontakt-liste.scss',
  templateUrl: './kontakt-liste.html',
})
export class KontaktListe {
  private readonly daten = inject(KontaktDaten);

  protected readonly kontakte = signal(this.daten.liste());
  protected readonly filter = signal('');
  protected readonly sortierung = signal<'name' | 'company'>('name');

  protected readonly treffer = computed(() => {
    const suche = this.filter().trim().toLowerCase();
    const feld = this.sortierung();
    return this.kontakte()
      .filter((k) => `${k.name} ${k.company}`.toLowerCase().includes(suche))
      .sort((a, b) => a[feld].localeCompare(b[feld], 'de'));
  });

  protected filterGeaendert(ereignis: Event): void {
    this.filter.set((ereignis.target as HTMLInputElement).value);
  }

  protected sortierungWechseln(): void {
    this.sortierung.update((alt) => (alt === 'name' ? 'company' : 'name'));
  }
}

src/app/kontakte/kontakt-liste/kontakt-liste.html:

HTML
<h1>Kontakte</h1>

<label for="kontakt-filter">Filter nach Name oder Firma</label>
<input id="kontakt-filter" type="search" [value]="filter()" (input)="filterGeaendert($event)" />

@if (filter() !== '') {
  <button type="button" (click)="filter.set('')">Filter löschen</button>
}

<button type="button" (click)="sortierungWechseln()">
  Sortiert nach:
  @switch (sortierung()) {
    @case ('name') { Name }
    @case ('company') { Firma }
  }
</button>

<ul>
  @for (kontakt of treffer(); track kontakt.id) {
    <li>{{ kontakt.name }} ({{ kontakt.company }})</li>
  } @empty {
    <li>Keine Kontakte gefunden.</li>
  }
</ul>

Warum es so geschrieben ist: [value]="filter()" hält das Feld nach „Filter löschen“ leer. .sort ändert das Array, das .filter gerade neu angelegt hat, und nicht kontakte(). Typische Fehler:

  • NG8001: 'kl-kontakt-liste' is not a known element: KontaktListe fehlt in imports von App.
  • Die Liste ändert sich nicht beim Tippen: filter ist kein Signal, oder filterGeaendert setzt es nicht.
  • NG5002: @for ohne track.

Was Sie jetzt können

  • Sie schreiben eine Komponente, tragen andere in imports ein und binden Text, Eigenschaften, Attribute und Ereignisse.
  • Sie halten Zustand in signal, leiten ihn mit computed ab, setzen effect nur für Reaktionen nach außen und kennen den Unterschied zu linkedSignal.
  • Sie steuern die Vorlage mit @if, @for (immer mit eindeutigem track) und @switch und holen Dienste mit inject().
  • Sie wissen, was OnPush und zoneless für Ihren Code bedeuten: Zustand gehört in Signals.
  • Sie haben die Kontaktliste aus festen Daten mit Filterfeld gebaut.

Weiterlesen

Die Kladde: src/app/kontakte hält contact-summary.ts, kontakt-daten.ts und die Komponente kontakt-liste mit Test. Als Nächstes zerlegen Sie sie in eigene Bausteine mit Ein- und Ausgaben (0103).