ListView JavaScript Performante en 2026 : Windowing, Virtualisation et Optimisation

Maîtrisez les techniques de virtualisation et windowing pour créer des listes JavaScript ultra-performantes. Guide complet avec React et NestJS.

ListView JavaScript Performante en 2026 : Windowing, Virtualisation et Optimisation

L'affichage de grandes quantités de données dans une application web représente un défi majeur pour les développeurs JavaScript. Qu'il s'agisse de milliers de produits dans un catalogue e-commerce ou de millions d'entrées dans un tableau de bord analytique, une approche naïve du rendu conduit inévitablement à des problèmes de performance. La virtualisation et le windowing constituent les solutions modernes pour résoudre ces problématiques.

La virtualisation de liste peut réduire le nombre d'éléments DOM de plusieurs milliers à quelques dizaines, améliorant drastiquement les temps de rendu et la consommation mémoire.

Comprendre le Problème de Performance des Listes

Lorsqu'une application JavaScript rend une liste de 10 000 éléments, chaque élément génère des nœuds DOM. Le navigateur doit calculer les styles, effectuer le layout et peindre chaque élément. Cette opération consomme de la mémoire et du temps CPU, provoquant des freezes et une expérience utilisateur dégradée.

Le problème s'aggrave avec la complexité des éléments. Un simple texte dans un <li> a un impact limité, mais une carte produit avec image, description et boutons multiplie les coûts de rendu.

typescript
// Approche naïve - À éviter avec de grandes listes
interface Product {
  id: string;
  name: string;
  price: number;
  imageUrl: string;
}

function ProductList({ products }: { products: Product[] }) {
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          <ProductCard product={product} />
        </li>
      ))}
    </ul>
  );
}

Le Principe du Windowing

Le windowing, ou fenêtrage, consiste à ne rendre que les éléments visibles dans la zone d'affichage (viewport) plus un buffer de quelques éléments au-dessus et en dessous. Les éléments hors écran sont détruits et recréés dynamiquement lors du défilement.

Cette technique réduit le nombre de nœuds DOM actifs de plusieurs ordres de grandeur. Une liste de 100 000 éléments peut ainsi fonctionner avec seulement 20 à 50 nœuds DOM visibles.

typescript
// Configuration de base pour le windowing
interface WindowConfig {
  itemHeight: number;
  containerHeight: number;
  overscan: number; // Éléments supplémentaires rendus hors écran
}

function calculateVisibleRange(
  scrollTop: number,
  config: WindowConfig,
  totalItems: number
): { startIndex: number; endIndex: number } {
  const { itemHeight, containerHeight, overscan } = config;
  
  const startIndex = Math.max(
    0,
    Math.floor(scrollTop / itemHeight) - overscan
  );
  
  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const endIndex = Math.min(
    totalItems - 1,
    startIndex + visibleCount + overscan * 2
  );
  
  return { startIndex, endIndex };
}

Implémentation avec React Window

React Window représente la bibliothèque de référence pour la virtualisation de listes en React. Légère et performante, elle gère automatiquement le calcul des positions et le recyclage des éléments.

typescript
import { FixedSizeList as List } from 'react-window';
import { memo, useCallback } from 'react';

interface RowProps {
  index: number;
  style: React.CSSProperties;
  data: Product[];
}

const Row = memo(function Row({ index, style, data }: RowProps) {
  const product = data[index];
  
  return (
    <div style={style} className="product-row">
      <img 
        src={product.imageUrl} 
        alt={product.name}
        loading="lazy"
      />
      <span>{product.name}</span>
      <span>{product.price.toFixed(2)} EUR</span>
    </div>
  );
});

function VirtualizedProductList({ products }: { products: Product[] }) {
  const itemData = products;
  
  return (
    <List
      height={600}
      itemCount={products.length}
      itemSize={80}
      width="100%"
      itemData={itemData}
    >
      {Row}
    </List>
  );
}

Listes à Hauteur Variable

Les listes réelles contiennent souvent des éléments de hauteurs différentes. React Window propose le composant VariableSizeList pour gérer cette situation.

typescript
import { VariableSizeList as List } from 'react-window';
import { useRef, useCallback } from 'react';

interface Message {
  id: string;
  content: string;
  author: string;
}

