Chaque ligne d’une liste, ce sont des éléments DOM, des styles calculés, du layout et, dans Angular, une vue avec des Bindings vérifiés par la Change Detection. Avec quelques centaines de lignes, vous ne le remarquez pas. Avec des dizaines de milliers, la page met plusieurs secondes à démarrer, le défilement saccade et la mémoire ne cesse de croître. La solution classique : ne pas rendre ce que l’on ne voit pas.
Le Virtual Scroll ne garde dans le DOM que les lignes de la zone visible, plus une marge de réserve au-dessus et en dessous. Au fil du défilement, les mêmes éléments sont réutilisés pour d’autres données. La taille totale de la liste est simulée : la barre de défilement se comporte comme si elle correspondait à toute la liste.
CDK Virtual Scroll
Depuis @angular/cdk/scrolling, vous utilisez trois éléments Standalone : CdkVirtualScrollViewport (le conteneur de défilement), CdkFixedSizeVirtualScroll (la stratégie : l’attribut itemSize) et CdkVirtualForOf (la directive *cdkVirtualFor, qui remplace @for). Le viewport doit avoir une hauteur, et itemSize doit être exactement la hauteur réelle d’une ligne en pixels. La stratégie s’en sert pour déterminer quels indices sont visibles.
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; } /* le viewport DOIT avoir une hauteur */
.row { height: 36px; } /* = itemSize, exactement */
`,
})
export class Orders {
readonly orders = input.required<Order[]>();
protected readonly byId = (_: number, o: Order) => o.id;
}@for rend toujours tous les éléments. Pour virtualiser, il vous faut la directive *cdkVirtualFor, qui reçoit du viewport la plage d’indices à rendre.Le buffer et le recyclage des vues
minBufferPx et maxBufferPx contrôlent la marge rendue hors de l’écran. Quand la marge descend sous minBufferPx, le viewport rend de nouvelles lignes jusqu’à atteindre maxBufferPx. Un buffer plus grand signifie moins de trous lors d’un défilement rapide, mais plus de DOM. Les vues qui quittent l’écran sont conservées dans un cache (templateCacheSize) et réutilisées. C’est pourquoi trackBy compte : sans lui, bien plus de DOM que nécessaire est recréé à chaque défilement.
<!-- minBufferPx : quand la réserve (au-dessus et en dessous) tombe sous ce nombre de px, il rend davantage… maxBufferPx : …jusqu’à atteindre ce nombre de 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>Hauteurs variables et autres limites
La stratégie standard suppose des lignes de même hauteur. Une stratégie autosize pour des éléments de tailles différentes existe dans @angular/cdk-experimental, mais la documentation du CDK dit explicitement qu’elle n’est pas prête pour la production. Si vos lignes diffèrent, vous avez trois options :
- leur donner la même hauteur (texte tronqué, détails au clic) ;
- écrire votre propre
VirtualScrollStrategy; - choisir l’une des alternatives ci-dessous.
Il y a aussi un coût UX : les lignes non rendues n’existent pas dans le DOM. Ctrl+F ne les trouvera pas, et les lecteurs d’écran ignorent leur existence.
Alternatives : le chargement par blocs
Beaucoup de listes n’ont pas besoin de virtualisation : elles ne doivent simplement pas tout charger d’un coup. La pagination est la plus simple et la plus accessible. Le défilement infini ajoute des pages quand l’utilisateur atteint un élément « sentinelle », observé avec IntersectionObserver ou avec @defer (on viewport) (voir @defer). La propriété CSS content-visibility: auto conserve le DOM complet, mais évite le layout et le rendu de ce qui est hors écran.
// Défilement infini : un élément « sentinelle » à la fin + IntersectionObserver
afterNextRender(() => {
const io = new IntersectionObserver(
([entry]) => entry.isIntersecting && this.loadNextPage(),
{ rootMargin: '0px 0px 200px 0px' }, // commence à charger 200 px plus tôt
);
io.observe(this.sentinel().nativeElement);
this.destroyRef.onDestroy(() => io.disconnect());
});Quand OUI, quand NON
- Des milliers ou des millions de lignes, déjà en mémoire : logs, transactions, tableaux de données.
- Des lignes de même hauteur (ou que vous pouvez ramener à la même hauteur).
- Des listes que l’on fait souvent défiler, où la fluidité compte.
- Des sélecteurs avec un très grand nombre d’options (un dropdown de 10 000 éléments).
- Moins de quelques centaines de lignes :
@foravectracksuffit et reste plus simple. - Les données arrivent déjà paginées du serveur : pagination ou défilement infini.
- Un contenu que les utilisateurs recherchent avec Ctrl+F, ou qui doit être entièrement accessible : pagination,
content-visibility. - Des cartes de hauteurs très différentes :
content-visibilityou@defersur les sections.
Pièges fréquents
- Viewport sans hauteur : il s’étire à la taille de la liste, donc il rend tout, et il n’y a plus aucune virtualisation. Donnez-lui une hauteur fixe ou flex.
itemSizedifférent de la hauteur réelle (padding, bordure, line-height) : les lignes « sautent », des trous apparaissent, etscrollToIndexatterrit ailleurs.- Pas de
trackBysur des données qui se rechargent : tout le DOM visible est recréé à chaque mise à jour. - Le conteneur change de taille (un panneau se ferme, un onglet est activé) et le viewport ne le sait pas. Appelez
checkViewportSize(). - Défilement infini sans
disconnect()sur l’observer : les callbacks s’exécutent après la destruction du composant.
Aide-mémoire
NOMBRE DE LIGNES SOLUTION
< ~300 @for simple avec track
300 – quelques milliers pagination ou défilement infini (données chargées par blocs) · content-visibility: auto
milliers – millions CDK Virtual Scroll (ne rend que le visible + le buffer)
CDK
imports CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf (@angular/cdk/scrolling)
obligatoire hauteur du viewport + itemSize = la hauteur réelle d’une ligne
options minBufferPx / maxBufferPx · templateCacheSize · appendOnly · orientation="horizontal"
API scrollToIndex(i, 'smooth') · scrolledIndexChange · getRenderedRange() · checkViewportSize()
hauteurs variables autosize est dans @angular/cdk-experimental, pas prêt pour la production
PIÈGES @for ne virtualise pas · sans trackBy, le DOM est recréé à chaque défilement · Ctrl+F ne trouve pas les lignes non rendues