Cours JavaScript et TypeScript · Module 5: JavaScript avancé
Web APIs : Intersection Observer et Resize Observer
Dans cette leçon8
Le Dr Alan Grant se tient devant la baie vitrée panoramique du centre de contrôle de Jurassic Park. "Nos écrans affichent des centaines d'éléments à la fois - images des caméras, graphiques, cartes des secteurs", dit-il. "Mais tout charger d'un coup tue les performances. Il nous faut un moyen de réagir à ce que l'utilisateur voit réellement et à la façon dont la mise en page change." La réponse, ce sont Intersection Observer et Resize Observer - des Web APIs modernes pour observer les éléments du DOM.
Le problème : suivre la visibilité à la main
L'approche traditionnelle pour vérifier si un élément est visible à l'écran exige d'écouter l'événement scroll et de calculer les positions à la main :
1// L'ancienne approche - lente et compliquée
2window.addEventListener("scroll", () => {
3 const images = document.querySelectorAll("img[data-src]");
4
5 images.forEach(img => {
6 const rect = img.getBoundingClientRect();
7 const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
8
9 if (isVisible) {
10 img.src = img.dataset.src; // Charger l'image
11 }
12 });
13});
14// Problème : l'événement scroll se déclenche des dizaines de fois par seconde !
15// getBoundingClientRect() peut forcer un recalcul synchrone de la mise en page (reflow)Le gestionnaire s'exécute des dizaines de fois par seconde et recalcule à chaque fois la position de toutes les images dans le thread principal, celui-là même qui gère le défilement et les clics. Les images déjà chargées sont toujours vérifiées, car personne ne leur retire l'attribut data-src.
Intersection Observer - observer la visibilité
IntersectionObserver permet d'observer de façon asynchrone le moment où un élément entre dans la zone visible (viewport) ou dans un autre élément, ou en sort. C'est un capteur à la limite de l'enclos : tu ne fixes pas l'écran sans arrêt, tu reçois un signal quand quelqu'un franchit la ligne :
1// Création d'un observateur
2const observer = new IntersectionObserver((entries) => {
3 entries.forEach(entry => {
4 // entry.isIntersecting - l'élément est-il visible
5 // entry.intersectionRatio - quelle proportion de l'élément est visible (0-1)
6 // entry.target - l'élément DOM observé
7
8 if (entry.isIntersecting) {
9 console.log(`L'élément ${entry.target.id} est maintenant visible !`);
10 console.log(`Visibilité : ${(entry.intersectionRatio * 100).toFixed(0)}%`);
11 }
12 });
13}, {
14 // Options de configuration
15 root: null, // null = viewport du navigateur
16 rootMargin: "0px", // marge autour de root (comme la marge CSS)
17 threshold: 0.5 // callback quand 50 % de l'élément est visible
18});
19
20// Début de l'observation
21const dinoCard = document.querySelector("#dino-card");
22observer.observe(dinoCard);
23
24// Arrêt de l'observation
25// observer.unobserve(dinoCard);
26// observer.disconnect(); // Arrête toutes les observationsroot est la zone de référence, rootMargin l'élargit ou la rétrécit comme une marge CSS, et threshold indique quelle part de l'élément doit être visible pour déclencher le signal. La fonction reçoit un tableau d'entrées, car un seul observateur surveille de nombreux éléments. Elle est aussi appelée une fois juste après observe(), avec l'état actuel - c'est pourquoi il faut toujours vérifier isIntersecting.
Le lazy loading des images
L'usage le plus courant d'Intersection Observer est le lazy loading - charger les images seulement quand l'utilisateur fait défiler la page jusqu'à elles :
1class LazyImageLoader {
2 constructor(options = {}) {
3 this.observer = new IntersectionObserver(
4 (entries) => this.handleIntersection(entries),
5 {
6 rootMargin: options.rootMargin || "200px", // Charger 200px avant l'entrée dans la vue
7 threshold: 0
8 }
9 );
10 }
11
12 handleIntersection(entries) {
13 entries.forEach(entry => {
14 if (entry.isIntersecting) {
15 const img = entry.target;
16
17 // Remplacer l'espace réservé par la vraie image
18 img.src = img.dataset.src;
19 img.classList.add("loaded");
20
21 // Arrêter d'observer l'image chargée
22 this.observer.unobserve(img);
23
24 console.log(`Image chargée : ${img.dataset.src}`);
25 }
26 });
27 }
28
29 observe(selector) {
30 const images = document.querySelectorAll(selector);
31 images.forEach(img => this.observer.observe(img));
32 console.log(`Observation de ${images.length} images`);
33 }
34
35 destroy() {
36 this.observer.disconnect();
37 }
38}
39
40// Utilisation
41const loader = new LazyImageLoader({ rootMargin: "300px" });
42loader.observe("img[data-src]");Le rootMargin par défaut fait commencer le chargement d'une image 200 pixels avant son entrée à l'écran, et même 300 dans l'exemple d'utilisation. Une fois l'image chargée, unobserve() la détache, pour que l'observateur ne travaille pas pour rien. Pour des images ordinaires, l'attribut HTML loading="lazy" suffit aujourd'hui - utilise l'observateur quand tu as besoin d'une logique à toi.
L'infinite scroll
Intersection Observer convient parfaitement à l'implémentation d'un défilement infini. Au lieu d'observer chaque carte, nous surveillons un seul marqueur invisible à la fin de la liste, la sentinelle :
1class InfiniteScroll {
2 constructor(containerSelector, loadMore) {
3 this.container = document.querySelector(containerSelector);
4 this.loadMore = loadMore;
5 this.isLoading = false;
6 this.page = 1;
7
8 // On crée un élément "sentinelle" à la fin de la liste
9 this.sentinel = document.createElement("div");
10 this.sentinel.className = "scroll-sentinel";
11 this.container.appendChild(this.sentinel);
12
13 // On observe la sentinelle
14 this.observer = new IntersectionObserver(
15 (entries) => {
16 if (entries[0].isIntersecting && !this.isLoading) {
17 this.loadNextPage();
18 }
19 },
20 { rootMargin: "100px" }
21 );
22
23 this.observer.observe(this.sentinel);
24 }
25
26 async loadNextPage() {
27 this.isLoading = true;
28 console.log(`Chargement de la page ${this.page}...`);
29
30 try {
31 const items = await this.loadMore(this.page);
32
33 items.forEach(item => {
34 const element = document.createElement("div");
35 element.className = "dino-card";
36 element.textContent = `${item.name} (${item.species})`;
37 // On insère AVANT la sentinelle
38 this.container.insertBefore(element, this.sentinel);
39 });
40
41 this.page++;
42 } catch (error) {
43 console.error("Erreur de chargement :", error);
44 } finally {
45 this.isLoading = false;
46 }
47 }
48}Quand la sentinelle s'approche de l'écran, nous chargeons la page suivante et insérons les cartes avant elle, si bien qu'elle reste toujours à la fin. Le drapeau isLoading protège contre un double chargement, et finally le retire même après une erreur. Grâce à textContent au lieu de innerHTML, le texte venu du serveur ne sera jamais traité comme du code HTML.
Validation des données et tests
Les données de loadMore() viennent de l'extérieur, alors avant qu'elles n'arrivent sur la page, fais-les passer par une chaîne de validation :
- Validation du schéma - la réponse est-elle un tableau d'objets avec les champs
nameetspecies - Validation des types - ces champs sont-ils des chaînes
- Validation des règles métier - l'espèce existe-t-elle dans le registre du parc
- Assainissement - nettoyer les données pour la sécurité avant l'affichage
Le code utilisant des observateurs se teste par couches : l'environnement jsdom ne possède pas IntersectionObserver, donc dans les tests unitaires tu le remplaces par un simulacre, les tests d'intégration vérifient la collaboration entre les classes, et le vrai défilement est vérifié par des tests end-to-end dans le navigateur et par des tests de performance.
Resize Observer - observer les changements de taille
ResizeObserver avertit quand un élément change de dimensions. Il est précieux pour les composants responsives, et son schéma d'utilisation est le même que ci-dessus :
1// Création d'un Resize Observer
2const resizeObserver = new ResizeObserver((entries) => {
3 entries.forEach(entry => {
4 const { width, height } = entry.contentRect;
5 console.log(`L'élément ${entry.target.id} a changé de taille : ${width}x${height}`);
6 });
7});
8
9// Observation d'un élément
10const mapContainer = document.querySelector("#park-map");
11resizeObserver.observe(mapContainer);contentRect décrit la zone de contenu, sans bordure ni padding. C'est un champ plus ancien, conservé pour la compatibilité - les plus récents contentBoxSize et borderBoxSize sont plus précis. Contrairement à l'événement resize de la fenêtre, l'observateur réagit au changement de taille d'un élément précis, quelle qu'en soit la cause.
Un composant responsive avec Resize Observer
L'observateur de taille permet à une grille de cartes d'adapter son nombre de colonnes à sa propre largeur, et non à celle de toute la fenêtre :
1class ResponsiveDinoGrid {
2 constructor(containerSelector) {
3 this.container = document.querySelector(containerSelector);
4 this.currentLayout = null;
5
6 this.observer = new ResizeObserver((entries) => {
7 const entry = entries[0];
8 const width = entry.contentRect.width;
9 this.updateLayout(width);
10 });
11
12 this.observer.observe(this.container);
13 }
14
15 updateLayout(width) {
16 let newLayout;
17
18 if (width < 400) {
19 newLayout = "single-column";
20 this.container.style.gridTemplateColumns = "1fr";
21 } else if (width < 800) {
22 newLayout = "two-columns";
23 this.container.style.gridTemplateColumns = "1fr 1fr";
24 } else {
25 newLayout = "multi-column";
26 this.container.style.gridTemplateColumns = "repeat(auto-fill, minmax(250px, 1fr))";
27 }
28
29 if (newLayout !== this.currentLayout) {
30 this.currentLayout = newLayout;
31 console.log(`Mise en page changée en : ${newLayout} (largeur : ${width}px)`);
32 }
33 }
34
35 destroy() {
36 this.observer.disconnect();
37 }
38}La grille change de mise en page à 400 et à 800 pixels, et n'affiche le message que lors d'un vrai changement de mise en page. Quand il s'agit uniquement d'apparence, envisage les container queries CSS, et garde l'observateur pour la logique que le CSS ne sait pas exprimer.
Combiner les deux observateurs
Les deux observateurs se complètent à merveille. Le panneau de surveillance n'active que les caméras visibles et adapte la qualité de l'image à la taille de la tuile :
1// Système de panneau de surveillance de Jurassic Park
2class MonitoringPanel {
3 constructor(panelSelector) {
4 this.panel = document.querySelector(panelSelector);
5 this.cameras = [];
6
7 // Intersection Observer - activer/désactiver les caméras selon leur visibilité
8 this.visibilityObserver = new IntersectionObserver(
9 (entries) => this.handleVisibility(entries),
10 { threshold: 0.1 }
11 );
12
13 // Resize Observer - adapter la qualité de l'image à la taille du panneau
14 this.resizeObserver = new ResizeObserver(
15 (entries) => this.handleResize(entries)
16 );
17 }
18
19 addCamera(cameraElement) {
20 this.cameras.push(cameraElement);
21 this.visibilityObserver.observe(cameraElement);
22 this.resizeObserver.observe(cameraElement);
23 }
24
25 handleVisibility(entries) {
26 entries.forEach(entry => {
27 const cameraId = entry.target.dataset.cameraId;
28
29 if (entry.isIntersecting) {
30 console.log(`Caméra ${cameraId} : ACTIVÉE (visible)`);
31 entry.target.classList.add("active");
32 } else {
33 console.log(`Caméra ${cameraId} : EN PAUSE (hors de la vue)`);
34 entry.target.classList.remove("active");
35 }
36 });
37 }
38
39 handleResize(entries) {
40 entries.forEach(entry => {
41 const { width } = entry.contentRect;
42 const cameraId = entry.target.dataset.cameraId;
43
44 let quality;
45 if (width < 200) quality = "low";
46 else if (width < 500) quality = "medium";
47 else quality = "high";
48
49 entry.target.dataset.quality = quality;
50 console.log(`Caméra ${cameraId} : qualité = ${quality} (largeur : ${width}px)`);
51 });
52 }
53
54 destroy() {
55 this.visibilityObserver.disconnect();
56 this.resizeObserver.disconnect();
57 }
58}Chaque caméra est surveillée par les deux observateurs à la fois, et destroy() les détache avec la méthode disconnect(). Sans cela, les observateurs continueraient de tourner alors que le panneau a disparu depuis longtemps.
Résumé
Le Dr Grant résume : "Les observateurs, c'est comme notre système de capteurs dans le parc - nous n'avons pas besoin de vérifier sans arrêt chaque caméra à la main :"
- IntersectionObserver - réagit quand un élément entre dans la zone visible ou en sort
- Lazy loading - charger les images/contenus seulement quand l'utilisateur fait défiler jusqu'à eux
- Infinite scroll - charger automatiquement plus de données pendant le défilement
- ResizeObserver - réagit au changement de taille des éléments du DOM
- Composants responsives - adapter la mise en page sans media queries
Les deux API sont asynchrones et bien plus performantes que l'écoute manuelle de scroll ou de resize. Le navigateur calcule lui-même les intersections et les tailles, et n'appelle ton callback que lorsque quelque chose change. Le callback lui-même s'exécute cependant dans le thread principal, il doit donc rester court. Tu retrouveras les observateurs dans la location sur le lazy loading des images, des routes et des composants, et dans le laboratoire ci-dessous tu les essaieras tout de suite.
Retiens bien : les observateurs sont les capteurs à la limite de l'enclos - ils ne se manifestent que lorsque quelque chose franchit vraiment la ligne.
Code de cette leçon : index.js
1// Web APIs : Intersection Observer et Resize Observer
2// Jurassic Park - Systeme de surveillance
3
4console.log("=== INTERSECTION OBSERVER ===");
5
6// Simulation d'IntersectionObserver (dans le navigateur, fonctionne avec le DOM)
7// Demonstration du concept - a quoi ressemble l'API
8
9// Creation de l'observateur
10// const observer = new IntersectionObserver((entries) => {
11// entries.forEach(entry => {
12// if (entry.isIntersecting) {
13// console.log("Element visible :", entry.target.id);
14// }
15// });
16// }, {
17// root: null, // viewport du navigateur
18// rootMargin: "0px", // marge
19// threshold: 0.5 // 50% de visibilite
20// });
21
22// === LAZY LOADING ===
23console.log("\n=== LAZY LOADING (concept) ===");
24
25class LazyImageLoader {
26 constructor(rootMargin = "200px") {
27 this.loadedImages = [];
28 this.rootMargin = rootMargin;
29 // Dans le navigateur : this.observer = new IntersectionObserver(...)
30 }
31
32 // Simulation du chargement d'une image
33 loadImage(imageData) {
34 console.log(`Chargement de l'image : ${imageData.src}`);
35 this.loadedImages.push(imageData.src);
36 return { ...imageData, loaded: true };
37 }
38
39 getStats() {
40 return {
41 loaded: this.loadedImages.length,
42 images: this.loadedImages
43 };
44 }
45}
46
47const loader = new LazyImageLoader("300px");
48loader.loadImage({ src: "/cameras/sector-a.jpg", alt: "Secteur A" });
49loader.loadImage({ src: "/cameras/sector-b.jpg", alt: "Secteur B" });
50console.log("Statistiques :", loader.getStats());
51
52// === INFINITE SCROLL ===
53console.log("\n=== INFINITE SCROLL (simulation) ===");
54
55class InfiniteScroll {
56 constructor(loadMore) {
57 this.loadMore = loadMore;
58 this.page = 0;
59 this.items = [];
60 this.isLoading = false;
61 }
62
63 async loadNextPage() {
64 if (this.isLoading) return;
65 this.isLoading = true;
66 this.page++;
67 console.log(`Chargement de la page ${this.page}...`);
68
69 const newItems = await this.loadMore(this.page);
70 this.items.push(...newItems);
71 this.isLoading = false;
72 return newItems;
73 }
74
75 getAll() {
76 return [...this.items];
77 }
78}
79
80// Simulation de l'API
81const fetchDinosaurs = (page) => {
82 return Promise.resolve([
83 { name: `Dino-${page}A`, species: "T-Rex" },
84 { name: `Dino-${page}B`, species: "Velociraptor" },
85 { name: `Dino-${page}C`, species: "Triceratops" },
86 ]);
87};
88
89const scroll = new InfiniteScroll(fetchDinosaurs);
90
91// Chargement des pages
92async function demo() {
93 await scroll.loadNextPage();
94 await scroll.loadNextPage();
95 await scroll.loadNextPage();
96 console.log("Tout charge :", scroll.getAll().length, "dinosaures");
97 console.log("Elements :", scroll.getAll().map(d => d.name));
98}
99
100demo();
101
102// === RESIZE OBSERVER ===
103console.log("\n=== RESIZE OBSERVER (concept) ===");
104
105class ResponsiveDinoGrid {
106 constructor() {
107 this.currentLayout = null;
108 }
109
110 updateLayout(width) {
111 let newLayout;
112 if (width < 400) newLayout = "single-column";
113 else if (width < 800) newLayout = "two-columns";
114 else newLayout = "multi-column";
115
116 if (newLayout !== this.currentLayout) {
117 this.currentLayout = newLayout;
118 console.log(`Disposition : ${newLayout} (largeur : ${width}px)`);
119 }
120 return newLayout;
121 }
122}
123
124const grid = new ResponsiveDinoGrid();
125grid.updateLayout(300); // single-column
126grid.updateLayout(600); // two-columns
127grid.updateLayout(1200); // multi-column
128
129// TODO : cree une classe MonitoringPanel
130// qui combine IntersectionObserver (activation des cameras)
131// avec ResizeObserver (ajustement de la qualite)
132console.log("\n=== Exercice ===");
133console.log("Cree MonitoringPanel - voir le TODO dans le code");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. À quoi sert IntersectionObserver en JavaScript ?
2. Pourquoi IntersectionObserver est-il préférable à l'écoute de l'événement scroll pour mettre en place le lazy loading ?
Tâches pratiques dans le jeu
- Éditeur de code
Implémente le chargement différé des images avec IntersectionObserver et une grille responsive avec ResizeObserver.
- Tri horizontal
Ordonne les éléments pour créer et utiliser un ResizeObserver :
- Tri vertical
Ordonne les étapes des tests d'une application, du niveau unitaire au niveau système :
- Tri vertical
Ordonne le pipeline de validation des données :