Accueil / Articles

É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.

Éditeur : REACTAPP.TOP8 min de lecture1742 mots

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 useMemo ou useRef), on crée un timer distinct pour chaque appel, ce qui annule l’effet escompté.
  • Le transfert de ...args et de this permet à cette fonction d’être réutilisée en toute sécurité.
  • Choisissez le débouncing pour les événements de « état final » et le throttling pour les événements d’« mises à jour continues pendant l’événement ».
  • Le débouncing décide du moment où une requête commence ; 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.