function ChatMessageList({ messages }: { messages: Message[] }) {
  const listRef = useRef<List>(null);
  
  // Cache des hauteurs calculées
  const heightCache = useRef<Map<number, number>>(new Map());
  
  const getItemHeight = useCallback((index: number): number => {
    if (heightCache.current.has(index)) {
      return heightCache.current.get(index)!;
    }
    
    // Estimation basée sur la longueur du contenu
    const message = messages[index];
    const baseHeight = 60;
    const charsPerLine = 50;
    const lineHeight = 24;
    const extraLines = Math.ceil(message.content.length / charsPerLine);
    
    const height = baseHeight + (extraLines - 1) * lineHeight;
    heightCache.current.set(index, height);
    
    return height;
  }, [messages]);
  
  const handleContentResize = useCallback((index: number, height: number) => {
    heightCache.current.set(index, height);
    listRef.current?.resetAfterIndex(index);
  }, []);
  
  return (
    <List
      ref={listRef}
      height={500}
      itemCount={messages.length}
      itemSize={getItemHeight}
      width="100%"
    >
      {({ index, style }) => (
        <MessageRow
          message={messages[index]}
          style={style}
          onResize={(height) => handleContentResize(index, height)}
        />
      )}
    </List>
  );
}

Backend NestJS pour le Chargement Paginé

La virtualisation côté client fonctionne optimalement avec un backend qui supporte la pagination. NestJS permet d'implémenter facilement des endpoints paginés performants.

typescript
import { Controller, Get, Query } from '@nestjs/common';
import { ProductService } from './product.service';

interface PaginationQuery {
  offset: number;
  limit: number;
  search?: string;
}

interface PaginatedResponse<T> {
  data: T[];
  total: number;
  offset: number;
  limit: number;
}

@Controller('products')
export class ProductController {
  constructor(private readonly productService: ProductService) {}
  
  @Get()
  async getProducts(
    @Query('offset') offset = 0,
    @Query('limit') limit = 50,
    @Query('search') search?: string
  ): Promise<PaginatedResponse<Product>> {
    const [data, total] = await Promise.all([
      this.productService.findMany({
        skip: Number(offset),
        take: Math.min(Number(limit), 100), // Limite maximale
        where: search
          ? { name: { contains: search, mode: 'insensitive' } }
          : undefined,
      }),
      this.productService.count({
        where: search
          ? { name: { contains: search, mode: 'insensitive' } }
          : undefined,
      }),
    ]);
    
    return {
      data,
      total,
      offset: Number(offset),
      limit: Number(limit),
    };
  }
}

Infinite Scroll avec Virtualisation

L'infinite scroll combine la virtualisation avec le chargement progressif des données. Cette approche offre la meilleure expérience utilisateur pour les grandes collections.

typescript
import { FixedSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { useInfiniteQuery } from '@tanstack/react-query';

function InfiniteProductList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['products'],
    queryFn: async ({ pageParam = 0 }) => {
      const response = await fetch(
        `/api/products?offset=${pageParam}&limit=50`
      );
      return response.json();
    },
    getNextPageParam: (lastPage) => {
      const nextOffset = lastPage.offset + lastPage.limit;
      return nextOffset < lastPage.total ? nextOffset : undefined;
    },
    initialPageParam: 0,
  });
  
  const allProducts = data?.pages.flatMap((page) => page.data) ?? [];
  const totalCount = data?.pages[0]?.total ?? 0;
  
  const isItemLoaded = (index: number) => {
    return !hasNextPage || index < allProducts.length;
  };
  
  const loadMoreItems = isFetchingNextPage
    ? () => Promise.resolve()
    : () => fetchNextPage();
  
  return (
    <InfiniteLoader
      isItemLoaded={isItemLoaded}
      itemCount={hasNextPage ? allProducts.length + 1 : allProducts.length}
      loadMoreItems={loadMoreItems}
    >
      {({ onItemsRendered, ref }) => (
        <List
          ref={ref}
          height={600}
          itemCount={totalCount}
          itemSize={80}
          width="100%"
          onItemsRendered={onItemsRendered}
        >
          {({ index, style }) => {
            if (!isItemLoaded(index)) {
              return (
                <div style={style} className="loading-placeholder">
                  Chargement...
                </div>
              );
            }
            
            return (
              <ProductRow
                product={allProducts[index]}
                style={style}
              />
            );
          }}
        </List>
      )}
    </InfiniteLoader>
  );
}

Optimisation des Performances de Rendu

Plusieurs techniques complémentaires améliorent les performances de la virtualisation.

typescript
import { memo, useMemo, useCallback, useRef } from 'react';
import { areEqual } from 'react-window';

// Memoization agressive des composants de ligne
const ProductRow = memo(function ProductRow({
  product,
  style,
  onSelect,
}: {
  product: Product;
  style: React.CSSProperties;
  onSelect: (id: string) => void;
}) {
  const handleClick = useCallback(() => {
    onSelect(product.id);
  }, [product.id, onSelect]);
  
  return (
    <div style={style} onClick={handleClick}>
      <span>{product.name}</span>
      <span>{product.price.toFixed(2)} EUR</span>
    </div>
  );
}, areEqual);

