Accueil / Articles

Comment les contextes de fermeture V8 conservent la mémoire au-delà de ce que consomme une fonction

V8 crée un contexte partagé par appel pour chaque variable externe mentionnée par une fonction interne ; ainsi, les variables non utilisées et les écouteurs permanents peuvent conserver des valeurs importantes.

Éditeur : REACTAPP.TOP8 min de lecture1849 mots

Sources: Interviewer: You have 5 years of experience and still don’t know how closures work internally

Vérifiez plutôt que de le réciter : [[Scopes]]

Lors d’un entretien frontend chez Swiggy, un bref extrait DOM a été affiché. Le candidat, doté de nombreuses années d’expérience en JavaScript, a répondu à la première question en utilisant l’exemple classique. La question suivante a mis ce scénario à mal, comme cela arrive pour la plupart des développeurs en poste.

Voici l’extrait de départ :

function update() {
  const elem = document.getElementById("root");
  const allH1s = document.querySelectorAll("h1");

  function handleClick() {
    console.dir(handleClick);
  }

  elem.addEventListener("click", handleClick);
}

update();

Après le retour de update(), l’écouteur d’événement de clic reste attaché à #root. Le fonc tion handleClick fait-il référence à allH1s, permettant ainsi au NodeList entier de rester actif ?

La réponse attendue dans le manuel est oui : une fermeture conserve son environnement lexical, de sorte que tout ce qui se trouve dans le champ d’application de update — y compris elem et allH1s — reste accessible tant que l’écouteur est attaché. Cette réponse décrit davantage le langage de spécification que ce que Chrome conserve réellement. L’intervieweur a demandé d’ouvrir les outils de développement pour vérifier.

Chaque objet fonction dans Chrome expose une liste interne [[Scopes]] indiquant les champs d’application que la fermeture conserve encore. console.dir permet de la visualiser. Le gestionnaire enregistre ses propres informations à chaque clic, ce qui vous permet de :

  1. Ouvrir une page contenant <h1 id="root"> (ou en créer une), coller le fragment de code et l’exécuter.
  2. Clicquer sur le titre afin que console.dir(handleClick) affiche la fonction.
  3. Développer [[Scopes]], puis Closure (update).

Ce qui apparaît n’est ni allH1s ni elem. La seule liaison externe conservée est handleClick lui-même, et ce uniquement parce que le corps du code fait référence à ce nom dans console.dir(handleClick). Si l’on change le corps du code en console.log("clicked"), l’entrée Closure (update) disparaît ; [[Scopes]] ne montre alors que Global.

Ainsi, la NodeList n’est pas retenue par le gestionnaire d’événement. Une fois que update() a terminé, rien ne la désigne plus et le collecteur peut la récupérer. La vue de l’inspecteur ne constitue pas une preuve de présence en mémoire — un nettoyage manuel ultérieur le confirme — mais elle contredit déjà l’affirmation selon laquelle « tout le scope est capturé ».

Le candidat est alors passé à l’autre modèle courant : les closures ne conservent que les variables qu’elles mentionnent, et handleClick ne cite jamais allH1s. L’intervieweur a demandé une ligne supplémentaire sans modifier handleClick.

Une ligne qui ne s’exécute jamais

function update() {
  const elem = document.getElementById("root");
  const allH1s = document.querySelectorAll("h1");
  const printH1 = () => allH1s;   // never called

  function handleClick() {
    console.dir(handleClick);   // unchanged
  }

  elem.addEventListener("click", handleClick);
}

update();

printH1 est une fonction flèche inutilisée qui accède à allH1s. Rien ne l’appelle, ne la renvoie ni ne la stocke. Exécutez les mêmes étapes d’inspection après l’avoir ajoutée.

handleClick reste inchangé, caractère par caractère, mais il affiche désormais un NodeList qu’il ne lit jamais. La fonction flèche elle-même fait défaut dans [[Scopes]] — rien n’a capturé printH1, ce qui a entraîné la disparition de cette liaison avec l’appel — mais la variable qu’elle mentionnait a survécu à l’intérieur du contexte partagé.

« Toute la portée » échoue dans le premier cas. « Seulement ce que cette fonction utilise » échoue dans le deuxième. La règle de V8 concerne l’appel, et non chaque fonction isolément.

La règle que V8 suit réellement

Lorsque V8 compile update, il examine chaque fonction interne et regroupe les variables externes que l’une d’elles référence. Ce groupe devient un objet de contexte alloué en mémoire heap, créé une fois par invocation. Les variables que aucune fonction interne ne mentionne restent sur la pile et disparaissent lorsque update retourne. Chaque fermeture générée pendant cette invocation pointe vers le même contexte. Il n’existe pas de sac privé « mes captures » par fonction.

