Écrire une utilité de débouncing à partir de zéro : temporisateurs, arguments et cela
Créez une fonction debounce réutilisable ligne par ligne, découvrez pourquoi elle transmet les arguments et dans quels cas il vaut mieux utiliser un mécanisme de limitation, ainsi que la manière de l’associer à AbortController.
Sources: Debouncing in JavaScript: How to Stop Sending Useless API Requests
Une boîte de recherche qui envoie une requête au serveur à chaque frappe transforme un mot saisi en une série de demandes, la plupart étant rejetées. La solution réside dans une petite utilité appelée debounce. Ici, vous la mettrez en œuvre, comprendrez chaque ligne de code, choisirez entre le debounce et le throttling, et l’associerez à l’annulation des requêtes afin d’obtenir un flux de recherche capable de fonctionner correctement lors d’une frappe réelle.
Comment les frappes se transforment en requêtes inutiles
Imaginez un utilisateur qui cherche un framework. La valeur saisie change caractère par caractère :
r
re
rea
reac
react
Si chaque modification déclenche une appel API, le serveur doit gérer cinq requêtes pour une seule recherche. Une recherche plus précise aggrave encore la situation :
r
re
rea
reac
react
react j
react js
react js d
react js de
react js dev
Cela représente dix requêtes, et seule la dernière reflète ce que l’utilisateur voulait ; les autres consomment toujours de la bande passante, du temps de traitement sur le serveur et des ressources liées aux limites de fréquence.
L’idée derrière le debounce
Le débouncing reporte l’appel d’une fonction jusqu’à ce qu’un certain temps se soit écoulé sans nouvel appel : attendre que l’utilisateur ait terminé, puis effectuer le traitement.
Supposons que l’appel de recherche soit différé avec un délai de 500 ms et que l’utilisateur tape à nouveau les mêmes cinq caractères :
r
re
rea
reac
react
Chaque frappe redémarre le compte à rebours, donc rien n’est envoyé tant que la saisie se poursuit. Après 500 ms de silence, une seule requête est envoyée :
react → API request
La boucle de décision à chaque appel :
Typing
↓
Wait 500ms
↓
Did user type again?
↓
Yes → Reset timer
↓
No → Execute function
Une boîte de recherche sans aucune protection
Commencez par un élément d’entrée simple :
<input id="search" placeholder="Search..." />
Obtenez une référence à cet élément en JavaScript :
const searchInput = document.querySelector("#search");
Puis attachez un écouteur input qui transmet la valeur à une fonction de recherche, laquelle se contente de l’enregistrer afin de compter le nombre de fois où elle est déclenchée :
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});function searchProducts(query) {
console.log("API request:", query);
}
Supposons maintenant que quelqu’un tape le nom d’un produit :
iphone
L’événement input est déclenché une fois par caractère, plus ou moins dans cette séquence :
i
ip
iph
ipho
iphon
iphone
Ainsi, le gestionnaire s’exécute six fois et enregistre six requêtes :
API request: i
API request: ip
API request: iph
API request: ipho
API request: iphon
API request: iphone
Alors qu’avec une API réelle il y a six allers-retours, tandis que l’utilisateur ne s’intéresse qu’à :
iphone
Mettre en œuvre le debounce en quelques lignes
Cette fonction utilitaire est une fermeture : elle prend une fonction et un délai, puis retourne un wrapper que l’on appelle à la place de la fonction originale :
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
Conserver un seul timer par fonction debounce
let timer;
timer se trouve dans la fermeture, donc chaque appel au wrapper voit la même valeur ; c’est ainsi qu’un appel sait qu’un appel précédent est encore en attente. Chaque appel à debounce obtient son propre timer indépendant.
Annuler l’appel en attente
clearTimeout(timer);
Chaque invocation annule l’appel en attente, s’il y en a un. Fournir la valeur undefined à clearTimeout lors de la première exécution n’a aucun effet néfaste.
Planifier un nouvel appel
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
Un nouveau délai fait exécuter la fonction d’origine après l’intervalle prévu. Avec 500 ms, les appels rapides se déroulent de la manière suivante :
Call
↓
500ms timer starts
Call again
↓
Old timer cancelled
↓
New 500ms timer starts
Call again
↓
Old timer cancelled
↓
New 500ms timer starts
La fonction s’exécute une seule fois par intervalle complet de 500 ms, à moins qu’un autre appel ne survienne ; dans ce cas, les derniers arguments priment.
Intégrer l’outil dans la boîte de recherche
Remplacez le gestionnaire simple par une version à débouncing (cela remplace la déclaration précédente de searchProducts, puisque les deux utilisent le même nom) :
const searchProducts = debounce((query) => {
console.log("API request:", query);
}, 500);
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});
Lorsque l’utilisateur tape les cinq caractères habituels :
r
re
rea
reac
react
chaque frappe annule le délai précédent, et finalement la période de silence s’achève :
react
↓
500ms
↓
API request
Le résultat est une seule requête API au lieu de cinq.
Pourquoi l’encapsuleur transmet-il les arguments
Deux détails semblent optionnels mais rendent l’outil réutilisable. Le premier est le paramètre rest :
return function (...args)
Le second concerne la manière dont l’original est appelé :
fn.apply(this, args);
Prendre en charge n’importe quel nombre d’arguments
const search = debounce((query) => {
console.log(query);
}, 500);
ou une fonction qui prend deux valeurs :
const calculate = debounce((a, b) => {
console.log(a + b);
}, 500);
Réunir tout cela avec la syntaxe rest et le transmettre permet à chacun de continuer de fonctionner :
...args
Préserver le contexte d’appel
Même ligne transmet également this :
fn.apply(this, args);
Le conteneur est une fonction ordinaire, ce qui lui permet de recevoir le this de l’appelant, par exemple un objet lorsque la fonction différée est stockée en tant que méthode. La fonction flèche à l’intérieur de setTimeout n’a pas de this propre, elle capte donc celui du conteneur, que apply lui transmet. Un gestionnaire de recherche ne s’en aperçoit jamais, mais une utilité partagée qui perd le contexte provoque des bugs confus dans du code basé sur des objets. Notez que une fonction flèche passée en tant que fn conserve néanmoins son this lexical.
Où la différation s’avère utile
Cette approche convient à tout événement se produisant en série rapide lorsque seul l’état final compte.
Suggestions de recherche
Le flux naïf envoie une requête à chaque frappe :
User types → API request
Le flux avec différation attend d’abord la pause :
User types
↓
Wait
↓
User stops
↓
API request
Redimensionnement de la fenêtre
Déplacer un bord d’une fenêtre génère continuellement des événements resize, et un écouteur direct exécute le travail coûteux à chaque fois :
window.addEventListener("resize", () => {
expensiveCalculation();
});
Cela peut signifier des centaines d’exécutions par déplacement. En l’encapsulant, le travail s’exécute une fois que la taille de la fenêtre est stabilisée :
const handleResize = debounce(() => {
expensiveCalculation();
}, 300);
window.addEventListener("resize", handleResize);
Enregistrement automatique
Dans un éditeur, le débouncing transforme une série de modifications en un seul enregistrement après que l’utilisateur a cessé d’écrire :
User types
User types
User types
User types
...
User stops
↓
Save document
Prudence : un débouncing en fin de séquence ne se déclenche jamais tant que l’utilisateur tape sans arrêt, et un enregistrement en attente est perdu si la page se ferme. Les éditeurs ajoutent généralement un délai maximal ou enregistrent automatiquement lors d’un visibilitychange.
Débouncing versus throttling
Ces deux concepts sont facilement confondus. Le débouncing attend que l’activité cesse, puis s’exécute une fois :
Typing → Typing → Typing → STOP
↓
Execute
Le throttling exécute la fonction au plus une fois par intervalle fixe, quel que soit le nombre d’événements reçus :
Event → Execute
↓
100ms
↓
Ignore
↓
100ms
↓
Execute
L’exécution en début de séquence par rapport à celle à la fin apporte des nuances, mais cette distinction couvre la plupart des cas.
Le débouncing ne corrige pas les réponses obsolètes
Le débouncing limite le nombre de requêtes initiées, mais pas ce qui arrive à celles déjà en cours d’exécution. Supposons qu’un utilisateur attende suffisamment longtemps pour que une requête soit envoyée, puis affine sa recherche :
User searches: react
↓
Request A
User changes search: react native
↓
Request B
Si la requête B revient avant la requête A, la réponse plus ancienne arrive en dernier et remplace les résultats plus récents. L’interface affiche alors des résultats correspondant à :
react
même si l’entrée indique maintenant :
react native
La solution consiste à annuler les requêtes obsolètes à l’aide de AbortController, en le combinant avec le mécanisme de débouncing :
Debouncing
↓
Reduce unnecessary requests
↓
AbortController
↓
Cancel outdated requests
Cette situation de concurrence est abordée en détail dans comment résoudre les conditions de concurrence que le débouncing ne peut pas résoudre.
Assemblage des deux couches
Cette logique conserve un controller au niveau du module. Chaque fois que la fonction de débouncing s’exécute, elle annule la requête précédente, crée un nouveau controller et transmet son signal à fetch. encodeURIComponent protège les caractères spéciaux, et un AbortError est ignoré car il indique une annulation intentionnelle et non une erreur :
const searchInput = document.querySelector("#search");
let controller;
const searchProducts = debounce(async (query) => {
controller?.abort();
controller = new AbortController();
try {
const response = await fetch(
`/api/products?q=${encodeURIComponent(query)}`,
{
signal: controller.signal
}
);
const data = await response.json();
renderResults(data);
} catch (error) {
if (error.name !== "AbortError") {
console.error(error);
}
}
}, 500);
searchInput.addEventListener("input", (event) => {
searchProducts(event.target.value);
});
Le débouncing permet d’éviter une requête par touche ; le AbortController arrête les requêtes dont les résultats ne sont plus pertinents.
Dans un environnement de production, il convient également d’ignorer les requêtes vides et de vérifier response.ok avant le parsing, étant donné que fetch ne rejette pas les requêtes en cas d’erreurs HTTP.
Un modèle de décision rapide
Lorsqu’un événement se produit de manière répétée, demandez-vous s’il est nécessaire de répondre à chacun d’eux. Si ce n’est pas le cas, une deuxième question permet de trancher. Pour les entrées, vous souhaitez obtenir la valeur finale, donc utilisez le mécanisme de debounce :
Input events
↓
Do I need every event?
↓
No
↓
Do I care about the final state?
↓
Yes
↓
Debounce
Pour le défilement, vous voulez des mises à jour régulières pendant la manipulation, donc utilisez le mécanisme de throttle :
Scroll events
↓
Do I need every event?
↓
No
↓
Do I need periodic updates while scrolling?
↓
Yes
↓
Throttle
En résumé
Le debounce vise essentiellement à contrôler la fréquence d’exécution de tâches coûteuses, et son fonctionnement se résume à deux lignes de code :
clearTimeout(timer);
timer = setTimeout(() => {
fn();
}, delay);
Utilisez-le lorsque des événements de ce type se produisent en série rapide :
input
resize
search
auto-save
Quelques points à retenir :
- La fonction debounce doit être créée une seule fois et réutilisée ; en en créer une nouvelle pour chaque événement (ou à chaque rendu React sans
useMemoouuseRef), on crée un timer distinct pour chaque appel, ce qui annule l’effet escompté. - Le transfert de
...argset dethispermet à cette fonction d’être réutilisée en toute sécurité.
AbortController détermine si une requête obsolète peut être terminée. Utilisez les deux pour les recherches basées sur le réseau.