Cours JavaScript et TypeScript · Module 9: Design patterns et architecture
Lazy loading - images, routes, composants
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 :
- Réduit la taille initiale du bundle - l'application démarre plus vite
- Améliore les Web Vitals - FCP et LCP plus rapides, à condition de ne pas retarder le contenu visible d'emblée
- Économise les ressources - mémoire, transfert, batterie
- Grandit avec l'application - les nouvelles fonctionnalités n'alourdissent pas le bundle de démarrage
- 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. Quel est le principal bénéfice du lazy loading des composants ?
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.