Cours JavaScript et TypeScript · Module 8: Événements et DOM

Throttling et debouncing

5 min de lecture

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. 1. Que fait la technique du debounce lorsqu'elle est appliquée à l'événement input ?

  2. 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) :

Articles utiles