Auf der Seite signal() und computed() hast du gesehen, was Signals tun. Diese Seite zeigt, wie sie im Inneren funktionieren: ein reaktiver Graph, der automatisch aus Lesezugriffen aufgebaut wird. Schreibzugriffe schicken nur eine Benachrichtigung, neu berechnet wird erst, wenn jemand liest. Daraus ergeben sich alle nützlichen Eigenschaften: computed ist lazy und memoisiert, du siehst nie inkonsistente Zwischenzustände, und Angular kann nur die betroffenen Komponenten neu rendern, ohne Zone.js.
computed nichts kostet, solange es niemand liest, warum items().push() die UI nicht aktualisiert, warum ein effect, der ein anderes Signal setzt, eine schlechte Idee ist, oder warum ein Signal, das in einem if-Zweig gelesen wird, manchmal „nicht reagiert“.Producer, Consumer und der Graph
Jedes Signal ist ein Knoten im Graphen. signal() ist ein Producer. computed() ist beides: Consumer (es liest andere Signals) und Producer (andere lesen es). effect() und Component-Templates sind Consumer. Solange eine reaktive Funktion läuft, gibt es einen aktiven Consumer, und jedes Signal, das in diesem Moment gelesen wird, wird zu seiner Abhängigkeit. Der Graph wird nicht deklariert: Er entsteht aus Lesezugriffen.
Jeder Knoten hat eine version, die nur steigt, wenn er einen Wert erzeugt, der sich vom vorherigen unterscheidet. Manche Consumer sind „live“ (Effects und Templates); für sie führt der Graph zusätzlich umgekehrte Kanten vom Producer zum Consumer, um ihnen Benachrichtigungen zu schicken. Die Primitive-Typen in @angular/core/primitives/signals zeigen genau diese Felder: version, dirty, producers, consumers.
const a = signal(1); // Producer
const b = signal(2); // Producer
const sum = computed(() => a() + b()); // Consumer UND Producer
const product = computed(() => a() * b());
const result = computed(() => sum() + product()); // Diamant: a und b kommen hier auf zwei Wegen an
// Während eine reaktive Funktion läuft, merkt sich Angular einen „aktiven Consumer“.
// Jedes Signal, das in diesem Moment gelesen wird, wird zu seiner Abhängigkeit. Du deklarierst nichts: Der Graph entsteht aus Lesezugriffen.Erst Push, dann Pull
Ein Schreibzugriff (set/update) berechnet nichts neu. Er erhöht nur die Version des Signals und meldet den Live-Consumern, dass sie dirty sein könnten: Das ist die Push-Phase. Wenn der Consumer wieder läuft (das Template beim nächsten Rendering, der Effect, wenn er an der Reihe ist), liest er, was er braucht, und jedes computed prüft seine Producer: Hat sich irgendeine Version geändert? Nur dann wird neu berechnet. Das ist die Pull-Phase.
a.set(5)
│ PUSH: a erhöht seine `version` und meldet den „Live“-Consumern (Template, Effect), dass sie dirty SEIN KÖNNTEN.
│ Noch wird nichts neu berechnet.
▼
Template / Effect als dirty markiert → Change Detection plant ein Rendering
│
▼
Template liest result()
│ PULL: result prüft seine Producer (sum, product) — haben sie eine andere Version?
│ sum prüft seine Producer (a, b) — a hat eine andere Version → sum wird neu berechnet.
│ Ist der neue Wert GLEICH dem alten, steigt die Version von sum nicht → die Propagation stoppt.
▼
result wird einmal neu berechnet, mit bereits konsistenten Werten (glitch-free)Die wichtige Konsequenz: Ein computed wird erst neu berechnet, nachdem alle Schreibzugriffe abgeschlossen sind, und zwar mit bereits konsistenten Werten. Im Diamanten der Demo sieht result nie ein neues sum mit einem alten product. Das nennt man glitch-free. Und wenn du a und b im selben Klick änderst, wird result nur einmal neu berechnet.
Im Template: a = 1, b = 2 · result = 5
Laziness und Memoisierung
Die Angular-Dokumentation sagt es direkt: Ein computed wird lazy ausgewertet und memoisiert. Die Funktion läuft erst beim ersten Lesen. Danach wird der Wert gespeichert und wiederverwendet, bis sich eine Abhängigkeit ändert. In der Praxis kannst du teure Ableitungen (Filtern, Sortieren) in ein computed legen: Sie kosten nur dann etwas, wenn das Ergebnis tatsächlich gebraucht wird.
const expensive = computed(() => {
console.log('berechne…');
return items().filter(matches).sort(byPrice);
});
items.set(newItems); // nichts wird ausgegeben: noch hat niemand gelesen
expensive(); // „berechne…“ — erster Lesezugriff
expensive(); // nichts: memoisierter Wert
items.set(other); // weiterhin nichts — als veraltet markiert, nicht neu berechnet
expensive(); // „berechne…“Gleichheit: Wo die Propagation stoppt
Die Version eines Knotens steigt nur, wenn der neue Wert nicht „gleich“ dem alten ist. Standardmäßig ist die Gleichheit Object.is: Bei Objekten und Arrays zählt die Referenz, nicht der Inhalt. Derselbe Mechanismus gilt auch für ein computed. Ändert sich sein Ergebnis nicht, werden seine Consumer nicht neu berechnet, selbst wenn sich seine Abhängigkeiten geändert haben. Mit { equal } legst du fest, was „dasselbe“ bedeutet.
// Standardmäßig: Object.is. Bei Objekten/Arrays zählt die REFERENZ.
const tags = signal(['a', 'b']);
tags.set(['a', 'b']); // andere Referenz → „neuer“ Wert → Consumer werden neu berechnet
// Eigene Gleichheit: Der Wert ändert sich nicht, wenn er nach deiner Regel „derselbe“ ist
const sameItems = (x: string[], y: string[]) => x.length === y.length && x.every((v, i) => v === y[i]);
const stableTags = signal(['a', 'b'], { equal: sameItems });
stableTags.set(['a', 'b']); // gleich → Version steigt nicht → nichts passiert downstream
// Auch ein computed hat eine Gleichheit: Ist sein Ergebnis gleich, stoppt die Propagation dort
const parity = computed(() => (n() % 2 ? 'ungerade' : 'gerade')); // n: 0 → 2 → 4 … parity bleibt 'gerade'| Knoten | Wert | Neuberechnungen |
|---|---|---|
parity = computed(() => n() % 2 ? 'ungerade' : 'gerade') | ||
label = computed(() => 'Die Zahl ist ' + parity()) |
| Signal | count = computed(tags().length) |
|---|---|
signal(...) — Standardgleichheit (Object.is) | 2 · neu berechnet × |
signal(..., { equal: sameItems }) | 2 · neu berechnet × |
Dynamische Abhängigkeiten
Bei jedem Durchlauf wird die Liste der Producer eines Consumers aus dem neu aufgebaut, was er tatsächlich gelesen hat. Ein Signal, das nur in einem if-Zweig gelesen wird, ist nur so lange eine Abhängigkeit, wie dieser Zweig läuft. Das ist ein Performance-Gewinn, aber auch eine Quelle der Verwirrung: „Ich ändere details und nichts passiert“ ist meist korrekt, weil der aktuelle Zweig es nicht liest.
const view = computed(() => {
if (showDetails()) {
return details(); // Abhängigkeiten: showDetails + details
}
return summary(); // Abhängigkeiten: showDetails + summary
});
// Bei jedem Durchlauf wird die Liste der Producer aus dem aufgebaut, was TATSÄCHLICH gelesen wurde.
// Solange showDetails() false ist, berechnet eine Änderung von details view nicht neu.view() = Zusammenfassung v0
Beim letzten Durchlauf erfasste Abhängigkeiten: · view neu berechnet ×
Effects und Templates: die Live-Consumer
Effects laufen asynchron, während der Change Detection, nicht im Moment von set(). Die Dokumentation zu v22 unterscheidet zwei Arten. Ein View-Effect wird im Kontext einer Komponente erstellt: Er läuft, bevor diese Komponente geprüft wird, und wird zusammen mit ihr zerstört. Ein Root-Effect wird in einem Root-Service erstellt: Er läuft, bevor alle Komponenten geprüft werden, und lebt so lange wie die Anwendung. Ändert sich eine Abhängigkeit, während der Effect läuft, läuft er noch einmal, bevor die Change Detection weitermacht.
// View-Effect: im Kontext einer Komponente erstellt → läuft VOR der Prüfung dieser Komponente
// durch die Change Detection; er wird zusammen mit der Komponente zerstört.
@Component({ /* ... */ })
export class Profile {
constructor() {
effect(() => console.log('profil', this.user()));
}
}
// Root-Effect: in einem Root-Service erstellt → läuft vor der Prüfung aller Komponenten;
// er lebt so lange wie die Anwendung.
@Injectable({ providedIn: 'root' })
export class Analytics {
constructor() {
effect(() => track(this.route()));
}
}
// Beide sind asynchron (sie laufen nicht im Moment von set()) und an die Change Detection gekoppelt.Auch das Template einer Komponente ist ein reaktiver Consumer. Ändert sich ein im Template gelesenes Signal, markiert Angular die Komponente zur Prüfung (wie markForCheck), und die Change Detection rendert nur sie und ihre Vorfahren neu. Darauf stützen sich zoneless-Anwendungen und das Standard-OnPush von v22 (siehe Change Detection).
@Component({
template: `<p>{{ fullName() }}</p>`, // das Template ist ein reaktiver Consumer
})
export class Card {
readonly first = signal('Ana');
readonly fullName = computed(() => `${this.first()} Pop`);
}
// first.set('Ioana') → der reaktive Knoten des Templates wird als dirty markiert → die Komponente wird
// zur Prüfung markiert (wie bei markForCheck) → die Change Detection rendert sie neu. Kein Zone.js,
// keine Prüfung des ganzen Baums: So funktionieren zoneless-Anwendungen.Wann ja, wann nein
- Teure Ableitungen in einem
computed: Sie werden nur bei Bedarf und einmal pro Änderung berechnet. - Ketten aus mehreren computeds statt eines riesigen computed. Mit Equality-Cutoff stoppen kleine Änderungen früh.
{ equal }für Werte, die oft neu erzeugt werden, aber „dieselben“ sind (Filter, ID-Listen vom Server).untrackedfür Lesezugriffe, die nichts auslösen sollen (Logging, Analytics).
- Er erkennt keine Mutationen:
pushauf ein Array in einem Signal benachrichtigt niemanden. - Er ordnet deine Seiteneffekte nicht: Brauchst du eine strenge zeitliche Reihenfolge, nimm RxJS (siehe Fortgeschrittenes RxJS).
- Er ist kein Event-Mechanismus: „Es wurde geklickt“ ist kein Zustand. Nutze
output()oder ein Subject. - Er führt Effects nicht synchron aus: Brauchst du das Ergebnis sofort, leite es mit
computedab.
Häufige Stolperfallen
// ✗ Schreiben innerhalb eines computed — Fehler: "Writing to signals is not allowed in a `computed`"
const total = computed(() => {
count.set(items().length); // Seiteneffekt in einer Ableitung
return items().reduce(sum, 0);
});
// ✗ Effect für eine Ableitung missbraucht (ein zusätzliches Rendering, Risiko von Schleifen)
effect(() => total.set(items().reduce(sum, 0)));
// ✓ Die Ableitung ist ein computed
const total2 = computed(() => items().reduce(sum, 0));
// ✗ Mutation: gleiche Referenz → Object.is sagt „gleich“ → nichts wird aktualisiert
items().push(newItem);
// ✓
items.update((list) => [...list, newItem]);- „Es reagiert nicht“: Prüfe, ob das Signal in dem Zweig gelesen wird, der läuft, und ob du es mit
()liest. Eine Referenz auf ein Signal ohne Aufruf erzeugt keine Abhängigkeit. - Große Objekte in einem einzigen Signal: Jedes geänderte Feld berechnet alles neu, was vom Objekt abhängt. Teile den Zustand auf oder leite die Felder mit
computedab. - Ein Effect, der in andere Signals schreibt: zusätzliche Renderings und das Risiko von Schleifen. Fast immer ist ein
computedoder ein linkedSignal die Lösung. - Lesezugriffe im Konstruktor, „damit es bereit ist“: Ein zu früh gelesenes computed wird mit unvollständigen Daten berechnet. Lass es lazy.
Spickzettel
KNOTEN ROLLE WANN ER LÄUFT
signal Producer — (ändert bei set/update nur Wert und Version)
computed Consumer + Producer lazy: beim Lesen, nur wenn sich eine Abhängigkeit geändert hat
effect „Live“-Consumer asynchron, während der Change Detection (View: vor der Komponente; Root: vor allen)
template „Live“-Consumer wenn die Komponente nach der dirty-Markierung neu gerendert wird
REGELN
Abhängigkeiten = was du beim letzten Durchlauf TATSÄCHLICH gelesen hast (dynamisch, pro Zweig)
Standardgleichheit Object.is; { equal } für „gleiche“ Werte → stoppt die Propagation
ein computed ohne Leser wird gar nicht berechnet
schreibe keine Signals in einem computed; leite Zustand nicht mit einem effect ab
untracked(() => …) liest, ohne eine Abhängigkeit zu erzeugen