Moderne Web-Anwendungen sind interaktiv. User-Input ändert Application-State. HTTP-Responses aktualisieren Daten. Timer triggern Animationen. Jede dieser State-Änderungen muss in der View reflektiert werden. Die Frage ist: Wann und wie synchronisiert man State mit View?
Die naive Lösung ist polling – kontinuierlich checken ob sich etwas geändert hat. Dies ist ineffizient und verschwendet CPU-Cycles. Eine bessere Lösung ist event-driven – detektiere wann Änderungen passieren können und checke dann. Angular’s Change Detection implementiert diese Strategie elegant.
Change Detection ist der Mechanismus der bestimmt wann Component-Templates re-evaluated werden müssen. Es ist ein Tree-Walking-Algorithm der von Root zu Leaves traversiert und Bindings checked. Die Performance dieses Mechanismus ist kritisch – ineffiziente Change Detection fühlt sich als UI-Lag an.
Angular nutzt Zone.js um zu detektieren wann asynchrone Operations
completen. Zone.js ist ein Execution-Context-Tracker für asynchrone
Tasks. Es patcht Browser-APIs wie setTimeout,
Promise.then, addEventListener und wrappet sie
in Zones.
Eine Zone ist ein Execution-Context der asynchrone Operations überlebt. Wenn ein async Task startet, noted Zone.js dies. Wenn er completes, notifiziert Zone.js Angular. Angular triggert dann Change Detection.
Dies ist fundamental: Ohne Zone.js müsste man manuell Change Detection triggern nach jedem async Event. Zone.js automatisiert dies. Der Preis ist overhead – Zone.js muss alle async APIs patchen.
// Was Zone.js macht (vereinfacht)
const originalSetTimeout = window.setTimeout;
window.setTimeout = function(callback: Function, delay: number) {
return originalSetTimeout(() => {
// Callback ausführen
callback();
// Angular notifizieren
angular.triggerChangeDetection();
}, delay);
};Diese Patches sind transparent. Developer-Code kann normale Browser-APIs nutzen. Zone.js fängt alles ab und triggert Change Detection automatisch.
Angular-Anwendungen sind Component-Trees. Change Detection traversiert diesen Tree top-down, von Root zu Leaves. Dies garantiert Konsistenz – Parent-State ist immer before Child-State aktualisiert.
Der Tree-Walk ist unidirektional. Data flows down via
@Input, Events flow up via @Output. Change
Detection folgt dem Data-Flow. Parents werden vor Children checked. Dies
verhindert inconsistent intermediate States.
@Component({
selector: 'app-root',
template: `
<app-header [user]="currentUser"></app-header>
<app-content [data]="contentData"></app-content>
`
})
export class AppComponent {
currentUser = { name: 'John' };
contentData = [...];
}
@Component({
selector: 'app-header',
template: `<nav>Welcome {{ user.name }}</nav>`
})
export class HeaderComponent {
@Input() user: User;
}Wenn currentUser in AppComponent ändert,
läuft Change Detection: 1. Check AppComponent bindings 2.
Check HeaderComponent bindings (empfängt neues
user Input) 3. Check ContentComponent
bindings
Die Reihenfolge ist garantiert. HeaderComponent sieht
nie einen inconsistent State wo AppComponent updated ist
aber Input noch alt.
Die Default-Strategy checked alle Bindings bei jedem Change Detection
Cycle. Jedes {{ expression }} im Template wird evaluated,
jedes [property]="value" wird re-computed.
@Component({
selector: 'app-counter',
template: `
<div>Count: {{ count }}</div>
<div>Squared: {{ count * count }}</div>
<button (click)="increment()">Increment</button>
`
})
export class CounterComponent {
count = 0;
increment() {
this.count++;
}
}Bei jedem Change Detection Cycle evaluiert Angular: -
{{ count }} - {{ count * count }}
Dies passiert auch wenn count sich nicht geändert hat.
Ein Click irgendwo in der App triggert Change Detection für alle
Components. Dies ist ineffizient aber simpel und fehlertolerant.
Die Default-Strategy macht keine Annahmen über Component-State-Management. Sie ist safe – keine Änderung wird verpasst. Der Preis ist Performance. Bei hunderten Components und komplexen Expressions wird dies merkbar.
Die OnPush-Strategy ist eine Optimierung. Sie assumiert dass
Component-State nur ändert wenn: 1. Ein @Input eine neue
Referenz erhält 2. Ein Event-Handler innerhalb der Component läuft 3.
AsyncPipe emittiert einen neuen Wert 4. Change Detection manuell
getriggert wird
Diese Annahmen erlauben Angular, Components zu skippen deren State nicht geändert haben kann. Das Result ist drastisch weniger Checks.
@Component({
selector: 'app-product',
template: `
<h2>{{ product.name }}</h2>
<p>{{ product.price | currency }}</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductComponent {
@Input() product: Product;
}Diese Component wird nur gecheckt wenn das product Input
eine neue Object-Referenz bekommt. Änderungen an Properties des
existierenden Objects triggern keine Change Detection:
// In Parent-Component
// Dies triggert KEINE Change Detection für ProductComponent
this.product.price = 99.99;
// Dies triggert Change Detection
this.product = { ...this.product, price: 99.99 };OnPush erfordert Immutability. State-Updates müssen neue Objects/Arrays kreieren statt existierende zu mutieren. Dies ist ein Pattern-Shift aber ermöglicht massive Performance-Gains.
Der Immutability-Constraint ist nicht nur für Performance. Er macht Code predictable. Wenn eine Function ein Object empfängt, kann es nicht mutiert werden. Dies vereinfacht Reasoning über Code-Flow.
OnPush checked Inputs via Referenz-Equality (===).
Primitive Types (Number, String, Boolean) sind by-value. Objects und
Arrays sind by-reference.
@Component({
selector: 'app-list',
template: `
<div *ngFor="let item of items">{{ item }}</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListComponent {
@Input() items: string[];
}
// Parent
this.items = ['a', 'b', 'c'];
// Dies triggert KEINE CD - gleiche Referenz
this.items.push('d');
// Dies triggert CD - neue Referenz
this.items = [...this.items, 'd'];Die Array-Mutation ändert die Referenz nicht. OnPush merkt dies nicht. Die immutable Variante kreiert ein neues Array. OnPush detektiert die neue Referenz und triggert Change Detection.
Für Objects ist das Pattern identisch:
// Mutation - triggert KEINE CD
this.user.name = 'Jane';
// Immutable - triggert CD
this.user = { ...this.user, name: 'Jane' };Der Spread-Operator ... ist essentiell für immutable
Updates. Er kreiert ein shallow Copy. Für nested Objects ist dies
insufficient:
interface User {
name: string;
address: {
city: string;
};
}
// Shallow copy - address ist noch die gleiche Referenz
this.user = { ...this.user };
this.user.address.city = 'Berlin'; // Mutation!
// Deep immutable update
this.user = {
...this.user,
address: {
...this.user.address,
city: 'Berlin'
}
};Dies ist verbose. Libraries wie Immer vereinfachen immutable Updates für nested Structures.
Event-Handlers triggern Change Detection automatisch, auch mit OnPush:
@Component({
selector: 'app-button',
template: `
<button (click)="handleClick()">{{ label }}</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ButtonComponent {
label = 'Click me';
handleClick() {
this.label = 'Clicked!';
// Change Detection wird automatisch getriggert
}
}Der Click-Handler läuft innerhalb Angular’s Zone. Zone.js detektiert
dies und triggert Change Detection. Die Component wird gechecked,
label hat einen neuen Wert, das Template wird updated.
Dies gilt für alle DOM-Events: click,
input, keydown, etc. Custom Events via
@Output propagieren ebenfalls:
@Component({
selector: 'app-child',
template: `<button (click)="notify()">Notify Parent</button>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildComponent {
@Output() notification = new EventEmitter<string>();
notify() {
this.notification.emit('Hello from child');
}
}
@Component({
selector: 'app-parent',
template: `
<app-child (notification)="handleNotification($event)"></app-child>
<div>{{ message }}</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ParentComponent {
message = '';
handleNotification(msg: string) {
this.message = msg;
// Change Detection läuft automatisch
}
}Der Event bubbles up. Der Parent-Handler läuft. Change Detection triggered. Beide Components werden gechecked.
Die AsyncPipe subscribed Observables und triggert Change Detection bei jedem emittierten Wert. Sie ist perfekt für OnPush:
@Component({
selector: 'app-users',
template: `
@if (users$ | async; as users) {
<div *ngFor="let user of users">{{ user.name }}</div>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UsersComponent {
users$ = this.userService.getUsers();
constructor(private userService: UserService) {}
}Die Pipe: 1. Subscribed users$ beim Template-Render 2.
Markiert die Component für Check bei jedem emittierten Wert 3.
Unsubscribes automatisch bei Component-Destroy
Ohne AsyncPipe wäre dies verbose:
export class UsersComponent implements OnInit, OnDestroy {
users: User[] = [];
private subscription: Subscription;
constructor(
private userService: UserService,
private cdr: ChangeDetectorRef
) {}
ngOnInit() {
this.subscription = this.userService.getUsers().subscribe(users => {
this.users = users;
this.cdr.markForCheck(); // Manuelles Triggern
});
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}AsyncPipe eliminiert diesen Boilerplate und integriert nahtlos mit OnPush.
Manchmal ist automatische Change Detection nicht gewünscht.
ChangeDetectorRef bietet fine-grained Control:
@Component({
selector: 'app-manual',
template: `<div>{{ data }}</div>`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManualComponent {
data = '';
constructor(private cdr: ChangeDetectorRef) {}
updateData(newData: string) {
this.data = newData;
// Option 1: Markiere für nächsten Cycle
this.cdr.markForCheck();
// Option 2: Check sofort (nur dieser Subtree)
// this.cdr.detectChanges();
}
}markForCheck() markiert die Component und alle Parents
bis Root als “dirty”. Der nächste Change Detection Cycle checked sie.
detectChanges() triggert sofort einen Check für diese
Component und Children.
Der Unterschied ist timing. markForCheck() wartet auf
den nächsten regulären Cycle. detectChanges() läuft sofort,
unabhängig von Zone.js. Dies ist useful für async Operations outside
Angular’s Zone.
Eine Component kann sich von Change Detection detachen:
constructor(private cdr: ChangeDetectorRef) {
this.cdr.detach();
}Detached Components werden nie automatisch gechecked. Change Detection muss manuell getriggert werden. Dies ist useful für high-frequency Updates:
export class AnimationComponent implements OnInit, OnDestroy {
position = 0;
private animationId: number;
constructor(private cdr: ChangeDetectorRef) {
this.cdr.detach(); // Detach from automatic CD
}
ngOnInit() {
this.animate();
}
animate() {
this.animationId = requestAnimationFrame(() => {
this.position += 1;
// Manual CD alle 100ms statt bei jedem Frame
if (this.position % 100 === 0) {
this.cdr.detectChanges();
}
this.animate();
});
}
ngOnDestroy() {
cancelAnimationFrame(this.animationId);
this.cdr.reattach(); // Cleanup
}
}Animations laufen 60 FPS. Change Detection bei jedem Frame ist overkill. Detach und manuell triggern alle 100ms ist effizienter.
NgZone ist Angular’s Wrapper um Zone.js. Es ermöglicht
Code-Execution outside Angular’s Zone:
@Component({
selector: 'app-clock',
template: `<div>{{ time }}</div>`
})
export class ClockComponent {
time = new Date().toLocaleTimeString();
constructor(private ngZone: NgZone) {}
startClock() {
this.ngZone.runOutsideAngular(() => {
setInterval(() => {
this.time = new Date().toLocaleTimeString();
// CD manuell triggern (z.B. jede Sekunde)
this.ngZone.run(() => {});
}, 1000);
});
}
}Der setInterval läuft outside Angular’s Zone. Zone.js
detektiert ihn nicht. Change Detection wird nicht automatisch
getriggert. Dies verhindert unnötige Cycles.
ngZone.run() führt Code inside Angular’s Zone aus. Dies
triggert Change Detection. Im Beispiel wird CD einmal pro Sekunde
getriggert statt bei jedem Interval-Tick.
Dies ist ein advanced Pattern für Performance-kritische Scenarios. Die meisten Components sollten innerhalb Angular’s Zone laufen.
Angular Signals sind ein neues Reactivity-Primitive. Sie bieten fine-grained Reactivity ohne die Complexity von Observables oder Zone.js.
Ein Signal ist ein Wert-Container mit Notification:
import { signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<div>Count: {{ count() }}</div>
<div>Doubled: {{ doubled() }}</div>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = signal(0);
doubled = computed(() => this.count() * 2);
constructor() {
effect(() => {
console.log('Count changed:', this.count());
});
}
increment() {
this.count.update(n => n + 1);
}
}Signals tracked automatisch Dependencies. Das doubled
Signal re-computes wenn count ändert. Der
effect läuft bei jeder count-Änderung.
Im Template wird count() called – dies ist ein
Signal-Read. Angular tracked diesen Read. Wenn das Signal updated, wird
nur dieser spezifische Teil des Templates re-rendered. Nicht die gesamte
Component, nur der Binding-Expression.
Dies ist drastisch effizienter als traditional Change Detection. Kein Tree-Walk, keine Expression-Re-Evaluation für ungeänderte Werte. Nur affected Bindings updaten.
Signals funktionieren perfekt mit OnPush:
@Component({
selector: 'app-user',
template: `
<div>Name: {{ user().name }}</div>
<div>Age: {{ user().age }}</div>
<button (click)="birthday()">Birthday</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserComponent {
user = signal({ name: 'John', age: 30 });
birthday() {
this.user.update(u => ({ ...u, age: u.age + 1 }));
}
}Signal-Updates triggern Change Detection für die Component. Kein
markForCheck() nötig. Signals integrieren nahtlos mit
beiden Strategies.
Signals und Observables sind complementary, nicht competitive. Signals sind synchron und lightweight. Observables sind asynchron und feature-rich.
Signals eignen sich für synchronen Component-State:
selectedId = signal<number | null>(null);
items = signal<Item[]>([]);Observables eignen sich für asynchrone Data-Streams:
users$ = this.http.get<User[]>('/api/users');
search$ = this.searchControl.valueChanges.pipe(
debounceTime(300),
switchMap(term => this.searchService.search(term))
);Die Kombination ist mächtig:
@Component({
template: `
@if (users$ | async; as users) {
<div *ngFor="let user of users"
[class.selected]="user.id === selectedId()">
{{ user.name }}
</div>
}
`
})
export class UserListComponent {
selectedId = signal<number | null>(null);
users$ = this.userService.getUsers();
}Observables für async HTTP, Signals für sync Component-State.
OnPush und Signals erzwingen Immutability-Patterns. Dies ist ein Constraint aber auch ein Feature. Immutable Updates machen Code predictable.
Ein mutable Update:
// Component A
this.sharedState.count++;
// Component B - unerwartete Änderung
console.log(this.sharedState.count); // ???Component B’s State ändert sich unexpectedly. Dies ist ein Action-at-a-Distance-Problem. Debugging ist schwierig – wer hat den State mutiert?
Ein immutable Update:
// Component A
const newState = { ...this.sharedState, count: this.sharedState.count + 1 };
this.stateService.updateState(newState);
// Component B subscribed den State Service
this.stateService.state$.subscribe(state => {
console.log(state.count); // Explicit update
});Component B empfängt einen expliziten Update-Notification. Der State-Flow ist transparent. Debugging ist einfach – folge dem Observable-Stream.
Immutability macht Time-Travel-Debugging möglich. Jeder State ist eine Snapshot. Man kann backwards durch History navigieren. Redux DevTools demonstrieren dies.
*ngFor rendert Lists. Ohne trackBy
destroyed und recreated Angular DOM-Nodes bei jedem Array-Update:
@Component({
template: `
<div *ngFor="let item of items">{{ item.name }}</div>
`
})
export class ListComponent {
items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
refresh() {
this.items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }]; // Neue Referenz
}
}refresh() kreiert ein neues Array mit gleichen Daten.
Angular sieht neue Object-Referenzen und destroyed/recreated alle
DOM-Nodes. Dies ist ineffizient.
trackBy gibt Angular einen Identity-Hint:
@Component({
template: `
<div *ngFor="let item of items; trackBy: trackById">
{{ item.name }}
</div>
`
})
export class ListComponent {
items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
trackById(index: number, item: any): number {
return item.id;
}
refresh() {
this.items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];
}
}Angular nutzt id als Identity. Trotz neuer
Array-Referenz erkennt Angular dass die Items gleich sind (gleiche
id). DOM-Nodes werden reused, nicht recreated.
Dies ist kritisch für Performance bei large Lists. Ohne
trackBy ist jedes Update ein complete Re-Render.
Tausende Items in DOM sind langsam, egal wie optimiert Change Detection ist. Virtual Scrolling rendert nur sichtbare Items:
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
template: `
<cdk-virtual-scroll-viewport itemSize="50" class="viewport">
<div *cdkVirtualFor="let item of items" class="item">
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [`
.viewport { height: 400px; }
.item { height: 50px; }
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class VirtualListComponent {
items = Array.from({ length: 100000 }, (_, i) => ({
id: i,
name: `Item ${i}`
}));
}Nur ~10 Items sind im DOM, auch bei 100k Items im Array. Scrolling updates den DOM-Window. Change Detection ist minimal – nur sichtbare Items werden gechecked.
Dies kombiniert gut mit OnPush. Large Lists werden performant.
Angular’s Dependency auf Zone.js ist historisch. Modern Frameworks wie Solid und Svelte nutzen fine-grained Reactivity ohne global Monkey-Patching. Angular bewegt sich in diese Richtung.
Zoneless Angular (experimental) removed Zone.js Dependency:
import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
bootstrapApplication(AppComponent, {
providers: [
provideExperimentalZonelessChangeDetection()
]
});Ohne Zone.js muss Change Detection manuell getriggert werden oder via Signals. Signals sind der preferred Path – sie bieten automatische fine-grained Updates ohne Zone.js overhead.
Zoneless ist die Zukunft. Signals werden zum primary Reactivity-Primitive. Zone.js bleibt für Backwards-Compatibility aber neue Apps sollten Signals nutzen.
Change Detection ist Angular’s Mechanism für State-View-Synchronization. Die Default-Strategy ist simple aber inefficient. OnPush optimiert via Referenz-Equality und Input-Checks. Signals bieten fine-grained Reactivity ohne Zone.js. Die Evolution geht von global Change Detection zu local reactive Updates. Performance-critical Apps sollten OnPush + Signals nutzen. Immutability ist key – mutable Updates brechen OnPush. TrackBy und Virtual Scrolling optimieren Lists. Die Zukunft ist Zoneless mit Signal-basierter Reactivity.