Cours JavaScript et TypeScript · Module 8: Événements et DOM
Throttling et debouncing
Parfois, les événements se produisent trop souvent (par ex. scroll, resize, mousemove). On utilise alors les techniques de throttling et de debouncing pour limiter la fréquence d'exécution des fonctions.
Prenons le moteur de recherche des espèces au centre de contrôle. Chaque caractère tapé dans le champ envoie un événement input, donc le mot « Velociraptor », ce sont douze requêtes vers la base de données du laboratoire, alors que seule la dernière nous intéresse. Le défilement de la page peut signaler scroll de nombreuses fois par seconde. « Tous les bruissements dans les buissons ne méritent pas une alarme », dit Ray Arnold. « Il faut savoir quand réagir. »
Implémenter les techniques d'optimisation
Les deux techniques sont des fonctions qui prennent ta fonction et en renvoient une version plus prudente. Commençons par le debouncing, qui attend le silence comme un raptor tapi dans l'herbe : il n'attaque que lorsque sa proie s'immobilise.
1// Debouncing - exécute la fonction seulement après un délai donné sans nouvel appel
2function debounce(func, delay) {
3 let timeoutId;
4 return function(...args) {
5 clearTimeout(timeoutId);
6 timeoutId = setTimeout(() => func.apply(this, args), delay);
7 };
8}La variable timeoutId vit dans une closure, elle se souvient donc du minuteur d'un appel à l'autre. Chaque appel annule le minuteur précédent avec clearTimeout() et en programme un nouveau. La fonction ne s'exécute que lorsqu'aucun nouvel appel n'arrive pendant delay millisecondes. L'écriture func.apply(this, args) transmet à la fois this et tous les arguments rassemblés par ...args.
Le throttling fonctionne autrement. Il ressemble au planning de nourrissage du T-Rex : peu importe combien de fois il rugit, il reçoit sa portion au maximum une fois par période donnée.
1// Throttling - limite la fréquence d'exécution de la fonction
2function throttle(func, limit) {
3 let inThrottle;
4 return function(...args) {
5 if (!inThrottle) {
6 func.apply(this, args);
7 inThrottle = true;
8 setTimeout(() => inThrottle = false, limit);
9 }
10 };
11}Le premier appel s'exécute immédiatement et lève le drapeau inThrottle. Les appels suivants pendant limit sont tout simplement rejetés, et non reportés à plus tard. Une fois le délai écoulé, le drapeau retombe, et l'appel suivant passe de nouveau. Attention à une conséquence de cette version : le dernier appel d'une série peut se perdre, donc à la fin du défilement, la fonction ne verra pas la position finale.
Le debouncing dans le moteur de recherche
Maintenant, on branche le debouncing sur le champ de recherche des dinosaures.
1// Utilisation du debouncing pour la recherche
2const searchInput = document.getElementById('dino-search');
3const debouncedSearch = debounce((query) => {
4 console.log(`Recherche de dinosaures : ${query}`);
5 searchDinosaurs(query);
6}, 300);
7
8searchInput.addEventListener('input', (e) => {
9 debouncedSearch(e.target.value);
10});Si tu tapes rapidement « Rex », l'écouteur appelle debouncedSearch() trois fois, mais la recherche ne démarre qu'une fois, 300 millisecondes après le dernier caractère. L'événement input lui-même se déclenche toujours à chaque caractère, on change seulement la fréquence à laquelle on lance le travail coûteux. Teste-toi : tu tapes « T », 200 millisecondes plus tard « R », puis tu arrêtes d'écrire. Combien de fois la recherche démarre-t-elle ? Une fois, pour « TR », car la pause de 200 millisecondes était trop courte pour que le premier minuteur ait le temps de se déclencher.
On peut écrire la même technique sans fonction utilitaire, avec une seule variable pour le minuteur.
1let timer;
2searchInput.addEventListener('input', () => {
3 clearTimeout(timer);
4 timer = setTimeout(() => searchDinosaurs(searchInput.value), 300);
5});C'est exactement le même mécanisme : on annule l'ancien minuteur et on en programme un nouveau. La fonction debounce() l'enferme simplement dans un emballage réutilisable, que tu peux appliquer à n'importe quelle fonction.
Le throttling pendant le défilement
Pour le défilement, on veut autre chose. La barre de progression doit se mettre à jour régulièrement pendant le mouvement, et pas seulement une fois qu'il est terminé.
1// Utilisation du throttling pour le scroll
2const throttledScroll = throttle(() => {
3 const scrollPosition = window.scrollY;
4 updateNavigationHighlight(scrollPosition);
5 updateProgressBar(scrollPosition);
6}, 100);
7
8window.addEventListener('scroll', throttledScroll);Même si le navigateur signale scroll de nombreuses fois, la position tirée de window.scrollY n'arrivera à la navigation qu'au maximum une fois toutes les 100 millisecondes, soit environ dix fois pour une seconde de défilement. La page défile toujours de façon fluide, car on ne limite que notre fonction, et non le défilement lui-même.
Quelle technique choisir ?
Choisis le debouncing quand seule la valeur finale compte : recherche, validation une fois la saisie terminée, enregistrement d'un brouillon ou recalcul de la mise en page quand la fenêtre a fini d'être redimensionnée. Choisis le throttling quand tu as besoin de mises à jour régulières pendant une action continue : progression du défilement, suivi du curseur pendant mousemove, position d'un élément qu'on fait glisser.
Mon conseil : dans les projets en production, utilise des implémentations éprouvées, par exemple debounce et throttle de la bibliothèque lodash, qui gèrent aussi l'appel final. Écris quand même tes propres versions au moins une fois, car c'est seulement là que tu comprends vraiment ce qui arrive au minuteur. Dans le bac à sable ci-dessous, tu verras les deux techniques côte à côte et tu compareras le nombre d'appels. Dans la leçon sur les performances, tu découvriras d'autres outils pour dompter les événements fréquents.
Retiens bien : le debouncing, c'est le raptor qui attend le silence, et le throttling, c'est le planning de nourrissage qui interdit de nourrir le T-Rex plus souvent que nécessaire.
Code de cette leçon : index.html
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Throttling et debouncing</title>
7 <style>
8 body {
9 font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
10 background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
11 margin: 0;
12 padding: 20px;
13 color: white;
14 }
15
16 .container {
17 max-width: 1200px;
18 margin: 0 auto;
19 }
20
21 h1 {
22 text-align: center;
23 margin-bottom: 40px;
24 }
25
26 .demo-grid {
27 display: grid;
28 grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
29 gap: 30px;
30 }
31
32 .demo-box {
33 background: rgba(0, 0, 0, 0.7);
34 border-radius: 15px;
35 padding: 25px;
36 border: 2px solid #34495e;
37 }
38
39 h2 {
40 color: #3498db;
41 margin-bottom: 15px;
42 font-size: 20px;
43 }
44
45 .description {
46 color: #bdc3c7;
47 font-size: 14px;
48 margin-bottom: 20px;
49 line-height: 1.5;
50 }
51
52 input[type="text"], textarea {
53 width: 100%;
54 padding: 12px;
55 border: 2px solid #34495e;
56 border-radius: 8px;
57 background: rgba(255, 255, 255, 0.1);
58 color: white;
59 font-size: 16px;
60 margin-bottom: 10px;
61 }
62
63 input:focus, textarea:focus {
64 outline: none;
65 border-color: #3498db;
66 }
67
68 .scroll-area {
69 height: 200px;
70 overflow-y: auto;
71 background: rgba(255, 255, 255, 0.05);
72 border-radius: 8px;
73 padding: 15px;
74 margin-bottom: 15px;
75 }
76
77 .scroll-content {
78 height: 800px;
79 }
80
81 .counter-display {
82 background: rgba(52, 152, 219, 0.2);
83 border: 1px solid #3498db;
84 border-radius: 8px;
85 padding: 15px;
86 text-align: center;
87 margin-bottom: 15px;
88 }
89
90 .counter-number {
91 font-size: 48px;
92 font-weight: bold;
93 color: #3498db;
94 }
95
96 .counter-label {
97 font-size: 14px;
98 color: #bdc3c7;
99 margin-top: 5px;
100 }
101
102 .comparison-grid {
103 display: grid;
104 grid-template-columns: 1fr 1fr;
105 gap: 10px;
106 margin-bottom: 15px;
107 }
108
109 .comparison-box {
110 background: rgba(255, 255, 255, 0.05);
111 border-radius: 8px;
112 padding: 12px;
113 text-align: center;
114 }
115
116 .comparison-number {
117 font-size: 32px;
118 font-weight: bold;
119 color: #e74c3c;
120 }
121
122 .comparison-number.throttled {
123 color: #27ae60;
124 }
125
126 .log {
127 background: #000;
128 border-radius: 8px;
129 padding: 12px;
130 max-height: 150px;
131 overflow-y: auto;
132 font-family: 'Courier New', monospace;
133 font-size: 11px;
134 }
135
136 .log-entry {
137 padding: 2px 0;
138 color: #95a5a6;
139 }
140
141 .log-entry.throttled {
142 color: #27ae60;
143 }
144
145 .log-entry.debounced {
146 color: #3498db;
147 }
148
149 .info-tag {
150 display: inline-block;
151 padding: 4px 10px;
152 border-radius: 12px;
153 font-size: 12px;
154 font-weight: bold;
155 margin-bottom: 10px;
156 }
157
158 .tag-throttle {
159 background: #27ae60;
160 color: white;
161 }
162
163 .tag-debounce {
164 background: #3498db;
165 color: white;
166 }
167
168 .reset-btn {
169 width: 100%;
170 padding: 10px;
171 background: #7f8c8d;
172 color: white;
173 border: none;
174 border-radius: 6px;
175 cursor: pointer;
176 font-weight: bold;
177 }
178
179 .reset-btn:hover {
180 background: #95a5a6;
181 }
182 </style>
183</head>
184<body>
185 <div class="container">
186 <h1>Throttling vs debouncing au Parc Jurassique</h1>
187
188 <div class="demo-grid">
189 <!-- Demo 1: Search avec Debouncing -->
190 <div class="demo-box">
191 <div class="info-tag tag-debounce">DEBOUNCING</div>
192 <h2>1. Recherche de dinosaures</h2>
193 <p class="description">
194 Searching starts only after 500ms from the last keystroke. This saves API calls.
195 </p>
196
197 <input type="text" id="search-input" placeholder="Saisis un nom de dinosaure...">
198
199 <div class="counter-display">
200 <div class="counter-number" id="search-count">0</div>
201 <div class="counter-label">Searches performed</div>
202 </div>
203
204 <div class="log" id="search-log"></div>
205 </div>
206
207 <!-- Demo 2: Scroll avec Throttling -->
208 <div class="demo-box">
209 <div class="info-tag tag-throttle">THROTTLING</div>
210 <h2>2. Surveillance de la position</h2>
211 <p class="description">
212 Position is checked at most every 200ms during scrolling. Improves performance.
213 </p>
214
215 <div class="scroll-area" id="scroll-area">
216 <div class="scroll-content">
217 <p>Défile vers le bas pour voir le throttling en action !</p>
218 <p style="margin-top: 100px;">Continue à défiler...</p>
219 <p style="margin-top: 100px;">Presque fini...</p>
220 <p style="margin-top: 100px;">Encore !</p>
221 <p style="margin-top: 100px;">Fin !</p>
222 </div>
223 </div>
224
225 <div class="counter-display">
226 <div class="counter-number" id="scroll-count">0</div>
227 <div class="counter-label">Position checks (throttled)</div>
228 </div>
229
230 <div class="log" id="scroll-log"></div>
231 </div>
232
233 <!-- Demo 3: Comparison without/with optimization -->
234 <div class="demo-box">
235 <h2>3. Comparaison : champ sans/avec debouncing</h2>
236 <p class="description">
237 Type something to see the difference between a normal handler and debounced.
238 </p>
239
240 <input type="text" id="comparison-input" placeholder="Teste la différence...">
241
242 <div class="comparison-grid">
243 <div class="comparison-box">
244 <div class="comparison-number" id="normal-count">0</div>
245 <div class="counter-label">Normal calls</div>
246 </div>
247 <div class="comparison-box">
248 <div class="comparison-number throttled" id="debounced-count">0</div>
249 <div class="counter-label">Debounced (300ms)</div>
250 </div>
251 </div>
252
253 <div class="log" id="comparison-log"></div>
254 </div>
255
256 <!-- Demo 4: Resize avec Throttling -->
257 <div class="demo-box">
258 <div class="info-tag tag-throttle">THROTTLING</div>
259 <h2>4. Gestionnaire de redimensionnement du textarea</h2>
260 <p class="description">
261 On resize, the function is called at most every 250ms.
262 </p>
263
264 <textarea id="resize-textarea" rows="4" placeholder="Redimensionne la fenêtre du navigateur..."></textarea>
265
266 <div class="comparison-grid">
267 <div class="comparison-box">
268 <div class="comparison-number" id="resize-normal">0</div>
269 <div class="counter-label">Normal resize</div>
270 </div>
271 <div class="comparison-box">
272 <div class="comparison-number throttled" id="resize-throttled">0</div>
273 <div class="counter-label">Throttled (250ms)</div>
274 </div>
275 </div>
276
277 <button class="reset-btn" id="reset-resize">Réinitialiser les compteurs</button>
278 </div>
279 </div>
280 </div>
281
282 <script>
283 console.log("Système d'optimisation Event Handlers - START");
284
285 // IMPLÉMENTATION DEBOUNCE
286 function debounce(func, delay) {
287 let timeoutId;
288 return function(...args) {
289 clearTimeout(timeoutId);
290 timeoutId = setTimeout(() => {
291 func.apply(this, args);
292 }, delay);
293 };
294 }
295
296 // IMPLÉMENTATION THROTTLE
297 function throttle(func, limit) {
298 let inThrottle;
299 return function(...args) {
300 if (!inThrottle) {
301 func.apply(this, args);
302 inThrottle = true;
303 setTimeout(() => inThrottle = false, limit);
304 }
305 };
306 }
307
308 // DEMO 1: Search avec Debouncing
309 const searchInput = document.getElementById('search-input');
310 const searchCount = document.getElementById('search-count');
311 const searchLog = document.getElementById('search-log');
312 let searchCounter = 0;
313
314 function logSearch(message, type = '') {
315 const entry = document.createElement('div');
316 entry.className = `log-entry ${type}`;
317 entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
318 searchLog.insertBefore(entry, searchLog.firstChild);
319 if (searchLog.children.length > 10) {
320 searchLog.removeChild(searchLog.lastChild);
321 }
322 }
323
324 function performSearch(query) {
325 searchCounter++;
326 searchCount.textContent = searchCounter;
327 logSearch(`Searching: "${query}"`, 'debounced');
328 console.log(`API Call: Searching dinosaure "${query}"`);
329 }
330
331 const debouncedSearch = debounce(performSearch, 500);
332
333 searchInput.addEventListener('input', function(e) {
334 const query = e.target.value;
335 logSearch(`⌨Saisi: "${query}"`);
336 debouncedSearch(query);
337 });
338
339 // DEMO 2: Scroll avec Throttling
340 const scrollArea = document.getElementById('scroll-area');
341 const scrollCount = document.getElementById('scroll-count');
342 const scrollLog = document.getElementById('scroll-log');
343 let scrollCounter = 0;
344
345 function logScroll(message, type = '') {
346 const entry = document.createElement('div');
347 entry.className = `log-entry ${type}`;
348 entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
349 scrollLog.insertBefore(entry, scrollLog.firstChild);
350 if (scrollLog.children.length > 8) {
351 scrollLog.removeChild(scrollLog.lastChild);
352 }
353 }
354
355 function checkScrollPosition() {
356 scrollCounter++;
357 scrollCount.textContent = scrollCounter;
358 const position = scrollArea.scrollTop;
359 const percentage = Math.round((position / (scrollArea.scrollHeight - scrollArea.clientHeight)) * 100);
360 logScroll(`Position: ${percentage}%`, 'throttled');
361 console.log(`Scroll position: ${percentage}%`);
362 }
363
364 const throttledScroll = throttle(checkScrollPosition, 200);
365 scrollArea.addEventListener('scroll', throttledScroll);
366
367 // DEMO 3: Input Comparison
368 const comparisonInput = document.getElementById('comparison-input');
369 const normalCount = document.getElementById('normal-count');
370 const debouncedCountDisplay = document.getElementById('debounced-count');
371 const comparisonLog = document.getElementById('comparison-log');
372 let normalCounter = 0;
373 let debouncedCounter = 0;
374
375 function logComparison(message, type = '') {
376 const entry = document.createElement('div');
377 entry.className = `log-entry ${type}`;
378 entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
379 comparisonLog.insertBefore(entry, comparisonLog.firstChild);
380 if (comparisonLog.children.length > 8) {
381 comparisonLog.removeChild(comparisonLog.lastChild);
382 }
383 }
384
385 function normalHandler() {
386 normalCounter++;
387 normalCount.textContent = normalCounter;
388 logComparison('Normal call');
389 }
390
391 function debouncedHandler() {
392 debouncedCounter++;
393 debouncedCountDisplay.textContent = debouncedCounter;
394 logComparison('Debounced call', 'debounced');
395 }
396
397 const debouncedCompare = debounce(debouncedHandler, 300);
398
399 comparisonInput.addEventListener('input', function() {
400 normalHandler();
401 debouncedCompare();
402 });
403
404 // DEMO 4: Resize avec Throttling
405 const resizeTextarea = document.getElementById('resize-textarea');
406 const resizeNormal = document.getElementById('resize-normal');
407 const resizeThrottled = document.getElementById('resize-throttled');
408 const resetResizeBtn = document.getElementById('reset-resize');
409 let resizeNormalCounter = 0;
410 let resizeThrottledCounter = 0;
411
412 function handleResize() {
413 resizeNormalCounter++;
414 resizeNormal.textContent = resizeNormalCounter;
415 const width = window.innerWidth;
416 const height = window.innerHeight;
417 resizeTextarea.value = `Window size: ${width}x${height}px\nNormal calls: ${resizeNormalCounter}`;
418 }
419
420 function handleResizeThrottled() {
421 resizeThrottledCounter++;
422 resizeThrottled.textContent = resizeThrottledCounter;
423 console.log(`Throttled resize: ${window.innerWidth}x${window.innerHeight}`);
424 }
425
426 const throttledResize = throttle(handleResizeThrottled, 250);
427
428 window.addEventListener('resize', function() {
429 handleResize();
430 throttledResize();
431 });
432
433 resetResizeBtn.addEventListener('click', function() {
434 resizeNormalCounter = 0;
435 resizeThrottledCounter = 0;
436 resizeNormal.textContent = '0';
437 resizeThrottled.textContent = '0';
438 resizeTextarea.value = '';
439 console.log('Compteurs resize réinitialisés');
440 });
441
442 // Initialisation
443 logSearch('Système de recherche prêt', 'debounced');
444 logScroll('Système de surveillance prêt', 'throttled');
445 logComparison('Comparison system ready');
446
447 console.log("Tous les systèmes d'optimisation actifs");
448 console.log('Debounce: Delays execution until a pause');
449 console.log('Throttle: Limits execution frequency');
450 </script>
451</body>
452</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. Que fait la technique du debounce lorsqu'elle est appliquée à l'événement
input?2. Quel événement est le mieux limité avec throttle pour que la fonction s'exécute par exemple toutes les 100 ms ?
Tâches pratiques dans le jeu
- Tri horizontal
Ordonne l'appel de la fonction
debounce(une ligne, de gauche à droite) :