Fiecare rând dintr-o listă înseamnă elemente DOM, stiluri calculate, layout și, în Angular, un view cu binding-uri verificate de change detection. La câteva sute de rânduri nu se simte. La zeci de mii, pagina pornește în secunde, scroll-ul sacadează și memoria crește constant. Soluția clasică e să nu randezi ce nu se vede.
Virtual scroll păstrează în DOM doar rândurile din zona vizibilă, plus o rezervă sus și jos. La scroll, aceleași elemente sunt refolosite pentru alte date. Dimensiunea totală a listei e simulată, așa că bara de scroll arată ca pentru lista întreagă.
CDK virtual scroll
Din @angular/cdk/scrolling folosești trei piese standalone: CdkVirtualScrollViewport (containerul cu scroll), CdkFixedSizeVirtualScroll (strategia: atributul itemSize) și CdkVirtualForOf (directiva *cdkVirtualFor, care înlocuiește @for). Viewport-ul trebuie să aibă o înălțime, iar itemSize trebuie să fie exact înălțimea reală a rândului în pixeli. Strategia calculează din ea ce indici sunt vizibili.
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; } /* viewport-ul TREBUIE să aibă înălțime */
.row { height: 36px; } /* = itemSize, exact */
`,
})
export class Orders {
readonly orders = input.required<Order[]>();
protected readonly byId = (_: number, o: Order) => o.id;
}@for randează mereu toate elementele. Pentru virtualizare ai nevoie de directiva *cdkVirtualFor, care primește de la viewport intervalul de indici de randat.Bufferul și reciclarea view-urilor
minBufferPx și maxBufferPx controlează rezerva randată în afara ecranului. Când rezerva scade sub minBufferPx, viewport-ul randează rânduri noi până ajunge la maxBufferPx. Un buffer mai mare înseamnă mai puține goluri la scroll rapid, dar mai mult DOM. View-urile care ies din ecran sunt păstrate într-un cache (templateCacheSize) și refolosite. De aceea contează trackBy: fără el, la fiecare scroll se recreează mai mult DOM decât e nevoie.
<!-- minBufferPx: sub atâția px de rezervă (sus/jos) se randează mai mult… maxBufferPx: …până la atâția px -->
<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>Înălțimi variabile și alte limite
Strategia standard presupune rânduri de aceeași înălțime. O strategie autosize pentru elemente cu dimensiuni diferite există în @angular/cdk-experimental, dar documentația CDK spune explicit că nu e gata de producție. Dacă rândurile tale diferă, ai trei variante:
- le aduci la aceeași înălțime (text trunchiat, detalii la click);
- îți scrii propria
VirtualScrollStrategy; - alegi o alternativă de mai jos.
Mai e un cost de UX: rândurile nerandate nu există în DOM. Ctrl+F nu le găsește, iar cititoarele de ecran nu știu de ele.
Alternative: încărcare pe bucăți
Multe liste nu au nevoie de virtualizare, ci doar să nu încarce totul deodată. Paginarea e cea mai simplă și cea mai accesibilă. Infinite scroll adaugă pagini când utilizatorul ajunge la un element „santinelă”, observat cu IntersectionObserver sau cu @defer (on viewport) (vezi @defer). CSS-ul content-visibility: auto lasă DOM-ul complet, dar sare peste layout și desenare pentru ce e în afara ecranului.
// Infinite scroll: un element „santinelă” la final + IntersectionObserver
afterNextRender(() => {
const io = new IntersectionObserver(
([entry]) => entry.isIntersecting && this.loadNextPage(),
{ rootMargin: '0px 0px 200px 0px' }, // începe încărcarea cu 200 px înainte
);
io.observe(this.sentinel().nativeElement);
this.destroyRef.onDestroy(() => io.disconnect());
});Când DA, când NU
- Mii sau milioane de rânduri, deja în memorie: loguri, tranzacții, tabele de date.
- Rânduri de aceeași înălțime (sau pe care le poți aduce la aceeași înălțime).
- Liste derulate des, unde fluiditatea scroll-ului contează.
- Selectoare cu foarte multe opțiuni (dropdown cu 10.000 de elemente).
- Sub câteva sute de rânduri:
@forcutracke suficient și mai simplu. - Datele vin oricum paginat de la server: paginare sau infinite scroll.
- Conținut pe care utilizatorii îl caută cu Ctrl+F sau trebuie să fie complet accesibil: paginare,
content-visibility. - Carduri cu înălțimi foarte diferite:
content-visibilitysau@deferpe secțiuni.
Capcane frecvente
- Viewport fără înălțime: se întinde cât lista, deci randează tot, adică nicio virtualizare. Dă-i o înălțime fixă sau flex.
itemSizediferit de înălțimea reală (padding, border, line-height): rândurile „sar”, apar goluri, iarscrollToIndexajunge în altă parte.- Fără
trackByla date care se reîncarcă: tot DOM-ul vizibil e recreat la fiecare actualizare. - Containerul își schimbă dimensiunea (panou închis, tab activat) și viewport-ul nu știe. Apelează
checkViewportSize(). - Infinite scroll fără
disconnect()la observer: callback-uri după distrugerea componentei.
Fișă rapidă
NUMĂR DE RÂNDURI SOLUȚIE
< ~300 @for simplu, cu track
300 – câteva mii paginare sau infinite scroll (date încărcate pe bucăți) · content-visibility: auto
mii – milioane CDK virtual scroll (randează doar ce se vede + buffer)
CDK
importuri CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf (@angular/cdk/scrolling)
obligatoriu înălțime pe viewport + itemSize = înălțimea reală a rândului
opțiuni minBufferPx / maxBufferPx · templateCacheSize · appendOnly · orientation="horizontal"
API scrollToIndex(i, 'smooth') · scrolledIndexChange · getRenderedRange() · checkViewportSize()
înălțimi variabile autosize e în @angular/cdk-experimental, nu e gata de producție
CAPCANE @for nu virtualizează · fără trackBy se recreează DOM la fiecare scroll · Ctrl+F nu găsește rândurile nerandate