Accueil / Articles

Usines soutenues par des mécanismes de fermeture : État privé, atténuation des réactions, limites de fréquence et annulation

Voyez comment les closures maintiennent l’état privé actif dans les factories JavaScript, pourquoi elles provoquent des valeurs obsolètes dans les timers React, et comment le debounce, la limitation de fréquence et l’annulation dépendent d’elles.

Éditeur : REACTAPP.TOP7 min de lecture1631 mots

Sources: Closures in JavaScript and React, Explained Simply

Les fermetures sont à l’origine d’une quantité surprenante de fonctionnalités courantes en JavaScript : compteurs, entrées déboussolées, régulation des requêtes, piles d’annulation et presque tous les hooks React. Elles sont également responsables d’une série de bugs où une fonction de rappel renvoie une valeur qui n’est plus vraie. Ce guide propose une définition opérationnelle, explique ce que cela signifie pour l’état React, puis présente quatre modèles de fabrication pratiques, en indiquant les limites à connaître avant de les utiliser en production.

Une définition opérationnelle

Une fermeture est une fonction associée aux variables qui étaient dans son champ de portée au moment de sa définition. Cette fonction conserve un accès à ces variables même après que la fonction externe qui les a déclarées ait renvoyé. Une image utile est celle d’une fonction qui quitte son « domicile » en emportant un sac contenant les variables auxquelles elle fait référence ; où qu’elle soit appelée par la suite, elle y puisera dans ce même sac.

Le modèle de fabrication du compteur

La démonstration classique concerne une usine qui renvoie une fonction permettant de mettre à jour une variable qui ne lui appartient pas :

function makeCounter() {
  let count = 0; // lives inside makeCounter

  function increment() {
    count = count + 1;
    console.log(count);
  }

  return increment;
}

const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3

Étape par étape, voici ce qui se passe :

  • makeCounter() s’exécute une seule fois et déclare count.
  • Elle renvoie increment, qui fait référence à count, puis makeCounter se termine.
  • Chaque appel à counter() lit et met à jour cette même count, c’est pourquoi la valeur affichée passe de 1 à 3.

En général, les variables locales d’une fonction deviennent inaccessibles lorsqu’elle retourne, et le moteur peut alors les réutiliser. Ici, increment fait toujours référence à count, de sorte que la variable reste valide tant que increment existe. Si vous souhaitez connaître les règles sous-jacentes qui permettent au moteur de trouver count, l’article sur la manière dont la chaîne de portée résout les variables explique ce mécanisme.

Pourquoi l’état de React devient obsolète à l’intérieur des timers

React n’ajoute rien de spécial par rapport aux closures ; il s’appuie simplement sur eux partout, en particulier dans les hooks. Cette dépendance explique l’une des surprises les plus fréquentes dans le code des composants :

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setTimeout(() => {
      console.log("Count is: " + count);
    }, 3000);
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Add</button>
      <button onClick={handleClick}>Log count in 3 seconds</button>
    </div>
  );
}

Commencez avec count égal à 0. Cliquez sur « Enregistrer le compteur dans 3 secondes », puis appuyez sur « Ajouter » trois fois avant que le compte à rebours ne se déclenche. La console affiche 0, et non 3.

La raison en est que chaque affichage de Counter correspond à un appel de fonction nouveau. Chaque appel dispose de sa propre constante count et définit son propre handleClick. La fonction de rappel de setTimeout a été créée lors de l’affichage où count était égal à 0, et c’est le seul valeur de count qu’elle peut jamais connaître.

Ce modèle explique la plupart des questions du type « Pourquoi mon état est-il obsolète ? », et c’est là l’objectif du tableau de dépendances dans useEffect : il indique à React quels valeurs capturées l’effet dépend, afin que React puisse le relancer avec de nouvelles valeurs lorsque celles-ci changent. Lorsque vous avez réellement besoin de la valeur la plus récente à l’intérieur d’une fonction de rappel différée, les options habituelles sont une mise à jour fonctionnelle comme setCount(c => c + 1) pour les écritures, ou un ref que vous mettez à jour pour les lectures. Les compromis entre ces choix sont abordés dans snapshot versus live values in delayed callbacks.

Quatre patterns de création que vous rencontrerez dans du code réel

État encapsulé sans classes

Avant que les champs préfixés de # ne fassent partie du langage, les closures constituaient le moyen standard pour cacher des données, et ils restent aujourd’hui une solution légère. Tout ce qui est déclaré dans la fonction factory n’est accessible que par l’intermédiaire des fonctions qu’elle retourne :

function createBankAccount(balance) {
  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    }
  };
}

const account = createBankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined — can't touch it directly

balance n’est pas une propriété de l’objet retourné, donc account.balance vaut undefined. La seule façon de le modifier est via les fonctions deposit et withdraw.

Débouncing d’une zone de recherche

Le débouncing diffère l’exécution d’une action jusqu’à ce que l’entrée se soit arrêtée pendant une durée définie. C’est ce qui empêche une boîte de recherche d’envoyer une requête à chaque frappe :

function debounce(func, delay) {
  let timer; // remembered between calls
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => func(...args), delay);
  };
}

const searchInput = document.getElementById("search");
const handleSearch = debounce((e) => {
  fetch(`/api/search?q=${e.target.value}`);
}, 300);

searchInput.addEventListener("input", handleSearch);