// Hook personnalisé pour la gestion du scroll optimisée
function useOptimizedScroll(
  onScroll: (scrollTop: number) => void,
  delay = 16
) {
  const lastScrollTop = useRef(0);
  const ticking = useRef(false);
  
  return useCallback((event: React.UIEvent<HTMLDivElement>) => {
    const scrollTop = event.currentTarget.scrollTop;
    lastScrollTop.current = scrollTop;
    
    if (!ticking.current) {
      requestAnimationFrame(() => {
        onScroll(lastScrollTop.current);
        ticking.current = false;
      });
      ticking.current = true;
    }
  }, [onScroll]);
}

Grilles Virtualisées

Les grilles à deux dimensions nécessitent une approche de virtualisation bidirectionnelle. React Window fournit le composant FixedSizeGrid pour ce cas d'usage.

typescript
import { FixedSizeGrid as Grid } from 'react-window';

interface GridItem {
  id: string;
  thumbnailUrl: string;
  title: string;
}

function VirtualizedImageGrid({ items }: { items: GridItem[] }) {
  const columnCount = 4;
  const rowCount = Math.ceil(items.length / columnCount);
  
  const Cell = useCallback(
    ({
      columnIndex,
      rowIndex,
      style,
    }: {
      columnIndex: number;
      rowIndex: number;
      style: React.CSSProperties;
    }) => {
      const index = rowIndex * columnCount + columnIndex;
      
      if (index >= items.length) {
        return null;
      }
      
      const item = items[index];
      
      return (
        <div style={style} className="grid-cell">
          <img
            src={item.thumbnailUrl}
            alt={item.title}
            loading="lazy"
          />
          <span>{item.title}</span>
        </div>
      );
    },
    [items, columnCount]
  );
  
  return (
    <Grid
      columnCount={columnCount}
      columnWidth={200}
      height={600}
      rowCount={rowCount}
      rowHeight={250}
      width={800}
    >
      {Cell}
    </Grid>
  );
}

Mesure des Performances

La mesure précise des performances permet de valider l'efficacité de la virtualisation et d'identifier les goulots d'étranglement.

typescript
import { useEffect, useRef } from 'react';

function useRenderMetrics(componentName: string) {
  const renderCount = useRef(0);
  const lastRenderTime = useRef(performance.now());
  
  useEffect(() => {
    const currentTime = performance.now();
    const timeSinceLastRender = currentTime - lastRenderTime.current;
    renderCount.current++;
    
    if (process.env.NODE_ENV === 'development') {
      console.log(
        `[${componentName}] Render #${renderCount.current}`,
        `Time since last: ${timeSinceLastRender.toFixed(2)}ms`
      );
    }
    
    lastRenderTime.current = currentTime;
  });
}

// Service NestJS pour le monitoring des performances
import { Injectable } from '@nestjs/common';

@Injectable()
export class PerformanceService {
  private metrics: Map<string, number[]> = new Map();
  
  recordQueryTime(queryName: string, duration: number): void {
    if (!this.metrics.has(queryName)) {
      this.metrics.set(queryName, []);
    }
    
    const times = this.metrics.get(queryName)!;
    times.push(duration);
    
    // Conserver uniquement les 100 dernières mesures
    if (times.length > 100) {
      times.shift();
    }
  }
  
  getAverageQueryTime(queryName: string): number {
    const times = this.metrics.get(queryName);
    if (!times || times.length === 0) return 0;
    
    return times.reduce((sum, t) => sum + t, 0) / times.length;
  }
}

Prêt à réussir tes entretiens Node.js / NestJS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Conclusion

La virtualisation de listes représente une technique fondamentale pour les applications JavaScript modernes manipulant de grandes quantités de données. En combinant le windowing côté client avec une pagination efficace côté serveur, les développeurs peuvent créer des interfaces fluides capables d'afficher des millions d'éléments sans compromis sur les performances.

Les outils comme React Window simplifient considérablement l'implémentation de ces patterns. L'intégration avec TanStack Query pour l'infinite scroll et NestJS pour le backend crée une architecture complète et performante. La clé du succès réside dans la mesure continue des performances et l'optimisation itérative basée sur des données réelles.

Défi du jour

Tu saurais repérer le bug en Node.js / NestJS ?

Un vrai bout de code, un bug caché, une tentative par jour. Sans compte pour essayer.

Anthony Fillion-Maillet

Écrit par

Anthony Fillion-Maillet

Fondateur de SharpSkill

Développeur fullstack depuis plus de 10 ans. Il dirige SharpSkill et répond de tout ce qui y est publié.

Mis à jour le 15 septembre 2026

Partager

Articles similaires