Cours JavaScript et TypeScript · Module 8: Événements et DOM
API DOM avancées - Observers et interfaces modernes
Dans cette leçon7
Les systèmes de surveillance de Jurassic Park doivent réagir aux changements en temps réel - l'apparition d'un dinosaure dans une zone, un changement de taille de la zone d'observation, des mutations dans la structure des données. JavaScript propose des API avancées pour observer ces changements sans avoir à interroger la page en permanence (polling).
L'interrogation régulière, c'est un gardien qui passe toutes les minutes devant chaque enclos pour demander si quelque chose a changé. Le gardien s'épuise, le processeur aussi, et la réaction arrive quand même parfois en retard. Les observateurs inversent cette organisation : c'est le navigateur qui te prévient quand quelque chose se produit.
IntersectionObserver - observer la visibilité
IntersectionObserver t'informe quand un élément entre dans la zone visible de la page ou en sort. Il est idéal pour le chargement différé (lazy loading), le défilement infini et les animations déclenchées au défilement. Le constructeur prend un callback et un objet d'options : root désigne la zone de référence (null par défaut, c'est-à-dire le viewport), threshold le seuil de visibilité, et rootMargin une marge autour de cette zone.
1// Utilisation de base
2const observer = new IntersectionObserver((entries) => {
3 entries.forEach(entry => {
4 if (entry.isIntersecting) {
5 console.log(entry.target.id + ' est visible');
6 entry.target.classList.add('visible');
7 } else {
8 entry.target.classList.remove('visible');
9 }
10 });
11}, {
12 threshold: 0.5, // 50 % de visibilité requis
13 rootMargin: '100px' // Marge supplémentaire "avant" l'élément
14});
15
16// Observer les éléments
17document.querySelectorAll('.zone-card').forEach(card => {
18 observer.observe(card);
19});Le callback se déclenche quand la partie visible d'une carte franchit la moitié, dans un sens ou dans l'autre. La propriété isIntersecting indique si l'élément se trouve dans la zone d'affichage, et entry.target désigne l'élément concerné par l'entrée. La fraction exacte de surface visible est donnée par intersectionRatio.
Le chargement différé des images se fait en quatre étapes : les images reçoivent leur adresse dans data-src au lieu de src, on crée un observateur avec un callback, quand une image entre dans le champ de vision, on copie data-src dans src, et pour finir, on appelle unobserve().
1// Chargement différé des images
2const imageObserver = new IntersectionObserver((entries) => {
3 entries.forEach(entry => {
4 if (entry.isIntersecting) {
5 const img = entry.target;
6 img.src = img.dataset.src; // Charger l'image
7 imageObserver.unobserve(img); // Arrêter l'observation
8 }
9 });
10});
11
12document.querySelectorAll('img[data-src]').forEach(img => {
13 imageObserver.observe(img);
14});Après unobserve(), l'image chargée disparaît de la liste des éléments observés, elle ne demande donc plus aucune attention. Mon conseil : pour de simples images, vérifie d'abord l'attribut HTML loading="lazy", qui fait la même chose sans JavaScript. Garde l'observateur pour le reste : animations, compteurs et chargement progressif des listes.
MutationObserver - observer les changements du DOM
MutationObserver réagit aux changements dans l'arbre du DOM - ajout/suppression d'éléments, changements d'attributs, modifications du texte.
1// Observer les changements dans un conteneur
2const mutObserver = new MutationObserver((mutations) => {
3 mutations.forEach(mutation => {
4 if (mutation.type === 'childList') {
5 mutation.addedNodes.forEach(node => {
6 if (node.nodeType === 1) { // Élément
7 console.log('Élément ajouté :', node.tagName);
8 }
9 });
10 mutation.removedNodes.forEach(node => {
11 if (node.nodeType === 1) {
12 console.log('Élément supprimé :', node.tagName);
13 }
14 });
15 }
16 if (mutation.type === 'attributes') {
17 console.log('Attribut modifié :', mutation.attributeName);
18 }
19 });
20});Le callback reçoit un tableau d'enregistrements de changements. Un enregistrement de type childList contient les listes addedNodes et removedNodes, et la condition nodeType === 1 (la constante Node.ELEMENT_NODE) ignore les nœuds texte. Un enregistrement de type attributes donne le nom de l'attribut modifié. Le navigateur livre les enregistrements par lots, de manière asynchrone après les modifications, et non un par un. Il est temps de le brancher.
1const container = document.getElementById('dino-list');
2mutObserver.observe(container, {
3 childList: true, // Observer l'ajout/la suppression d'enfants
4 attributes: true, // Observer les changements d'attributs
5 subtree: true // Observer aussi les descendants
6});
7
8// Arrêter l'observation
9// mutObserver.disconnect();Les options décident de ce qu'on écoute, et subtree étend l'écoute à tous les descendants. Au moins une des options childList, attributes ou characterData doit être activée, sinon observe() lèvera une erreur TypeError. La méthode disconnect() met fin à l'observation.
ResizeObserver - observer la taille
ResizeObserver réagit au changement de taille des éléments - pas seulement de la fenêtre, mais de n'importe quel élément du DOM.
1const resizeObserver = new ResizeObserver((entries) => {
2 entries.forEach(entry => {
3 const { width, height } = entry.contentRect;
4 console.log(entry.target.id + ':', width + 'x' + height);
5
6 // Adapter la mise en page en fonction de la taille
7 if (width < 400) {
8 entry.target.classList.add('compact');
9 } else {
10 entry.target.classList.remove('compact');
11 }
12 });
13});
14
15resizeObserver.observe(document.getElementById('dashboard'));Un dashboard plus étroit que 400 pixels reçoit la classe compact, que ce soit la fenêtre ou le panneau voisin qui ait rétréci. Le callback se déclenche aussi une fois juste après le début de l'observation, à condition que l'élément soit rendu, donc la mise en page est correcte dès le départ. Si tu ne modifies que des styles, envisage les requêtes de conteneur CSS (@container), qui font la même chose sans JavaScript.
Web Storage API
localStorage et sessionStorage permettent de stocker des données dans le navigateur. Tous deux ne stockent que du texte.
1// localStorage - les données survivent à la fermeture du navigateur
2localStorage.setItem('parkSettings', JSON.stringify({
3 theme: 'dark',
4 alertLevel: 3,
5 lastVisit: Date.now()
6}));
7
8const settings = JSON.parse(localStorage.getItem('parkSettings'));
9console.log(settings.theme); // 'dark'
10
11// sessionStorage - les données disparaissent à la fermeture de l'onglet
12sessionStorage.setItem('currentZone', 'A');C'est pourquoi l'écriture se fait toujours en deux étapes : JSON.stringify(), puis localStorage.setItem(). La lecture fonctionne à l'inverse : localStorage.getItem(), puis JSON.parse(). Pour une clé qui n'existe pas, getItem() renvoie null. Les données de sessionStorage survivent à l'actualisation de la page, mais pas à la fermeture de l'onglet.
1// Écoute des changements (entre onglets !)
2window.addEventListener('storage', (event) => {
3 console.log('Modifié :', event.key);
4 console.log('Ancienne valeur :', event.oldValue);
5 console.log('Nouvelle valeur :', event.newValue);
6});
7
8// Suppression
9localStorage.removeItem('parkSettings');
10localStorage.clear(); // Tout supprimerL'événement storage est reçu par les autres onglets de la même origine (protocole, domaine et port), et non par l'onglet qui a effectué la modification, c'est donc ainsi que tu synchroniseras les réglages entre les onglets. Ne stocke pas ici de mots de passe ni de jetons, car n'importe quel script de la page pourrait les lire.
Clipboard API
Le presse-papiers est géré par l'objet navigator.clipboard. Ses méthodes renvoient des promesses, c'est pourquoi on travaille avec async et await.
1// Copie de texte
2async function copyToClipboard(text) {
3 try {
4 await navigator.clipboard.writeText(text);
5 console.log('Copié :', text);
6 } catch (err) {
7 console.error('Erreur de copie :', err);
8 }
9}
10
11// Collage de texte
12async function pasteFromClipboard() {
13 try {
14 const text = await navigator.clipboard.readText();
15 return text;
16 } catch (err) {
17 console.error('Accès au presse-papiers refusé');
18 }
19}La Clipboard API ne fonctionne que dans un contexte sécurisé, c'est-à-dire en HTTPS ou sur localhost, et le navigateur peut bloquer la lecture du presse-papiers ou la soumettre à l'accord de l'utilisateur. D'où le try...catch dans les deux fonctions. Passons au bouton de copie.
1// Bouton de copie avec retour visuel
2const copyBtn = document.getElementById('copy-btn');
3copyBtn.addEventListener('click', async () => {
4 const data = document.getElementById('sensor-data').textContent;
5 await copyToClipboard(data);
6 copyBtn.textContent = 'Copié !';
7 setTimeout(() => copyBtn.textContent = 'Copier', 2000);
8});Grâce à await, le libellé ne change qu'une fois les données du capteur copiées, et au bout de deux secondes, il revient à « Copier ».
History API et navigation
Une application monopage peut modifier l'adresse dans la barre du navigateur sans recharger la page.
1// Ajout d'une entrée à l'historique (sans rechargement de la page)
2history.pushState({ zone: 'A' }, '', '/park/zone-a');
3history.pushState({ zone: 'B' }, '', '/park/zone-b');
4
5// Réaction au bouton "Précédent"
6window.addEventListener('popstate', (event) => {
7 if (event.state) {
8 console.log('Retour à la zone :', event.state.zone);
9 loadZone(event.state.zone);
10 }
11});
12
13// Remplacement de l'entrée actuelle (sans en ajouter une nouvelle)
14history.replaceState({ zone: 'A', view: 'map' }, '', '/park/zone-a?view=map');La méthode pushState() prend un état, un titre qui n'est plus utilisé aujourd'hui et une adresse, puis ajoute une nouvelle entrée à l'historique. La méthode replaceState() remplace l'entrée actuelle. pushState() seul ne déclenche pas popstate. Cet événement n'arrive qu'après un clic sur « Précédent » ou « Suivant ». La condition if (event.state) protège contre l'entrée de départ, qui n'a pas d'état.
DocumentFragment - optimisation du DOM
Ajouter de nombreux éléments d'un coup coûte cher. DocumentFragment permet de préparer les éléments "hors du DOM" et de les ajouter en une seule opération :
1// SANS fragment - chaque appendChild modifie séparément le DOM vivant
2const list = document.getElementById('dino-list');
3const dinosaurs = ['Rex', 'Blue', 'Brachio', 'Stego', 'Trike'];
4
5// MAUVAIS - 5 opérations DOM séparées
6dinosaurs.forEach(name => {
7 const li = document.createElement('li');
8 li.textContent = name;
9 list.appendChild(li); // 5 modifications séparées du DOM vivant
10});Ici, chacune des cinq itérations modifie séparément l'arbre vivant de la page. Les navigateurs modernes regroupent ces modifications et recalculent généralement la mise en page une fois par image, mais si le code lit des dimensions entre deux insertions, par exemple offsetHeight, chaque lecture forcera un reflow séparé.
1// BON - DocumentFragment
2const fragment = document.createDocumentFragment();
3dinosaurs.forEach(name => {
4 const li = document.createElement('li');
5 li.textContent = name;
6 fragment.appendChild(li); // Opération "en mémoire"
7});
8list.appendChild(fragment); // 1 seule modification du DOM vivantLe fragment ne vit qu'en mémoire. L'insérer déplace tous ses enfants dans la liste en une seule opération, tandis que le fragment lui-même reste vide et n'apparaît pas dans le DOM. C'est ainsi que tu limites les reflows au minimum, et MutationObserver reçoit un seul enregistrement au lieu de cinq.
« Les API DOM avancées sont le radar et les systèmes d'alerte précoce du parc », résume Ray Arnold. « IntersectionObserver est une caméra thermique, MutationObserver est un système d'alarme, et ResizeObserver est un écran de contrôle adaptatif. Utilise-les avec sagesse ! »
Dans le bac à sable ci-dessous, tu essaieras les observateurs, DocumentFragment et Web Storage dans un panneau interactif. Tous ces outils te serviront dans le projet final, un panneau de contrôle interactif du parc.
Retiens bien : un bon système de parc n'interroge pas les enclos toutes les minutes, il attend que l'observateur donne lui-même l'alarme.
Code de cette leçon : index.html
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <title>API DOM avancées</title>
6 <style>
7 body { font-family: monospace; background: #1a1a2e; color: #e0e0e0; padding: 16px; }
8 h2 { color: #4ecdc4; }
9 .zone-card { padding: 12px; margin: 8px 0; border: 1px solid #3d3d5c; border-radius: 8px; transition: opacity 0.3s; opacity: 0.5; }
10 .zone-card.visible { opacity: 1; background: #16213e; }
11 #log { background: #0f3460; padding: 12px; border-radius: 8px; max-height: 200px; overflow-y: auto; font-size: 13px; }
12 .log-entry { padding: 2px 0; border-bottom: 1px solid #1a1a4e; }
13 button { padding: 8px 16px; margin: 4px; background: #4ecdc4; color: #1a1a2e; border: none; border-radius: 4px; cursor: pointer; }
14 #dino-list { list-style: none; padding: 0; }
15 #dino-list li { padding: 6px 10px; margin: 3px 0; background: #16213e; border-radius: 4px; }
16 </style>
17</head>
18<body>
19 <h2>API DOM avancées</h2>
20
21 <h3>1. IntersectionObserver</h3>
22 <div id="zone-container" style="height: 120px; overflow-y: auto;">
23 <div class="zone-card" id="z1">Zone A - T-Rex</div>
24 <div class="zone-card" id="z2">Zone B - Raptors</div>
25 <div class="zone-card" id="z3">Zone C - Herbivores</div>
26 <div class="zone-card" id="z4">Zone D - Aviary</div>
27 <div class="zone-card" id="z5">Zone E - Aquatic</div>
28 </div>
29
30 <h3>2. MutationObserver + DocumentFragment</h3>
31 <button onclick="addDinos()">Ajouter des dinosaures</button>
32 <button onclick="clearList()">Effacer</button>
33 <ul id="dino-list"></ul>
34
35 <h3>3. Web Storage</h3>
36 <button onclick="saveVisit()">Enregistrer la visite</button>
37 <button onclick="loadVisit()">Lire</button>
38 <p id="storage-info">-</p>
39
40 <h3>Journal</h3>
41 <div id="log"></div>
42
43 <script>
44 function log(msg) {
45 const el = document.getElementById('log');
46 const entry = document.createElement('div');
47 entry.className = 'log-entry';
48 entry.textContent = new Date().toLocaleTimeString() + ' ' + msg;
49 el.prepend(entry);
50 }
51
52 // IntersectionObserver
53 const io = new IntersectionObserver((entries) => {
54 entries.forEach(e => {
55 e.target.classList.toggle('visible', e.isIntersecting);
56 if (e.isIntersecting) log('Visible : ' + e.target.textContent);
57 });
58 }, { root: document.getElementById('zone-container'), threshold: 0.5 });
59
60 document.querySelectorAll('.zone-card').forEach(c => io.observe(c));
61
62 // MutationObserver
63 const mo = new MutationObserver((muts) => {
64 muts.forEach(m => {
65 m.addedNodes.forEach(n => {
66 if (n.nodeType === 1) log('Ajouté : ' + n.textContent);
67 });
68 });
69 });
70 mo.observe(document.getElementById('dino-list'), { childList: true });
71
72 // DocumentFragment
73 function addDinos() {
74 const names = ['Rex', 'Blue', 'Brachio', 'Stego', 'Trike'];
75 const frag = document.createDocumentFragment();
76 names.forEach(n => {
77 const li = document.createElement('li');
78 li.textContent = n;
79 frag.appendChild(li);
80 });
81 document.getElementById('dino-list').appendChild(frag);
82 log('Added 5 dinosaurs (DocumentFragment)');
83 }
84
85 function clearList() {
86 document.getElementById('dino-list').innerHTML = '';
87 log('Liste effacée');
88 }
89
90 // Web Storage
91 function saveVisit() {
92 const data = { zone: 'A', time: new Date().toISOString(), count: Math.floor(Math.random()*10) };
93 localStorage.setItem('lastVisit', JSON.stringify(data));
94 log('Saved visit in localStorage');
95 }
96
97 function loadVisit() {
98 const raw = localStorage.getItem('lastVisit');
99 if (raw) {
100 const data = JSON.parse(raw);
101 document.getElementById('storage-info').textContent = 'Zone: ' + data.zone + ', Time: ' + data.time;
102 log('Lu depuis localStorage');
103 } else {
104 document.getElementById('storage-info').textContent = 'Aucune donnée enregistrée';
105 }
106 }
107
108 log('Panneau prêt');
109 </script>
110</body>
111</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. À quoi sert IntersectionObserver en JavaScript ?
2. À quels changements MutationObserver réagit-il ?
Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Dans le fichier index.html, la fonction setupObservers() crée deux observateurs : un IntersectionObserver vérifie si la section #radar est visible au moins à moitié (threshold 0.5), et un MutationObserver recompte les alertes de la liste #alerts. Complète les blancs : ___BLANK1___ est la propriété de l'entrée (entry) qui indique si l'élément est visible maintenant, et ___BLANK2___ est l'option de observe() qui fait réagir le MutationObserver à l'ajout et au retrait des enfants de la liste. Le statut du radar doit changer avec sa visibilité, et le compteur #alert-count doit afficher le nombre d'alertes après chaque clic sur le bouton.
- Tri vertical
Ordonne les étapes pour implémenter le chargement différé des images avec IntersectionObserver :
- Clique dans l'ordre
Ordonne les étapes pour enregistrer et lire des données dans localStorage :
- Éditeur de code
Le fichier index.html contient trois fonctions : renderDinosaurs(names) ajoute des dinosaures à la liste #dino-list, saveConfig(config) enregistre les réglages dans localStorage sous la clé parkConfig, et loadConfig() les lit (sans valeur enregistrée, elle renvoie { theme: "jungle" }). Complète les blancs : ___BLANK1___ est la méthode de document qui crée un DocumentFragment, ___BLANK2___ est l'expression qui transforme l'objet config en texte JSON, et ___BLANK3___ est l'expression qui retransforme le texte raw en objet. La liste doit être remplie en un seul appel à appendChild avec le fragment, et un objet enregistré doit revenir de loadConfig() sans changement.
- Clique dans l'ordre
Ordonne les éléments pour envoyer un événement personnalisé :
- Tri vertical
Ordonne les éléments d’une implémentation de debounce sur l’événement input :
- Tri vertical
Ordonne les éléments pour créer et envoyer un événement personnalisé :
- Tri vertical
Ordonne les étapes pour créer et envoyer un événement personnalisé :