0 Tage Serie
javascript / expert
Snippet

Immutable Array-Abgleich für TrackBy-optimierte Listen

Wenn ein Live-Feed bei jedem Tick ein vollständiges Array neu ausgibt, zwingt ein naives Ersetzen des gesamten Arrays Angulars `@for`-Block dazu, jede Zeile neu auszuwerten, selbst wenn sich nur eine geändert hat — schlimmer noch, es bricht die Referenzgleichheit für Kind-Komponenten mit `OnPush`, obwohl deren Daten identisch sind. `RowReconciler.merge` führt einen geschlüsselten Diff durch: unveränderte Zeilen behalten ihre ursprüngliche Objektreferenz (`existing`, nicht `next`), sodass `OnPush`-Kindkomponenten das Re-Rendering vollständig überspringen; nur Zeilen, deren `version` sich tatsächlich erhöht hat, werden ersetzt. In Kombination mit `track row.id` vermeidet Angulars Abgleich das Zerstören und Neuerzeugen von DOM-Knoten für Zeilen, die lediglich ihre Position gewechselt haben.

snippet.js
javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
interface Row { id: string; version: number; label: string; }
@Injectable({ providedIn: 'root' })
export class RowReconciler {
merge(current: readonly Row[], incoming: readonly Row[]): Row[] {
const incomingById = new Map(incoming.map((row) => [row.id, row]));
const merged: Row[] = [];
for (const existing of current) {
const next = incomingById.get(existing.id);
if (!next) continue;
merged.push(next.version === existing.version ? existing : next);
incomingById.delete(existing.id);
}
for (const remaining of incoming) {
if (incomingById.has(remaining.id)) {
merged.push(remaining);
}
}
return merged;
}
}
@Component({
selector: 'app-row-list',
standalone: true,
template: `
@for (row of rows(); track row.id) {
<app-row [data]="row" />
}
`,
})
export class RowListComponent {
private readonly reconciler = inject(RowReconciler);
private readonly source = inject(RowFeed).stream();
readonly rows = toSignal(
this.source.pipe(scan((acc, incoming) => this.reconciler.merge(acc, incoming), [] as Row[])),
{ initialValue: [] },
);
}
angular
Erklärung
1
merged.push(next.version === existing.version ? existing : next);
Bewahrt die Objektidentität unveränderter Zeilen, sodass nachgelagerte `OnPush`-Komponenten dieselbe Referenz sehen und die Change Detection überspringen.
2
const incomingById = new Map(incoming.map((row) => [row.id, row]));
Baut einmal vorab einen Lookup-Index auf und macht so aus einer O(n²)-verschachtelten Array-Suche O(1)-Map-Zugriffe.
3
this.source.pipe(scan((acc, incoming) => this.reconciler.merge(acc, incoming), [] as Row[]))
RxJS `scan` reicht das vorherige zusammengeführte Array als Akkumulator in jeden neuen Abgleichsdurchlauf weiter und macht den Diff über Emissionen hinweg zustandsbehaftet.