Le détail crucial réside dans le lieu où timer est déclaré : une seule fois, à l’intérieur de debounce, en dehors de la fonction retournée. Chaque frappe appelle cette fonction, qui annule le délai en attente et en programme un nouveau, de sorte qu’une requête n’est envoyée que après 300 ms sans frappe. Si timer était déclaré à l’intérieur de la fonction retournée, chaque appel disposerait d’une variable nouvelle, le délai précédent ne pourrait jamais être annulé, et chaque frappe générerait toujours une requête.

Dans un composant React, la même logique s’applique avec une étape supplémentaire : la fonction déboussolée doit être créée une seule fois et rester stable (par exemple avec useMemo ou un ref), sinon chaque rendu crée une nouvelle fermeture avec un nouveau timer, et le déboussolage cesse silencieusement de fonctionner.

Un simple limiteur de requêtes

La même technique peut limiter la fréquence à laquelle un point de terminaison est servi :

function createRateLimiter(maxRequests, windowMs) {
  let requestCount = 0;
  setInterval(() => (requestCount = 0), windowMs);

  return function () {
    requestCount++;
    return requestCount <= maxRequests;
  };
}

const rateLimiter = createRateLimiter(5, 60000); // 5 requests per minute

app.get("/api/data", (req, res) => {
  if (!rateLimiter()) {
    return res.status(429).send("Too many requests");
  }
  res.send("Here's your data");
});

requestCount est partagé en privé par chaque appel à rateLimiter, et un intervalle le réinitialise à chaque fenêtre temporelle. Cette version est un compteur à fenêtre fixe stocké en mémoire, il convient donc de garder ses limites à l’esprit :

  • il compte tous les appels ensemble, de sorte qu’un seul client peut épuiser le quota destiné à tous ; les vrais limiteurs comptent selon l’utilisateur, le token ou l’IP
  • l’état se trouve dans un seul processus, ce qui permet à plusieurs instances serveur d’utiliser pleinement la limite
  • les fenêtres fixes autorisent des pics à la fin de la période, jusqu’à deux fois la limite en peu de temps
  • le setInterval n’est jamais supprimé, ce qui est important lors des tests ou lorsque les limiteurs sont créés dynamiquement

Histoire des actions annulées et réexécutées

Un gestionnaire d’historique conserve sa pile et son curseur en privé pour chaque instance :

function createHistoryManager() {
  let history = [];
  let currentIndex = -1;

  return {
    save(state) {
      history = history.slice(0, currentIndex + 1);
      history.push(state);
      currentIndex++;
    },
    undo() {
      if (currentIndex > 0) currentIndex--;
      return history[currentIndex];
    },
    redo() {
      if (currentIndex < history.length - 1) currentIndex++;
      return history[currentIndex];
    }
  };
}

save élimine d’abord tout ce qui se trouve après la position actuelle, de sorte que l’enregistrement d’un nouveau changement après un annulation supprime la branche de réexécution, ce qui correspond au comportement de la plupart des éditeurs. undo et redo se contentent de déplacer currentIndex dans les limites autorisées et de restituer l’état à ce moment-là. Notez que les états stockés doivent être des captures immuables ; si vous enregistrez un objet puis que vous le modifiez, chaque entrée de l’historique fait référence aux mêmes données modifiées.

Heuristiques rapides pour repérer les closures

  • Les fonctions internes utilisent celles qui les contiennent. Une fonction définie à l’intérieur d’une autre fonction qui fait référence aux variables de cette dernière fonction est un closure.
  • Il capture une liaison, et non un flux en temps réel des nouvelles rendus. En React, chaque rendu crée de nouvelles liaisons, de sorte qu’un ancien callback conserve les valeurs issues de son propre rendu. Stricto sensu, une fermeture retient la variable elle-même ; par conséquent, si cette même variable est réaffectée ultérieurement, comme c’est le cas pour count dans la factory de compteur, la fermeture perçoit la nouvelle valeur.
  • Une fonction retournée est un indice fort. Lorsqu’une fonction renvoie une autre fonction, une fermeture est presque toujours impliquée.
  • Le piège de var dans une boucle

    Les variables de boucle illustrent le plus clairement la règle des liaisons :

    for (var i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 1000);
    }
    // Logs: 3, 3, 3 — var is shared across the whole loop
    
    for (let i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 1000);
    }
    // Logs: 0, 1, 2 — let creates a new i on every iteration
    

    Avec var, il n’y a qu’une seule variable i pour toute la fonction, et au moment où les délais d’expiration s’exécutent, la boucle l’a déjà mise à 3, de sorte que les trois fonctions de rappel affichent toutes 3. Avec let, chaque itération dispose de sa propre version de i, et chaque fonction de rappel conserve une version différente, affichant ainsi 0, 1 et 2.

    En résumé

    Les closures sont le mécanisme à l’origine de l’état privé, des entrées différées, des limiteurs de requêtes, des piles d’annulation et des hooks personnalisés, ainsi que de nombreux bugs liés à un état obsolète. Deux questions résolvent la plupart des confusions : quelle variable cette fonction a-t-elle capturée, et cette variable a-t-elle été remplacée par une plus récente depuis ? Dans les factories JavaScript classiques, la variable capturée est partagée et mise à jour sur place. Dans React, chaque rendu génère de nouvelles variables ; par conséquent, tout callback qui survit à son rendu voit un état ancien, sauf si vous lui donnez délibérément un moyen de lire l’état actuel.