Cours JavaScript et TypeScript · Module 9: Design patterns et architecture

Lazy loading - images, routes, composants

17 min de lecture
Dans cette leçon8

Imagine un visiteur qui, à l'entrée du parc, doit récupérer les cartes de tous les secteurs avant de faire le moindre pas. C'est exactement ainsi que fonctionne une application qui charge tout au démarrage. Le lazy loading est une stratégie d'optimisation qui consiste à retarder le chargement des ressources jusqu'au moment où elles deviennent réellement nécessaires : quand l'utilisateur fait défiler la page, passe sur une nouvelle route ou effectue une action qui exige un composant précis.

Les avantages du lazy loading

Tu gagnes un chargement initial plus rapide : un bundle de départ plus petit, un affichage plus rapide du premier contenu (FCP) et une meilleure perception des performances. Tu économises la RAM, le transfert de données et la batterie des appareils mobiles. L'utilisateur profite d'un défilement fluide, d'un chargement progressif du contenu et d'une adaptation aux capacités de son appareil et de sa connexion.

Le lazy loading des images

Le lazy loading natif en HTML

La méthode la plus simple ne demande aucun JavaScript. L'attribut loading indique au navigateur à quel moment télécharger l'image :

1<!-- Lazy loading natif pour les images sous la ligne de flottaison (pris en charge par les navigateurs modernes) -->
2<img src="gallery-photo.jpg" alt="Photo de la galerie" loading="lazy" />
3
4<!-- Eager loading pour les images above-the-fold -->
5<img src="logo.jpg" alt="Logo" loading="eager" />
6
7<!-- Sans attribut : eager par défaut (la valeur "auto" n'existe pas dans le standard HTML) -->
8<img src="banner.jpg" alt="Bannière" />

Le standard ne connaît que les valeurs lazy et eager, la valeur par défaut. Ne marque pas en lazy les images visibles immédiatement, car tu retarderais le plus grand élément de la page, et en indiquant width et height, tu évites les décalages de mise en page.

L'API Intersection Observer

Quand tu as besoin de plus de contrôle, utilise Intersection Observer. Cette API signale l'entrée d'un élément dans la zone visible (viewport) sans écouter chaque défilement :

1// Implémentation du lazy loading avec Intersection Observer
2class LazyImageLoader {
3  constructor() {
4    this.imageObserver = new IntersectionObserver(
5      this.handleIntersection.bind(this),
6      {
7        root: null, // viewport
8        rootMargin: '50px', // charger 50px avant l'entrée dans le viewport
9        threshold: 0.1 // déclencher quand 10 % de l'image est visible
10      }
11    );
12
13    this.initLazyImages();
14  }
15
16  initLazyImages() {
17    const lazyImages = document.querySelectorAll('img[data-src]');
18    lazyImages.forEach(img => {
19      this.imageObserver.observe(img);
20    });
21  }
22
23  handleIntersection(entries) {
24    entries.forEach(entry => {
25      if (entry.isIntersecting) {
26        const img = entry.target;
27        this.loadImage(img);
28        this.imageObserver.unobserve(img);
29      }
30    });
31  }
32
33  loadImage(img) {
34    // Chargement de l'image depuis l'attribut data-src
35    img.src = img.dataset.src;
36    img.classList.add('loaded');
37
38    // Suppression de data-src après le chargement
39    delete img.dataset.src;
40
41    // Gestion des erreurs de chargement
42    img.onerror = () => {
43      img.src = '/images/error-placeholder.png';
44      img.classList.add('error');
45    };
46  }
47}
48
49// Initialisation
50document.addEventListener('DOMContentLoaded', () => {
51  new LazyImageLoader();
52});

L'adresse attend dans data-src jusqu'à l'entrée dans la zone visible, et unobserve garantit que l'image ne se charge qu'une seule fois.

Des composants React pour le lazy loading des images

En React, on enferme cette logique dans un hook useIntersectionObserver et un composant LazyImage :

1// Hook pour le lazy loading des images
2import { useState, useRef, useEffect } from 'react';
3
4function useIntersectionObserver(options = {}) {
5  const [isIntersecting, setIsIntersecting] = useState(false);
6  const [hasIntersected, setHasIntersected] = useState(false);
7  const elementRef = useRef(null);
8
9  useEffect(() => {
10    const element = elementRef.current;
11    if (!element) return;
12
13    const observer = new IntersectionObserver(
14      ([entry]) => {
15        const isElementIntersecting = entry.isIntersecting;
16        setIsIntersecting(isElementIntersecting);
17
18        if (isElementIntersecting && !hasIntersected) {
19          setHasIntersected(true);
20        }
21      },
22      { threshold: 0.1, rootMargin: '50px', ...options }
23    );
24
25    observer.observe(element);
26
27    return () => observer.disconnect();
28  }, [hasIntersected, options]);
29
30  return { elementRef, isIntersecting, hasIntersected };
31}
32
33// Composant LazyImage
34function LazyImage({ src, alt, placeholder, className = '' }) {
35  const [imageLoaded, setImageLoaded] = useState(false);
36  const [imageError, setImageError] = useState(false);
37  const { elementRef, hasIntersected } = useIntersectionObserver();
38
39  const handleImageLoad = () => {
40    setImageLoaded(true);
41  };
42
43  const handleImageError = () => {
44    setImageError(true);
45  };
46
47  return (
48    <div ref={elementRef} className={`lazy-image-container ${className}`}>
49      {/* Placeholder affiché pendant le chargement */}
50      {!imageLoaded && (
51        <img
52          src={placeholder}
53          alt={`${alt} placeholder`}
54          className="placeholder-image"
55        />
56      )}
57
58      {/* L'image réelle, chargée en lazy */}
59      {hasIntersected && (
60        <img
61          src={imageError ? '/images/error.png' : src}
62          alt={alt}
63          onLoad={handleImageLoad}
64          onError={handleImageError}
65          className={`lazy-image ${imageLoaded ? 'loaded' : 'loading'}`}
66        />
67      )}
68
69      {/* Loading spinner */}
70      {hasIntersected && !imageLoaded && !imageError && (
71        <div className="image-spinner">Chargement...</div>
72      )}
73    </div>
74  );
75}
76
77// Utilisation du composant
78function Gallery({ images }) {
79  return (
80    <div className="gallery">
81      {images.map((image, index) => (
82        <LazyImage
83          key={index}
84          src={image.url}
85          alt={image.alt}
86          placeholder={image.placeholder}
87          className="gallery-item"
88        />
89      ))}
90    </div>
91  );
92}

Attention : la valeur par défaut options = {} crée un nouvel objet à chaque rendu, donc l'effet recrée l'observateur à chaque rendu. En production, passe un objet stable.

Chargement progressif des images

Un effet bien connu : un aperçu flou se transforme en douceur en image nette :

1// Composant avec chargement progressif (flou → net)
2function ProgressiveImage({ src, placeholder, alt }) {
3  const [imageLoaded, setImageLoaded] = useState(false);
4  const { elementRef, hasIntersected } = useIntersectionObserver();
5
6  return (
7    <div ref={elementRef} className="progressive-image">
8      {/* Placeholder flou */}
9      <img
10        src={placeholder}
11        alt={alt}
12        className={`progressive-placeholder`}
13        style={{
14          filter: 'blur(10px)',
15          transition: 'opacity 0.3s ease'
16        }}
17      />
18
19      {/* Image nette */}
20      {hasIntersected && (
21        <img
22          src={src}
23          alt={alt}
24          onLoad={() => setImageLoaded(true)}
25          className={`main-image ${imageLoaded ? 'visible' : 'hidden'}`}
26          style={{
27            opacity: imageLoaded ? 1 : 0,
28            transition: 'opacity 0.3s ease'
29          }}
30        />
31      )}
32    </div>
33  );
34}

Le placeholder léger est visible tout de suite, et l'image complète n'apparaît qu'après l'événement onLoad.

Le lazy loading des routes (route-based)

React Router avec Suspense

C'est en découpant le code par routes que tu gagneras le plus. Tu définis le composant de la page, tu l'enveloppes dans la fonction lazy avec un import() dynamique et tu ajoutes une frontière Suspense avec un composant de remplacement (fallback) :

1import { Suspense, lazy } from 'react';
2import { Routes, Route } from 'react-router';
3import ErrorBoundary from './components/ErrorBoundary';
4import LoadingSpinner from './components/LoadingSpinner';
5
6// Lazy loading des composants de routes
7const HomePage = lazy(() => import('./pages/HomePage'));
8const ProductsPage = lazy(() => import('./pages/ProductsPage'));
9const ProductDetailPage = lazy(() => import('./pages/ProductDetailPage'));
10const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
11const AdminPanel = lazy(() => import('./pages/AdminPanel'));
12
13// Composants avec chargement et gestion d'erreurs personnalisés
14const LazyRoute = ({ children }) => (
15  <ErrorBoundary>
16    <Suspense fallback={<LoadingSpinner />}>
17      {children}
18    </Suspense>
19  </ErrorBoundary>
20);
21
22function App() {
23  return (
24    <Routes>
25      <Route
26        path="/"
27        element={
28          <LazyRoute>
29            <HomePage />
30          </LazyRoute>
31        }
32      />
33      <Route
34        path="/products"
35        element={
36          <LazyRoute>
37            <ProductsPage />
38          </LazyRoute>
39        }
40      />
41      <Route
42        path="/products/:id"
43        element={
44          <LazyRoute>
45            <ProductDetailPage />
46          </LazyRoute>
47        }
48      />
49      <Route
50        path="/profile"
51        element={
52          <LazyRoute>
53            <UserProfilePage />
54          </LazyRoute>
55        }
56      />
57      <Route
58        path="/admin/*"
59        element={
60          <LazyRoute>
61            <AdminPanel />
62          </LazyRoute>
63        }
64      />
65    </Routes>
66  );
67}

L'import() dynamique renvoie une Promise qui se résout en module, et le bundler extrait chaque page dans un fichier séparé. Sans React, c'est simplement const module = await import('./module.js'). lazy exige un export par défaut, et ErrorBoundary intercepte un téléchargement raté.

Stratégies avancées de chargement des routes

On peut télécharger une route avant même que l'utilisateur clique : au survol d'un lien, selon le parcours prévu ou pendant les moments d'inactivité :

1// Preloading des routes selon le comportement de l'utilisateur
2class RoutePreloader {
3  constructor() {
4    this.preloadedRoutes = new Set();
5    this.setupPreloading();
6  }
7
8  setupPreloading() {
9    // Preload au survol
10    this.setupHoverPreloading();
11
12    // Preload selon la probabilité
13    this.setupPredictivePreloading();
14
15    // Preload pendant l'inactivité
16    this.setupIdlePreloading();
17  }
18
19  setupHoverPreloading() {
20    document.addEventListener('mouseover', (e) => {
21      const link = e.target.closest('a[href]');
22      if (link && this.shouldPreload(link.href)) {
23        this.preloadRoute(new URL(link.href).pathname);
24      }
25    });
26  }
27
28  setupPredictivePreloading() {
29    // Si l'utilisateur est sur /products, preload de /products/:id
30    const currentPath = window.location.pathname;
31
32    if (currentPath === '/products') {
33      // Délai pour ne pas perturber le chargement de la page actuelle
34      setTimeout(() => {
35        this.preloadRoute('/products/:id');
36      }, 2000);
37    }
38  }
39
40  setupIdlePreloading() {
41    if ('requestIdleCallback' in window) {
42      requestIdleCallback(() => {
43        this.preloadCriticalRoutes();
44      });
45    }
46  }
47
48  preloadRoute(path) {
49    if (this.preloadedRoutes.has(path)) return;
50
51    const routeMap = {
52      '/products': () => import('./pages/ProductsPage'),
53      '/products/:id': () => import('./pages/ProductDetailPage'),
54      '/profile': () => import('./pages/UserProfilePage'),
55      '/admin': () => import('./pages/AdminPanel')
56    };
57
58    const preloadFunction = routeMap[path];
59    if (preloadFunction) {
60      preloadFunction().then(() => {
61        this.preloadedRoutes.add(path);
62        console.log(`Route ${path} preloaded`);
63      });
64    }
65  }
66
67  shouldPreload(href) {
68    // Preload uniquement des liens internes
69    return href.startsWith(window.location.origin);
70  }
71
72  preloadCriticalRoutes() {
73    const critical = ['/products', '/profile'];
74    critical.forEach(route => this.preloadRoute(route));
75  }
76}
77
78// Initialisation
79new RoutePreloader();

requestIdleCallback attend que le navigateur soit inactif (le code vérifie d'abord que la fonction existe).

Le lazy loading des composants

Lazy loading au niveau des composants

Les composants lourds, comme les graphiques ou un éditeur de code, ne sont chargés qu'à l'ouverture de l'onglet qui en a besoin :

1// Lazy loading des composants coûteux
2const ExpensiveChart = lazy(() => import('./components/ExpensiveChart'));
3const DataVisualization = lazy(() => import('./components/DataVisualization'));
4const VideoPlayer = lazy(() => import('./components/VideoPlayer'));
5const CodeEditor = lazy(() => import('./components/CodeEditor'));
6
7function Dashboard({ activeTab, data }) {
8  return (
9    <div className="dashboard">
10      <nav className="dashboard-nav">
11        {/* Navigation toujours visible */}
12      </nav>
13
14      <main className="dashboard-content">
15        {activeTab === 'charts' && (
16          <Suspense fallback={<div>Chargement des graphiques...</div>}>
17            <ExpensiveChart data={data} />
18          </Suspense>
19        )}
20
21        {activeTab === 'visualization' && (
22          <Suspense fallback={<div>Chargement de la visualisation...</div>}>
23            <DataVisualization data={data} />
24          </Suspense>
25        )}
26
27        {activeTab === 'video' && (
28          <Suspense fallback={<div>Chargement du lecteur...</div>}>
29            <VideoPlayer />
30          </Suspense>
31        )}
32
33        {activeTab === 'editor' && (
34          <Suspense fallback={<div>Chargement de l'éditeur...</div>}>
35            <CodeEditor />
36          </Suspense>
37        )}
38      </main>
39    </div>
40  );
41}

La navigation apparaît immédiatement, et chaque onglet a son propre fallback.

Lazy loading des modales et des drawers

Seule une partie des utilisateurs ouvre les modales, donc le hook useLazyModal ne les importe qu'après le clic :

1// Hook pour les modales en lazy
2function useLazyModal() {
3  const [isOpen, setIsOpen] = useState(false);
4  const [Component, setComponent] = useState(null);
5
6  const openModal = async (modalType) => {
7    const modalMap = {
8      'user-settings': () => import('./modals/UserSettingsModal'),
9      'payment': () => import('./modals/PaymentModal'),
10      'image-editor': () => import('./modals/ImageEditorModal'),
11    };
12
13    const importFunction = modalMap[modalType];
14    if (importFunction) {
15      const { default: ModalComponent } = await importFunction();
16      setComponent(() => ModalComponent);
17      setIsOpen(true);
18    }
19  };
20
21  const closeModal = () => {
22    setIsOpen(false);
23    // En option : décharger le composant après la fermeture
24    setTimeout(() => setComponent(null), 300);
25  };
26
27  return { Component, isOpen, openModal, closeModal };
28}
29
30// Utilisation des modales en lazy
31function App() {
32  const { Component: ModalComponent, isOpen, openModal, closeModal } = useLazyModal();
33
34  return (
35    <div>
36      <button onClick={() => openModal('user-settings')}>
37        Paramètres utilisateur
38      </button>
39      <button onClick={() => openModal('payment')}>
40        Paiement
41      </button>
42      <button onClick={() => openModal('image-editor')}>
43        Éditeur d'images
44      </button>
45
46      {isOpen && ModalComponent && (
47        <Suspense fallback={<div>Chargement...</div>}>
48          <ModalComponent onClose={closeModal} />
49        </Suspense>
50      )}
51    </div>
52  );
53}

L'écriture setComponent(() => ModalComponent) est indispensable, car si tu passais la fonction directement, useState la traiterait comme une fonction de mise à jour de l'état.

Lazy loading par fonctionnalité

Les modules fonctionnels entiers, comme le panneau d'administration ou le panier, sont chargés avec un squelette (skeleton) au lieu d'un spinner :

1// Lazy loading de features entières
2const AdminFeature = lazy(() => import('./features/Admin'));
3const AnalyticsFeature = lazy(() => import('./features/Analytics'));
4const ShoppingCartFeature = lazy(() => import('./features/ShoppingCart'));
5
6function FeatureLoader({ feature, ...props }) {
7  const featureMap = {
8    admin: AdminFeature,
9    analytics: AnalyticsFeature,
10    cart: ShoppingCartFeature
11  };
12
13  const FeatureComponent = featureMap[feature];
14
15  if (!FeatureComponent) {
16    return <div>Fonctionnalité inconnue : {feature}</div>;
17  }
18
19  return (
20    <ErrorBoundary>
21      <Suspense fallback={<FeatureLoadingSkeleton feature={feature} />}>
22        <FeatureComponent {...props} />
23      </Suspense>
24    </ErrorBoundary>
25  );
26}
27
28// Skeleton pour les différentes features
29function FeatureLoadingSkeleton({ feature }) {
30  const skeletons = {
31    admin: <AdminSkeleton />,
32    analytics: <ChartsSkeleton />,
33    cart: <CartSkeleton />
34  };
35
36  return skeletons[feature] || <DefaultSkeleton />;
37}

Le skeleton montre la forme du futur contenu, donc la page saute moins.

Techniques avancées de lazy loading

Virtual scrolling avec lazy loading

Le virtual scrolling n'affiche que les lignes visibles d'une longue liste, ici avec la bibliothèque react-window :

1// Virtual scrolling pour les grandes listes
2import { List } from 'react-window';
3
4function VirtualizedList({ items, itemHeight = 50 }) {
5  const [loadedItems, setLoadedItems] = useState(new Set());
6
7  const loadItem = useCallback(async (index) => {
8    if (loadedItems.has(index)) return;
9
10    // Simulation du chargement des données
11    await new Promise(resolve => setTimeout(resolve, 100));
12    setLoadedItems(prev => new Set([...prev, index]));
13  }, [loadedItems]);
14
15  const Row = ({ index, style }) => {
16    const item = items[index];
17    const isLoaded = loadedItems.has(index);
18
19    useEffect(() => {
20      if (!isLoaded) {
21        loadItem(index);
22      }
23    }, [index, isLoaded, loadItem]);
24
25    return (
26      <div style={style} className="list-item">
27        {isLoaded ? (
28          <ItemContent item={item} />
29        ) : (
30          <ItemSkeleton />
31        )}
32      </div>
33    );
34  };
35
36  return (
37    <List
38      style={{ height: 400 }}
39      rowComponent={Row}
40      rowCount={items.length}
41      rowHeight={itemHeight}
42      rowProps={{}}
43      overscanCount={5} // Preload de 5 éléments hors du viewport
44    />
45  );
46}

overscanCount rend quelques lignes supplémentaires hors de la zone visible, pour qu'un défilement rapide ne révèle pas de vide.

Lazy loading avec cache

ComponentCache garde les composants dans une map, et preload les télécharge à l'avance :

1// Cache pour les composants chargés en lazy
2class ComponentCache {
3  constructor() {
4    this.cache = new Map();
5  }
6
7  async get(key, loader) {
8    if (this.cache.has(key)) {
9      return this.cache.get(key);
10    }
11
12    const component = await loader();
13    this.cache.set(key, component);
14    return component;
15  }
16
17  preload(key, loader) {
18    if (!this.cache.has(key)) {
19      this.get(key, loader);
20    }
21  }
22
23  clear(key) {
24    if (key) {
25      this.cache.delete(key);
26    } else {
27      this.cache.clear();
28    }
29  }
30}
31
32const componentCache = new ComponentCache();
33
34// Hook qui utilise le cache
35function useLazyComponent(componentKey, loader) {
36  const [Component, setComponent] = useState(null);
37  const [loading, setLoading] = useState(false);
38  const [error, setError] = useState(null);
39
40  const loadComponent = useCallback(async () => {
41    if (Component) return;
42
43    setLoading(true);
44    setError(null);
45
46    try {
47      const loadedComponent = await componentCache.get(componentKey, loader);
48      setComponent(() => loadedComponent.default);
49    } catch (err) {
50      setError(err);
51    } finally {
52      setLoading(false);
53    }
54  }, [componentKey, loader, Component]);
55
56  // Fonction de preload
57  const preload = useCallback(() => {
58    componentCache.preload(componentKey, loader);
59  }, [componentKey, loader]);
60
61  return { Component, loading, error, loadComponent, preload };
62}

De toute façon, le navigateur n'exécutera pas un module deux fois, mais ta propre map te donne le contrôle : nettoyage, statistiques ou durée de validité (TTL).

Lazy loading adapté à la connexion

Sur une connexion lente, on charge moins de choses et en moins bonne qualité. La Network Information API fournit le type de connexion effectif (effectiveType) :

1// Chargement adaptatif selon la connexion
2function useConnectionAwareLazyLoading() {
3  const [connectionType, setConnectionType] = useState('4g');
4
5  useEffect(() => {
6    const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
7
8    if (connection) {
9      setConnectionType(connection.effectiveType);
10
11      const updateConnection = () => {
12        setConnectionType(connection.effectiveType);
13      };
14
15      connection.addEventListener('change', updateConnection);
16      return () => connection.removeEventListener('change', updateConnection);
17    }
18  }, []);
19
20  const shouldLazyLoad = useCallback((priority = 'normal') => {
21    const strategies = {
22      '4g': { immediate: true, normal: true, low: true },
23      '3g': { immediate: true, normal: true, low: false },
24      '2g': { immediate: true, normal: false, low: false },
25      'slow-2g': { immediate: false, normal: false, low: false }
26    };
27
28    return strategies[connectionType]?.[priority] ?? false;
29  }, [connectionType]);
30
31  const getLoadingStrategy = useCallback(() => {
32    const strategies = {
33      '4g': 'aggressive', // Tout charger
34      '3g': 'moderate',   // Charger seulement si nécessaire
35      '2g': 'conservative', // Charger le minimum
36      'slow-2g': 'minimal' // Seulement l'indispensable
37    };
38
39    return strategies[connectionType] || 'moderate';
40  }, [connectionType]);
41
42  return { connectionType, shouldLazyLoad, getLoadingStrategy };
43}
44
45// Composant qui s'adapte à la connexion
46function AdaptiveImageGrid({ images }) {
47  const { shouldLazyLoad, getLoadingStrategy } = useConnectionAwareLazyLoading();
48  const strategy = getLoadingStrategy();
49
50  const imageSettings = {
51    aggressive: { quality: 'high', eager: 10 },
52    moderate: { quality: 'medium', eager: 4 },
53    conservative: { quality: 'low', eager: 2 },
54    minimal: { quality: 'thumbnail', eager: 1 }
55  };
56
57  const settings = imageSettings[strategy];
58
59  return (
60    <div className="image-grid">
61      {images.map((image, index) => (
62        <AdaptiveImage
63          key={image.id}
64          src={image.url}
65          quality={settings.quality}
66          placeholder={image.placeholder}
67          loading={index < settings.eager ? 'eager' : 'lazy'}
68          shouldOptimize={strategy !== 'aggressive'}
69        />
70      ))}
71    </div>
72  );
73}

Cette API ne fonctionne que dans les navigateurs basés sur Chromium, c'est pourquoi le hook démarre avec la valeur '4g' et vérifie que navigator.connection existe.

Surveiller les performances du lazy loading

Une optimisation ne s'évalue pas à l'œil nu. LazyLoadingMonitor mesure le temps de chargement, les erreurs et les succès du cache :

1// Monitoring des performances du lazy loading
2class LazyLoadingMonitor {
3  constructor() {
4    this.metrics = {
5      componentsLoaded: 0,
6      totalLoadTime: 0,
7      failedLoads: 0,
8      cacheHits: 0
9    };
10
11    this.startTime = performance.now();
12  }
13
14  trackComponentLoad(componentName, loadTime, fromCache = false) {
15    this.metrics.componentsLoaded++;
16    this.metrics.totalLoadTime += loadTime;
17
18    if (fromCache) {
19      this.metrics.cacheHits++;
20    }
21
22    // Web Vitals tracking
23    if (loadTime > 2500) { // Slow loading threshold
24      console.warn(`Slow lazy load detected: ${componentName} took ${loadTime}ms`);
25    }
26
27    // Send to analytics
28    this.sendMetrics('component_lazy_load', {
29      component: componentName,
30      loadTime,
31      fromCache,
32      timestamp: Date.now()
33    });
34  }
35
36  trackFailedLoad(componentName, error) {
37    this.metrics.failedLoads++;
38
39    console.error(`Failed to lazy load ${componentName}:`, error);
40
41    this.sendMetrics('component_lazy_load_error', {
42      component: componentName,
43      error: error.message,
44      timestamp: Date.now()
45    });
46  }
47
48  getAverageLoadTime() {
49    return this.metrics.componentsLoaded > 0
50      ? this.metrics.totalLoadTime / this.metrics.componentsLoaded
51      : 0;
52  }
53
54  getCacheHitRate() {
55    return this.metrics.componentsLoaded > 0
56      ? (this.metrics.cacheHits / this.metrics.componentsLoaded) * 100
57      : 0;
58  }
59
60  sendMetrics(event, data) {
61    // Intégration avec Google Analytics, Mixpanel, etc.
62    if (typeof gtag !== 'undefined') {
63      gtag('event', event, data);
64    }
65  }
66
67  generateReport() {
68    const sessionTime = performance.now() - this.startTime;
69
70    return {
71      sessionDuration: sessionTime,
72      componentsLoaded: this.metrics.componentsLoaded,
73      averageLoadTime: this.getAverageLoadTime(),
74      cacheHitRate: this.getCacheHitRate(),
75      failureRate: (this.metrics.failedLoads / Math.max(1, this.metrics.componentsLoaded + this.metrics.failedLoads)) * 100,
76      totalFailures: this.metrics.failedLoads
77    };
78  }
79}
80
81// Singleton instance
82const lazyLoadMonitor = new LazyLoadingMonitor();
83
84// Hook avec monitoring
85function useMonitoredLazyComponent(componentName, loader) {
86  const [Component, setComponent] = useState(null);
87  const [loading, setLoading] = useState(false);
88
89  const loadComponent = useCallback(async () => {
90    if (Component) return;
91
92    const startTime = performance.now();
93    setLoading(true);
94
95    try {
96      const loadedComponent = await loader();
97      const loadTime = performance.now() - startTime;
98
99      setComponent(() => loadedComponent.default);
100      lazyLoadMonitor.trackComponentLoad(componentName, loadTime);
101    } catch (error) {
102      lazyLoadMonitor.trackFailedLoad(componentName, error);
103      throw error;
104    } finally {
105      setLoading(false);
106    }
107  }, [componentName, loader, Component]);
108
109  return { Component, loading, loadComponent };
110}

Le seuil de 2500 ms marque la limite d'un bon score LCP.

Bonnes pratiques du lazy loading

1. Prioriser le contenu

La règle la plus importante : ce qui est visible tout de suite se charge immédiatement, le reste en différé :

1// Above-the-fold content - eager loading
2// Below-the-fold content - lazy loading
3function ContentStrategy() {
4  return (
5    <>
6      {/* Critical path - eager */}
7      <Header />
8      <Hero />
9      <MainContent />
10
11      {/* Secondary content - lazy */}
12      <Suspense fallback={<SectionSkeleton />}>
13        <LazySection name="testimonials" />
14      </Suspense>
15
16      <Suspense fallback={<SectionSkeleton />}>
17        <LazySection name="newsletter" />
18      </Suspense>
19
20      {/* Low priority - very lazy */}
21      <Suspense fallback={<FooterSkeleton />}>
22        <LazyFooter />
23      </Suspense>
24    </>
25  );
26}

L'en-tête et le contenu principal n'attendent pas, tandis que les avis et le pied de page arrivent en arrière-plan.

2. Des solutions de repli soignées (graceful fallbacks)

Le chargement différé peut échouer, par exemple après une coupure de connexion, alors combine Suspense avec une frontière d'erreur :

1// Prévois toujours des graceful fallbacks
2function LazyComponentWrapper({ children, fallback, errorFallback }) {
3  return (
4    <ErrorBoundary
5      fallback={errorFallback || <div>Une erreur est survenue pendant le chargement</div>}
6    >
7      <Suspense fallback={fallback || <LoadingSkeleton />}>
8        {children}
9      </Suspense>
10    </ErrorBoundary>
11  );
12}

Au lieu d'un écran vide, l'utilisateur voit un squelette ou un message clair.

3. Le preloading stratégique

Pour finir, l'anticipation : peu après l'arrivée sur la page, on télécharge les composants qui seront probablement nécessaires :

1// Preloading stratégique selon le comportement de l'utilisateur
2function useStrategicPreloading() {
3  useEffect(() => {
4    // Preload 2 secondes après le montage du composant
5    const timeoutId = setTimeout(() => {
6      // Preload des prochains composants probables
7      import('./components/UserProfile');
8      import('./components/ShoppingCart');
9    }, 2000);
10
11    // Preload selon le mouvement de la souris vers un lien
12    const handleMouseMove = (e) => {
13      // Si le curseur s'approche d'un élément précis
14      // preload du composant associé
15    };
16
17    document.addEventListener('mousemove', handleMouseMove);
18
19    return () => {
20      clearTimeout(timeoutId);
21      document.removeEventListener('mousemove', handleMouseMove);
22    };
23  }, []);
24}

La fonction renvoyée par useEffect supprime le timer et l'écouteur quand le composant disparaît.

Récapitulatif

Le lazy loading en bref :

  1. Réduit la taille initiale du bundle - l'application démarre plus vite
  2. Améliore les Web Vitals - FCP et LCP plus rapides, à condition de ne pas retarder le contenu visible d'emblée
  3. Économise les ressources - mémoire, transfert, batterie
  4. Grandit avec l'application - les nouvelles fonctionnalités n'alourdissent pas le bundle de démarrage
  5. S'adapte aux conditions - un chargement ajusté à la connexion

Le lazy loading doit rester invisible pour l'utilisateur tout en accélérant nettement l'application. C'est une technique parmi d'autres, à côté du cache, du debouncing (retarder une opération coûteuse jusqu'à ce que l'utilisateur arrête de taper) et du virtual scrolling. Commence par le découpage par routes car c'est lui qui apporte le plus grand gain pour le plus petit coût. Dans la leçon suivante, tu mesureras et allégeras le bundle lui-même.

Retiens : le visiteur n'a pas besoin des cartes de toute l'île à l'entrée, seulement de la carte suivante quand il pénètre dans un nouveau secteur.

Dans l’aperçu ci-dessous, fais défiler la galerie de dinosaures : les cartes ne se chargent que lorsqu’elles entrent dans le champ de vision, et le compteur indique combien de kilo-octets ont été téléchargés jusqu’ici.

Code de cette leçon : lazy-loading-demo.html
1<!DOCTYPE html>
2<html lang="fr">
3<head>
4    <meta charset="UTF-8">
5    <meta name="viewport" content="width=device-width, initial-scale=1.0">
6    <title>Lazy loading - Jurassic Park</title>
7    <style>
8        body {
9            font-family: 'Courier New', monospace;
10            background: linear-gradient(135deg, #1e3c28 0%, #2d5a3d 100%);
11            color: #e0e0e0;
12            margin: 0;
13            padding: 20px;
14        }
15
16        .header {
17            text-align: center;
18            padding: 20px;
19            background: rgba(0, 0, 0, 0.3);
20            border-radius: 10px;
21            margin-bottom: 30px;
22        }
23
24        h1 {
25            color: #4CAF50;
26            text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
27        }
28
29        .gallery {
30            display: grid;
31            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
32            gap: 20px;
33            max-width: 1200px;
34            margin: 0 auto;
35        }
36
37        .dino-card {
38            background: rgba(255, 255, 255, 0.1);
39            border-radius: 10px;
40            padding: 15px;
41            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
42            transition: transform 0.3s ease;
43        }
44
45        .dino-card:hover {
46            transform: translateY(-5px);
47        }
48
49        .dino-image {
50            width: 100%;
51            height: 200px;
52            background: linear-gradient(45deg, #2d5a3d 0%, #3d6a4d 100%);
53            border-radius: 8px;
54            display: flex;
55            align-items: center;
56            justify-content: center;
57            font-size: 60px;
58            transition: opacity 0.5s ease;
59            position: relative;
60            overflow: hidden;
61        }
62
63        .dino-image.loading {
64            opacity: 0.5;
65        }
66
67        .dino-image.loaded {
68            opacity: 1;
69            background: linear-gradient(45deg, #4CAF50 0%, #45a049 100%);
70        }
71
72        .dino-image::after {
73            content: 'Chargement...';
74            position: absolute;
75            bottom: 10px;
76            font-size: 12px;
77            color: #fff;
78            opacity: 0;
79            transition: opacity 0.3s;
80        }
81
82        .dino-image.loading::after {
83            opacity: 1;
84        }
85
86        .dino-info {
87            margin-top: 10px;
88        }
89
90        .dino-name {
91            font-size: 18px;
92            font-weight: bold;
93            color: #4CAF50;
94            margin-bottom: 5px;
95        }
96
97        .dino-status {
98            font-size: 14px;
99            color: #aaa;
100        }
101
102        .stats {
103            background: rgba(0, 0, 0, 0.3);
104            padding: 20px;
105            border-radius: 10px;
106            margin: 30px auto;
107            max-width: 600px;
108            text-align: center;
109        }
110
111        .stat-item {
112            display: inline-block;
113            margin: 0 15px;
114        }
115
116        .stat-value {
117            font-size: 24px;
118            color: #4CAF50;
119            font-weight: bold;
120        }
121    </style>
122</head>
123<body>
124    <div class="header">
125        <h1>Système de lazy loading - Jurassic Park</h1>
126        <p>Observe comment les dinosaures se chargent à la demande !</p>
127    </div>
128
129    <div class="stats" id="stats">
130        <div class="stat-item">
131            <div class="stat-value" id="loaded-count">0</div>
132            <div>Chargés</div>
133        </div>
134        <div class="stat-item">
135            <div class="stat-value" id="total-count">0</div>
136            <div>Total</div>
137        </div>
138        <div class="stat-item">
139            <div class="stat-value" id="bandwidth-saved">0 KB</div>
140            <div>Téléchargé</div>
141        </div>
142    </div>
143
144    <div class="gallery" id="gallery"></div>
145
146    <script>
147        console.log('Initialisation du système de lazy loading pour Jurassic Park...');
148
149        // Données des dinosaures
150        const dinosaurs = [
151            { name: 'Tyrannosaurus Rex', emoji: '', species: 'T-Rex', size: 150 },
152            { name: 'Velociraptor', emoji: '', species: 'Raptor', size: 80 },
153            { name: 'Triceratops', emoji: '', species: 'Herbivore', size: 120 },
154            { name: 'Brachiosaurus', emoji: '', species: 'Sauropode', size: 200 },
155            { name: 'Stegosaurus', emoji: '', species: 'Herbivore', size: 110 },
156            { name: 'Pteranodon', emoji: '', species: 'Volant', size: 90 },
157            { name: 'Ankylosaurus', emoji: '', species: 'Cuirassé', size: 130 },
158            { name: 'Spinosaurus', emoji: '', species: 'Carnivore', size: 170 },
159            { name: 'Parasaurolophus', emoji: '', species: 'Hadrosaure', size: 100 },
160            { name: 'Dilophosaurus', emoji: '', species: 'Carnivore', size: 95 }
161        ];
162
163        // Statistiques
164        let loadedCount = 0;
165        let totalBandwidthSaved = 0;
166
167        // Classe pour gérer le lazy loading
168        class DinosaurLazyLoader {
169            constructor() {
170                this.observer = new IntersectionObserver(
171                    this.handleIntersection.bind(this),
172                    {
173                        root: null,
174                        rootMargin: '100px',
175                        threshold: 0.1
176                    }
177                );
178
179                console.log('Intersection Observer créé');
180            }
181
182            handleIntersection(entries) {
183                entries.forEach(entry => {
184                    if (entry.isIntersecting) {
185                        const card = entry.target;
186                        this.loadDinosaur(card);
187                        this.observer.unobserve(card);
188                    }
189                });
190            }
191
192            loadDinosaur(card) {
193                const imageDiv = card.querySelector('.dino-image');
194                const emoji = card.dataset.emoji;
195                const size = parseInt(card.dataset.size);
196
197                console.log(`Chargement du dinosaure :${card.dataset.name}`);
198
199                // Simulation du délai de chargement
200                setTimeout(() => {
201                    imageDiv.textContent = emoji;
202                    imageDiv.classList.remove('loading');
203                    imageDiv.classList.add('loaded');
204
205                    loadedCount++;
206                    totalBandwidthSaved += size;
207
208                    this.updateStats();
209
210                    console.log(`Dinosaure chargé :${card.dataset.name}`);
211                }, Math.random() * 500 + 200);
212            }
213
214            updateStats() {
215                document.getElementById('loaded-count').textContent = loadedCount;
216                document.getElementById('bandwidth-saved').textContent = totalBandwidthSaved + ' KB';
217            }
218
219            observe(element) {
220                this.observer.observe(element);
221            }
222        }
223
224        // Initialisation
225        const loader = new DinosaurLazyLoader();
226        const gallery = document.getElementById('gallery');
227
228        // Création des cartes de dinosaures
229        dinosaurs.forEach((dino, index) => {
230            const card = document.createElement('div');
231            card.className = 'dino-card';
232            card.dataset.name = dino.name;
233            card.dataset.emoji = dino.emoji;
234            card.dataset.size = dino.size;
235
236            card.innerHTML = `
237                <div class="dino-image loading"></div>
238                <div class="dino-info">
239                    <div class="dino-name">${dino.name}</div>
240                    <div class="dino-status">Espèce : ${dino.species} | ID: #${(index + 1).toString().padStart(3, '0')}</div>
241                </div>
242            `;
243
244            gallery.appendChild(card);
245            loader.observe(card);
246        });
247
248        // Mise à jour des statistiques
249        document.getElementById('total-count').textContent = dinosaurs.length;
250
251        console.log(`${dinosaurs.length} cartes de dinosaures créées`);
252        console.log('Fais défiler la page pour voir le lazy loading en action !');
253        console.log('Les dinosaures se chargeront automatiquement en entrant dans le viewport');
254    </script>
255</body>
256</html>

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quel est le principal bénéfice du lazy loading des composants ?

  2. 2. Que retourne l'expression d'import dynamique import() en JavaScript ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri vertical

    Range les étapes de configuration du lazy loading d'un composant :

  • Tri horizontal

    Range les éléments d'un import dynamique dans le bon ordre :

  • Éditeur de code

    Le fichier index.js contient une classe DinosaurAI avec des besoins (hunger, fatigue, danger sur une échelle de 0 à 100) et un arbre de décision. Complète les blancs : ___BLANK1___ est la limite inférieure d'un besoin, en dessous de laquelle updateNeed ne le fait pas descendre, ___BLANK2___ est la méthode de tableau qui parcourt les noms des besoins et garde celui dont la valeur est la plus élevée, et ___BLANK3___ est l'opérateur de comparaison de decide() : on choisit l'action d'un besoin seulement quand sa valeur est supérieure à 30. Un danger au-dessus de 70 donne toujours 'flee', et quand aucun besoin ne dépasse 30, decide() retourne 'idle'.

  • Clique dans l'ordre

    Range les éléments d'un abonnement dans l'Observer Pattern :

  • Éditeur de code

    Le fichier index.js contient une classe DataManager avec un cache dans une Map (avec une durée de vie TTL) et un stockage dans localStorage. Complète les blancs : ___BLANK1___ est la méthode de JSON qui transforme une valeur en texte avant de l'enregistrer dans localStorage, ___BLANK2___ est l'opérateur de comparaison qui fait que get() retourne la valeur du cache seulement avant l'heure expires, et ___BLANK3___ est la méthode de JSON qui retransforme le texte de localStorage en valeur. Après l'expiration d'une entrée, get() lit la valeur dans localStorage, pour une clé absente il retourne null, et invalidate supprime la clé des deux endroits.

Articles utiles