Application de la règle :

  • Cas 1 : seule handleClick existe parmi les fonctions internes ; elle fait référence à handleClick. Contexte = { handleClick }. elem et allH1s restent locaux à la pile et disparaissent.
  • Cas 2 : les fonctions internes sont printH1 et handleClick. L’union comprend allH1s et handleClick. Le contexte = { allH1s, handleClick }. Rien ne fait référence à printH1, donc ce lien disparaît — mais la case de contexte qu’il a forcée reste occupée par le même objet que handleClick.
  • Deux conséquences de cette règle provoquent des fuites réelles.

    Cela est déterminé en lisant le code, avant que quoi que ce soit ne s’exécute

    La structure du contexte est fixée au moment de la parsing. Le moteur n’attend pas pour voir si printH1 sera jamais utilisé. Même en le plaçant dans une branche impossible, allH1s est quand même promu :

    if (false) {
      const printH1 = () => allH1s;   // never even instantiated
    }
    // [[Scopes]] of handleClick: Closure (update) { allH1s: NodeList(3), handleClick }
    

    L’exécution de cette variante sous Chrome 153 via le protocole DevTools, suivie d’une forceful garbage collection, ne fait pas disparaître le NodeList. Assigner printH1 = null après sa création ne change rien : l’outil d’aide lui-même n’a jamais verrouillé de mémoire — c’est le slot de contexte partagé qui l’a fait.

    Le maintien en mémoire dépend du plus ancien closure frère

    C’est pourquoi la question du recruteur portait sur la mémoire. Suivez les verrous en place :

    [ <h1 id="root"> ]   (still in the document)
          ↓ event listener list holds
    [ handleClick ]   (the function object)
          ↓ [[Context]] points to the ONE context for this call
    [ { allH1s, handleClick } ]   (the shared context)
          ↓ allH1s slot holds
    [ NodeList(3) [h1#root, h1, h1] ]
          meanwhile
    [ printH1 ]   <- gone, it was on the stack
    

    printH1 a disparu. La promotion qu’il a provoquée — allH1s restant dans le contexte — survit à sa disparition. handleClick verrouille le contexte ; la liste des écouteurs de l’élément verrouille handleClick ; le document verrouille l’élément. Tant que le nœud n’est pas supprimé ou que l’écouteur n’est pas détaché, le NodeList ainsi que les nœuds h1 qu’il référence restent accessibles.

    Prouvez que c’est de la mémoire, et non un artefact de l’inspecteur

    [[Scopes]] affiche les références. Seule la collecte de déchets montre le maintien en mémoire des objets. Chrome peut forcer une collecte ; WeakRef observe un objet sans le conserver. Collez :

    let ref;
    function update() {
      const elem = document.getElementById("root");
      const allH1s = document.querySelectorAll("h1");
      ref = new WeakRef(allH1s);            // observe, don't retain
      const printH1 = () => allH1s;         // the one line
      function handleClick() {
        console.dir(handleClick);
      }
      elem.addEventListener("click", handleClick);
    }
    update();
    

    Ouvrez le panneau Mémoire, cliquez sur « Collect garbage » plusieurs fois, puis vérifiez la console :

    > ref.deref()
    ▶ NodeList(3) [h1#root, h1, h1]
    // still here after two forced GCs
    

    Supprimez la ligne printH1, rechargez le site, et répétez l’opération :

    > ref.deref()
    undefined
    // collected — nothing was holding it
    

    Ces deux résultats diffèrent par une flèche non utilisée : soit le NodeList est détruit lorsque update renvoie, soit il reste en mémoire tant que le gestionnaire de clic est actif.

    Les outils de développement permettent également de conserver en mémoire les objets que vous avez affichés. Si vous avez cliqué avant d’forcer le nettoyage des objets inutilisés, console.dir(handleClick) a été exécuté et la console a conservé la fonction affichée — ainsi que son contexte — même après que l’écouteur { once: true } a été désactivé. Il est essentiel de vider la console (ou d’éviter d’afficher les éléments suspects) avant de se fier à une capture de l’espace mémoire lorsqu’on cherche des fuites réelles.

    Où cela pose problème dans du code réel

    Trois titres ne représentent qu’un exemple simplifié. Remplacez allH1s par un gros envoi de données, un fichier traité ou un tableau de un million de lignes. Remplacez printH1 par un outil d’aide au débogage, un formatteur inutilisé ou une fonction de rappel destinée à être utilisée plus tard. Ajoutez un écouteur longue durée — comme resize sur window, un gestionnaire de messages de socket ou une fonction setInterval.

    function setup() {
      const rows = new Array(1e6).fill(0).map((_, i) => ({ i }));  // the big thing
      const debug = () => rows.length;        // mentions it, never called
    
      function onResize() {
        console.dir(onResize);              // never touches rows
      }
      window.addEventListener("resize", onResize);
    }
    setup();
    

    Après le retour de setup() et l’exécution du GC, [[Scopes]] de onResize affiche toujours le tableau de un million d’éléments, et un WeakRef y associé reste résolvable. Le gestionnaire de redimensionnement bloque en mémoire une valeur qu’il ne nomme jamais. Les analyses manquent ce schéma car la fonction en question semble inoffensive, et le élément qui a provoqué cette promotion a pu être ajouté bien plus tard.

    La solution

    Chaque option ci-dessous a été testée de la même manière : [[Scopes]], exécution forcée du GC, puis WeakRef.deref().

    1. Éviter les fonctions imbriquées qui nomment la grande valeur externe

    Captures :

    const printH1 = () => allH1s;
    

    Ne pas faire :

    const printH1 = (list) => list;
    

    Transférer la valeur en paramètre permet d’éviter une référence externe, de sorte qu’aucun emplacement de contexte n’apparaît. Supprimer les outils non utilisés est encore mieux.

    2. Détacher l’écouteur une fois la tâche terminée

    Le contexte n’existe que tant que handleClick est actif. Si cette liaison est rompue :

    // any one of these frees the NodeList on the next GC
    elem.removeEventListener("click", handleClick);
    elem.addEventListener("click", handleClick, { once: true });
    elem.addEventListener("click", handleClick, { signal: controller.signal });
    controller.abort();
    

    En supprimant #root du document, on le libère également. Ce type de fuite privilégie des cibles durables telles que window, document, les sockets et des racines à longue durée de vie, car rien ne supprime habituellement l’écouteur.

    3. Lorsqu’un élément frère doit conserver l’accès, mettre à null le lien externe par la suite

    let allH1s = document.querySelectorAll("h1");
    const printH1 = () => allH1s;
    // ...use it...
    allH1s = null;   // the context slot still exists, but now holds null
    

    Après avoir mis à null, [[Scopes]] continue d’énumérer allH1s, mais la valeur est null et le NodeList peut être collecté. C’est l’option la moins élégante, mais utile lorsque la restructuration de l’élément frère n’est pas possible pour le moment.

    S’agit-il de la spécification, ou de V8 ?

    V8. Selon ECMAScript, une fermeture capture son Enregistrement d’Environnement et ne précise rien concernant la stratégie de collecte des déchets. Un moteur conforme pourrait conserver toutes les liaisons en portée tout au long de la durée de vie d’une fermeture ; un autre pourrait être plus strict que V8. L’optimisation de V8 consiste à ne retenir que les variables mentionnées par une fonction interne. C’est pourquoi l’expression « toute la portée » correspond à la formulation de la spécification et non à ce que Chrome, Edge, Node ou Deno conservent en pratique.

    Ne vous fiez à aucun de ces comportements. Conservez les valeurs volumineuses dans des portées restreintes, écrivez des outils qui prennent des arguments au lieu de se référer aux fonctions externes, et fournissez à chaque écouteur à longue durée de vie un point de désactivation bien documenté.

    Ce que l’intervieweur testait

    Ce n’est pas la définition du dictionnaire d’une fermeture. Ce qui importait, c’était de comprendre que le captage est limité à une invocation, et non à chaque fonction imbriquée : les fonctions issues d’une même appel partagent un seul contexte, dont la structure est fixée lors de l’analyse syntaxique ; le stockage des informations suit la fonction la plus longue à exister, et un ingénieur compétent utilise DevTools plutôt que de se référer à un schéma.

    Points clés

    • Les fermetures ne conservent ni l’ensemble des scopes ni uniquement les noms qu’elles référencent. Elles partagent un seul contexte par appel, contenant toutes les variables externes mentionnées par toute fonction interne.
    • Cet ensemble de variables est déterminé au moment de l’analyse syntaxique. Les fonctions internes inutilisées ou les helpers à valeur nulle continuent de faire figurer les noms qu’elles mentionnent.
    • Le contexte persiste aussi longtemps que la fermeture la plus longue à exister. Un écouteur permanent rend tout le contexte permanent.
  • Vérifiez les références avec console.dir(fn) → [[Scopes]], ainsi que le maintien en mémoire grâce à WeakRef combiné à Memory → Collect garbage. Videz d’abord la console afin que DevTools ne soit pas le responsable du maintien en mémoire.
  • Les entretiens axés sur les boucles récompensent ceux qui savent relier le comportement du moteur à l’identification des fuites mémoire. Savoir décrire les Enregistrements d’environnement est moins utile que de connaître quel panneau ouvrir, quel icône déclenche la collecte des données, et comment l’affichage dans la console peut lui-même maintenir un graphe suspect actif. Considérez [[Scopes]] comme une carte des références, et WeakRef combiné à une collecte forcée des données comme la preuve du maintien en mémoire ; rediseignez ensuite le processus de manière à ce que la réponse ne dépende plus des détails de l’optimiseur.