Jede Zeile in einer Liste bedeutet DOM-Elemente, berechnete Styles, Layout und in Angular einen View mit Bindings, die von der Change Detection geprüft werden. Bei ein paar hundert Zeilen merkst du nichts davon. Bei Zehntausenden braucht die Seite Sekunden zum Starten, das Scrollen ruckelt und der Speicherverbrauch wächst stetig. Die klassische Lösung: nicht rendern, was man nicht sieht.
Virtual Scroll hält nur die Zeilen im sichtbaren Bereich im DOM, plus einen Puffer darüber und darunter. Beim Scrollen werden dieselben Elemente für andere Daten wiederverwendet. Die Gesamtgröße der Liste wird simuliert, sodass die Scrollleiste aussieht, als gehörte sie zur ganzen Liste.
CDK Virtual Scroll
Aus @angular/cdk/scrolling nutzt du drei Standalone-Bausteine: CdkVirtualScrollViewport (der Container mit Scroll), CdkFixedSizeVirtualScroll (die Strategie: das Attribut itemSize) und CdkVirtualForOf (die Direktive *cdkVirtualFor, die @for ersetzt). Der Viewport braucht eine Höhe, und itemSize muss exakt der tatsächlichen Höhe einer Zeile in Pixeln entsprechen. Die Strategie berechnet daraus, welche Indizes sichtbar sind.
import { CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
@Component({
selector: 'app-orders',
imports: [CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf],
template: `
<cdk-virtual-scroll-viewport itemSize="36" class="viewport">
<div class="row" *cdkVirtualFor="let o of orders(); trackBy: byId">
{{ o.id }} · {{ o.customer }} · {{ o.total }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: `
.viewport { height: 480px; } /* der Viewport MUSS eine Höhe haben */
.row { height: 36px; } /* = itemSize, exakt */
`,
})
export class Orders {
readonly orders = input.required<Order[]>();
protected readonly byId = (_: number, o: Order) => o.id;
}@for-Block rendert immer alle Elemente. Für Virtualisierung brauchst du die Direktive *cdkVirtualFor, die vom Viewport den Bereich der zu rendernden Indizes erhält.Der Puffer und das Recycling von Views
minBufferPx und maxBufferPx steuern den Puffer, der außerhalb des Bildschirms gerendert wird. Fällt der Puffer unter minBufferPx, rendert der Viewport neue Zeilen, bis er maxBufferPx erreicht. Ein größerer Puffer bedeutet weniger Lücken beim schnellen Scrollen, aber mehr DOM. Views, die den Bildschirm verlassen, werden in einem Cache (templateCacheSize) gehalten und wiederverwendet. Deshalb ist trackBy wichtig: Ohne es wird bei jedem Scrollen viel mehr DOM neu erzeugt als nötig.
<!-- minBufferPx: Fällt der Puffer (oben und unten) unter so viele px, wird mehr gerendert … maxBufferPx: … bis so viele px erreicht sind -->
<cdk-virtual-scroll-viewport
itemSize="36"
minBufferPx="360"
maxBufferPx="720"
(scrolledIndexChange)="first.set($event)">
<div *cdkVirtualFor="let r of rows; trackBy: byId; templateCacheSize: 40">…</div>
</cdk-virtual-scroll-viewport>Variable Höhen und weitere Grenzen
Die Standardstrategie geht von Zeilen gleicher Höhe aus. Eine Autosize-Strategie für Elemente unterschiedlicher Größe gibt es in @angular/cdk-experimental, doch die CDK-Dokumentation sagt ausdrücklich, dass sie nicht produktionsreif ist. Wenn sich deine Zeilen unterscheiden, hast du drei Möglichkeiten:
- sie auf dieselbe Höhe bringen (abgeschnittener Text, Details per Klick);
- eine eigene
VirtualScrollStrategyschreiben; - eine der Alternativen unten wählen.
Dazu kommt ein UX-Preis: Zeilen, die nicht gerendert sind, existieren nicht im DOM. Strg+F findet sie nicht, und Screenreader wissen nichts von ihnen.
Alternativen: Laden in Blöcken
Viele Listen brauchen keine Virtualisierung, sie dürfen nur nicht alles auf einmal laden. Paginierung ist die einfachste und zugänglichste Lösung. Infinite Scroll fügt Seiten hinzu, wenn der Benutzer ein „Sentinel“-Element erreicht, das mit IntersectionObserver oder mit @defer (on viewport) beobachtet wird (siehe @defer). Die CSS-Eigenschaft content-visibility: auto lässt das DOM vollständig, überspringt aber Layout und Zeichnen für alles außerhalb des Bildschirms.
// Infinite scroll: ein „Sentinel“-Element am Ende + IntersectionObserver
afterNextRender(() => {
const io = new IntersectionObserver(
([entry]) => entry.isIntersecting && this.loadNextPage(),
{ rootMargin: '0px 0px 200px 0px' }, // beginne 200 px früher mit dem Laden
);
io.observe(this.sentinel().nativeElement);
this.destroyRef.onDestroy(() => io.disconnect());
});Wann ja, wann nein
- Tausende oder Millionen Zeilen, bereits im Speicher: Logs, Transaktionen, Datentabellen.
- Zeilen gleicher Höhe (oder solche, die du auf dieselbe Höhe bringen kannst).
- Listen, die oft gescrollt werden und bei denen flüssiges Scrollen zählt.
- Auswahlfelder mit sehr vielen Optionen (ein Dropdown mit 10.000 Einträgen).
- Unter ein paar hundert Zeilen:
@formittrackreicht und ist einfacher. - Die Daten kommen ohnehin paginiert vom Server: Paginierung oder Infinite Scroll.
- Inhalte, die Benutzer mit Strg+F durchsuchen oder die vollständig zugänglich sein müssen: Paginierung,
content-visibility. - Karten mit sehr unterschiedlichen Höhen:
content-visibilityoder@deferauf Abschnitten.
Häufige Stolperfallen
- Viewport ohne Höhe: Er dehnt sich auf die Größe der Liste aus und rendert damit alles, also gar keine Virtualisierung. Gib ihm eine feste Höhe oder eine Flex-Höhe.
itemSizeweicht von der tatsächlichen Höhe ab (Padding, Border, line-height): Zeilen „springen“, es entstehen Lücken, undscrollToIndexlandet an der falschen Stelle.- Kein
trackBybei Daten, die neu geladen werden: Das gesamte sichtbare DOM wird bei jedem Update neu erzeugt. - Der Container ändert seine Größe (ein Panel schließt sich, ein Tab wird aktiviert) und der Viewport weiß nichts davon. Rufe
checkViewportSize()auf. - Infinite Scroll ohne
disconnect()am Observer: Callbacks laufen weiter, nachdem die Komponente zerstört wurde.
Spickzettel
ANZAHL ZEILEN LÖSUNG
< ~300 einfaches @for mit track
300 – einige Tsd. Paginierung oder Infinite Scroll (Daten in Blöcken geladen) · content-visibility: auto
Tausende – Mio. CDK Virtual Scroll (rendert nur, was sichtbar ist + Puffer)
CDK
Imports CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf (@angular/cdk/scrolling)
Pflicht Höhe am Viewport + itemSize = tatsächliche Zeilenhöhe
Optionen minBufferPx / maxBufferPx · templateCacheSize · appendOnly · orientation="horizontal"
API scrollToIndex(i, 'smooth') · scrolledIndexChange · getRenderedRange() · checkViewportSize()
variable Höhen autosize steckt in @angular/cdk-experimental, nicht produktionsreif
STOLPERFALLEN @for virtualisiert nicht · ohne trackBy wird das DOM bei jedem Scrollen neu erzeugt · Strg+F findet nicht gerenderte Zeilen nicht