Una casella di ricerca che lancia una richiesta a ogni tasto premuto manda cinque richieste per “redis”, e quattro arrivano scadute prima che torni la risposta. Un handler di scroll che ricalcola il layout gira centinaia di volte al secondo e fa scattare la pagina. Stesso problema in entrambi i casi: l’evento del DOM si attiva molto più spesso di quanto serva alla funzione. Debounce e throttle lo risolvono in due modi diversi.
Cosa fa il debounce e cosa fa il throttle
Il debounce aspetta che l’evento smetta di attivarsi per un certo periodo, poi esegue una volta. Ogni nuova chiamata azzera il timer. Se l’evento non si ferma mai, la funzione non parte mai. Il campo di ricerca è il caso tipico: aspetta che l’utente faccia una pausa, poi manda una richiesta per quello che ha scritto per ultimo.
Il throttle limita quante volte la chiamata può eseguire. Durante una raffica continua a girare, al massimo una volta per intervallo. Usalo per scroll, dove la pagina deve continuare ad aggiornarsi mentre l’utente scorre, ma non a ogni singolo evento.
| Debounce | Throttle | |
|---|---|---|
| Esegue durante la raffica | No, solo dopo che finisce | Sì, al massimo una volta per intervallo |
| Cosa ottieni | L’ultima chiamata della raffica | Un campione costante durante tutta la raffica |
| Uso tipico | Campo di ricerca, validazione form, salvataggio automatico | scroll, resize, mousemove, drag |
| Se la raffica non si ferma mai | Non esegue mai | Continua a girare secondo lo schema |
Entrambi sono wrapper. Passi la tua callback e un ritardo, ottieni in cambio una funzione con la stessa firma, e la agganci all’evento invece dell’originale.
Come scrivere una funzione di debounce
| |
Ogni chiamata annulla il setTimeout in attesa e ne programma uno nuovo, così solo l’ultima chiamata della raffica vive abbastanza da scattare. Il timing segue le regole di setTimeout descritte in come l’event loop di JavaScript pianifica le callback: la callback non parte mai prima di delay, e può partire più tardi se lo stack di chiamate è occupato quando il timer scade.
Collegato a un campo di ricerca:
| |
Scrivi “redis” a velocità normale e la console mostra una riga sola, searching for: redis, circa 300ms dopo l’ultimo tasto premuto. Non cinque righe per cinque lettere.
Come scrivere una funzione di throttle
| |
Questa è la versione leading-edge. La prima chiamata in una finestra parte subito; tutto quello che arriva dopo viene scartato finché non sono passati interval millisecondi.
| |
Scorri in continuazione per due secondi e ottieni circa dieci righe di log distribuite in modo uniforme, invece delle centinaia di eventi scroll che il browser genera davvero.
Opzioni debounce e throttle di lodash: leading, trailing, maxWait
Ognuna delle funzioni scritte a mano sopra fissa una sola scelta. Il debounce scatta solo sul trailing edge, dopo il periodo di quiete. Il throttle scatta solo sul leading edge, nell’istante in cui si apre una finestra, quindi l’ultima chiamata di una raffica si perde se cade a metà finestra. Per scroll conta: l’ultima posizione a cui si è fermato l’utente potrebbe non essere mai gestita.
_.debounce(fn, wait, options) e _.throttle(fn, wait, options) di lodash rendono configurabili entrambi i bordi con i booleani leading e trailing, e i default sono diversi. Il debounce ha default leading: false, trailing: true: aspetta, poi scatta. Il throttle ha default entrambi true: scatta subito, poi un’altra volta alla fine di una raffica durata più di un intervallo. Internamente, il throttle di lodash è il suo debounce con maxWait impostato sull’intervallo, quindi i due sono più simili di quanto suggeriscano i nomi.
maxWait è un’opzione del debounce che limita quanto può essere rinviata la prima chiamata. Senza, chi scrive con pause più corte del delay non attiva mai l’handler, anche dopo dieci secondi di attività. Con maxWait: 2000, scatta almeno ogni due secondi.
Il debounce leading-only risolve il caso del doppio invio. _.debounce(sendMail, 300, { leading: true, trailing: false }) scatta al primo click e ignora i click successivi finché non passano 300ms senza alcun click. Il primo click conta; gli altri vengono scartati, non ritardati.
Quali eventi richiedono debounce e quali throttle
| Evento | Scelta | Perché |
|---|---|---|
| Ricerca mentre si scrive | Debounce | Conta solo la query finale; i tasti precedenti producono richieste che butteresti via |
resize della finestra | Debounce o throttle | Debounce se serve solo la dimensione finale (ricalcolare il layout una volta); throttle se l’interfaccia deve seguire visibilmente il ridimensionamento |
scroll | Throttle | La pagina deve continuare a rispondere durante tutto lo scroll, non solo dopo che l’utente si ferma |
mousemove (drag, tooltip che segue) | Throttle, tramite requestAnimationFrame | Aggiornamenti più veloci della frequenza di refresh dello schermo sono lavoro sprecato |
| Click su un bottone (evitare il doppio invio) | Debounce leading-only o disabilitazione al click | Il primo click deve contare; gli altri vanno ignorati, non ritardati |
| Autosave a ogni tasto premuto | Debounce con maxWait | Salva dopo una pausa, ma garantisce un salvataggio durante una digitazione continua lunga |
Quando l’handler guida qualcosa di visivo (un drag, un tooltip che segue il cursore, un’animazione legata allo scroll), sostituisci Date.now con requestAnimationFrame:
| |
Il ritmo delle chiamate ora segue la frequenza di rendering del browser invece di un numero di millisecondi scelto a caso. L’aggiornamento gira una volta per frame, appena prima che il frame venga disegnato. Non può quindi girare più spesso di quanto chiunque possa vedere, e resta sincronizzato con il rendering anche quando il thread principale è sotto carico.
Errori comuni con debounce e throttle
Creare la funzione debounced dentro l’handler. In React, onChange={(e) => debounce(handleSearch, 300)(e.target.value)} chiama debounce() a ogni tasto premuto, e ogni chiamata restituisce un nuovo wrapper con il suo timer vuoto. Non c’è mai un timer precedente da annullare, quindi ogni tasto premuto attiva comunque una richiesta, solo con 300ms di ritardo. Crea la funzione debounced una sola volta (useMemo, useRef, o scope di modulo fuori da un componente) e chiama sempre quella stessa istanza.
Non annullare allo smontaggio. Un autosave debounced che scatta dopo che l’utente ha navigato altrove scrive su un componente che non esiste più, o salva da una closure non più valida. Le funzioni debounced e throttled di lodash hanno già un metodo .cancel(); per il debounce scritto a mano, aggiungine uno che chiama clearTimeout(timer). Chiamalo quando il componente si smonta o la pagina si chiude.
Testare con timer reali. Aspettare un debounce con await new Promise(r => setTimeout(r, 300)) funziona, ma aggiungilo a ogni test di timing e la suite diventa lenta al punto che la gente smette di eseguirla in locale. Usa i timer fittizi: vi.useFakeTimers() in Vitest o jest.useFakeTimers() in Jest, poi sposta l’orologio con vi.advanceTimersByTime(300). Un timer fittizio è un test double per l’orologio, non per il tuo codice, la distinzione tracciata in test unitari vs test di integrazione vs test end-to-end.
Fare debounce su qualcosa che deve sembrare dal vivo. Una barra di progresso o una maniglia di drag che si muove solo quando l’utente si ferma sembra rotta. Se il requisito è “aggiorna in modo fluido mentre questo continua a succedere”, serve il throttle.
Usare debounce o throttle di default?
Usa il debounce quando conta solo lo stato finale: campi di ricerca, autosave, validare un campo dopo che l’utente ha smesso di scrivere. Usa il throttle quando l’utente si aspetta di vedere il progresso mentre qualcosa continua, come scroll, resize o drag, e passa alla versione con requestAnimationFrame quando il risultato è un aggiornamento visivo.
In produzione, importa debounce e throttle da una libreria mantenuta (lodash, o un hook come use-debounce in React) invece di spedire le versioni sopra. Esistono per mostrare il meccanismo. Le versioni della libreria gestiscono già cancellazione, maxWait, chiamate trailing e il binding di this, i punti dove una copia scritta a mano di solito si rompe.