Retour au Centre de connaissances
Empreinte

WeakMap et WeakSet : garder et lâcher des clés

Une sonde sur des formes fixes : les clés qu’un WeakMap accepte, pourquoi rien ne se compte, ce qu’un Map fort garde en vie et ce qu’une collection faible ne supporte pas.

BotBrowser Team

Documentation

Vous voulez la documentation structurée pour Empreinte ?

Cet article fait partie de la bibliothèque éditoriale. Pour les étapes de configuration, la référence et les mises à jour continues, passez directement à la section docs.

BotBrowser prend en charge le rejeu d’une fixture same-origin autorisée sous un profil déclaré et la tenue d’un journal borné de ce qu’une page a stocké et du moment où elle l’a stocké, mais il ne peut pas énumérer le contenu d’une collection faible, ni lire une entrée dont la page ne détient plus la clé, ni rendre une entrée disparue à nouveau disponible comme valeur. Cette analyse exécute une sonde sur onze lignes mesurées et note les types de clés qu’un WeakMap accepte, ce que ses propres méthodes font d’une clé primitive, si un Map fort maintient la même clé en vie, ce qu’ajoutent WeakRef et FinalizationRegistry, et ce que fait une collection faible lorsqu’elle est sérialisée.

TL;DR

  • Un WeakMap accepte les objets, les fonctions, les tableaux et les symboles non enregistrés, et rejette null, les chaînes et les nombres avec une TypeError, si bien que la sonde affiche accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError.
  • Ni WeakMap ni WeakSet ne possède size, keys, values, entries ou forEach, et la sonde trouve donc none pour les deux.
  • Un Map fort et un WeakMap peuvent détenir la même clé en même temps, et abandonner la référence extérieure laisse l’entrée forte en place avec size=1.
  • delete puis get renvoie undefined, et un set ultérieur sur la même clé fonctionne, si bien qu’une entrée faible est remplaçable plutôt que consommée.
  • set lève une exception sur une clé primitive tandis que get, has et delete renvoient un raté, ce qui explique qu’un chemin de lecture puisse rester silencieux sur une mauvaise clé.
  • WeakRef.deref() renvoie la cible tant qu’elle est encore joignable, et après une seconde collecte demandée elle renvoie undefined, ce qui est un signal de collecte qui ne demande aucun registre.
  • FinalizationRegistry ajoute un rappel avec une valeur retenue, et un PerformanceObserver sur entryTypes: ['gc'] signale aussi chaque collecte, donc aucun des deux n’est le seul observateur.
  • Une clé enregistrée avec Symbol.for() est rejetée, donc la règle des symboles couvre Symbol() et les symboles connus, mais pas le registre.
  • Un WeakMap se sérialise en {} avec JSON.stringify et lève une DOMException avec structuredClone.

Sommaire

Quatre panneaux : le WeakMap accepte les clés objet, fonction, tableau et symbole mais rejette null, chaîne et nombre avec TypeError ; le deuxième montre l’absence de size et d’énumération ; le troisième montre un Map fort gardant une entrée après l’abandon de la référence extérieure ; le quatrième montre JSON.stringify produisant un objet vide et structuredClone levant une DOMException

Ce qu’une collection faible retient réellement

Un WeakMap conserve une association entre une clé et une valeur sans maintenir la clé en vie. La valeur n’est joignable que tant que la clé est joignable depuis un autre endroit, et dès que plus rien d’autre ne pointe vers la clé, l’entrée entière devient collectable. La sonde vérifie séparément les deux moitiés de cette phrase, car elles échouent de manières différentes.

node=v26.9.0
weakmap-keys -> accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError
no-enumeration -> weakmap=none weakset=none
same-key-both -> strong=1 weak=value
strong-still-has-entry -> size=1
delete-then-read -> undefined
reset-after-delete -> second
weakref-deref-before -> true
primitive-key-methods -> get=undefined has=false delete=false set=throws:TypeError
missing-object-key -> undefined
weakset-identity -> frozen=true copy=false
serialization -> json=ok:{} structuredClone=throws:DOMException

La première ligne concerne la clé, la deuxième la collection, et les autres ce qui se passe autour d’elles. Lire la sortie dans cet ordre est le moyen le plus rapide de garder les trois séparées, car les propriétés qu’on attend d’un Map se répartissent entre ces trois réponses et aucune ne tient de la façon dont un utilisateur de Map l’attend.

La conséquence pratique est qu’un WeakMap n’est pas un cache à taille limitée, et la question voisine de savoir quelles valeurs survivent à une copie est traitée par structuredClone face à JSON, ce que cette collection ne sait pas faire du tout. C’est un endroit où attacher des données à un objet que vous détenez déjà, avec la garantie que l’attachement disparaît quand l’objet disparaît. Une page qui stocke un état par élément dans un WeakMap plutôt que dans une propriété de l’élément n’a aucun code de nettoyage à écrire, et une page qui stocke un état par requête dans un WeakMap indexé par un objet de requête obtient le même résultat. Ce qu’elle n’obtient pas, c’est un moyen de demander combien d’éléments sont stockés, d’où les deux sections suivantes sur ce que l’on peut et ne peut pas observer.

Les clés qu’un WeakMap accepte

La clé doit être une valeur que le moteur d’exécution peut détenir faiblement. La sonde essaie sept types de clés et note ceux qui survivent à set :

weakmap-keys -> accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError

Les objets, les fonctions et les tableaux sont les cas évidents. Les symboles sont le cas intéressant, car le langage a changé : un symbole non enregistré est devenu une clé faible valide afin qu’un enregistrement indexé par symbole puisse être collecté comme un enregistrement indexé par objet. La sonde le confirme sur ce moteur d’exécution, et un lecteur sur un moteur plus ancien devrait considérer la ligne des symboles comme dépendante de la version plutôt qu’universelle. L’exception est le registre : une sonde séparée place Symbol(), Symbol.for() et Symbol.iterator comme clés et imprime Symbol()=accepted, Symbol.for()=TypeError et Symbol.iterator=accepted, donc un symbole enregistré n’est pas une clé faible alors qu’un symbole non enregistré l’est.

null est rejeté parce que ce n’est pas un objet, et les chaînes et les nombres sont rejetés pour la même raison : ce sont des primitives, et une primitive n’a pas d’identité qu’un collecteur pourrait observer. Un appelant qui indexe un WeakMap par un identifiant sous forme de chaîne construit donc quelque chose qui ne peut pas fonctionner, et l’échec survient au moment du set plutôt que plus tard, ce qui est la moitié la plus clémente du problème.

La moitié moins clémente est que les méthodes de lecture n’échouent pas de la même façon. La sonde appelle chaque méthode avec la chaîne text :

primitive-key-methods -> get=undefined has=false delete=false set=throws:TypeError

Seul set lève une exception. get renvoie undefined, has renvoie false et delete renvoie false, soit exactement les mêmes réponses qu’une clé valide mais absente. Un chemin de lecture qui accepte un identifiant fourni par un appelant ne peut donc pas distinguer « cette clé est invalide » de « cette clé n’a pas d’entrée », et le chemin d’écriture est le seul endroit où l’erreur devient visible. Un utilitaire qui normalise chaque identifiant en objet avant de toucher la table comble cet écart, et un utilitaire qui ne le fait pas laisse un raté silencieux en production.

Un objet gelé reste une clé valide, et l’appartenance se décide par identité plutôt que par forme. La sonde enregistre un objet gelé puis interroge un autre objet au même contenu :

weakset-identity -> frozen=true copy=false

L’original gelé est membre et la copie ne l’est pas, même si les deux se sérialisent à l’identique. C’est la même règle que suivent Map et Set, et il vaut la peine de l’énoncer, car un appelant qui indexe par « l’objet d’identifiant 1 » plutôt que par « cet objet » obtient un raté qui ressemble à un défaut de la collection.

Pourquoi rien ne peut être compté ni listé

Une collection faible ne peut pas rendre compte de son propre contenu, car le rendre compte serait précisément ce qui le maintient en vie. La sonde demande à chaque collection si elle possède les méthodes qu’un utilisateur de Map ou de Set chercherait :

no-enumeration -> weakmap=none weakset=none

Aucune des deux collections ne possède size, keys, values, entries ou forEach. Ce n’est pas une fonctionnalité manquante, c’est la définition. Une propriété size devrait compter les entrées que le collecteur est libre d’abandonner, si bien que le nombre changerait sans qu’aucun code ne s’exécute, et un itérateur keys() remettrait à l’appelant une référence forte vers chaque clé qu’il produit.

Le coût pratique est qu’une page ne peut pas annoncer « cette table faible contient douze entrées » dans un diagnostic. Ce qu’elle peut annoncer, c’est le nombre d’objets qu’elle détient encore fortement, un nombre différent et souvent plus utile : si la page conserve une liste des éléments auxquels elle a attaché des données, cette liste est le côté fort, et le côté faible ne peut être déduit qu’à partir d’elle.

Le coût apparaît aussi dans les tests. Un test qui veut prouver qu’une entrée a été collectée ne peut pas faire d’assertion sur la collection, car il n’y a rien sur quoi la faire. Il doit faire porter l’assertion sur la moitié observable, c’est-à-dire WeakRef.deref() qui renvoie undefined, ce qui exige que le collecteur se soit exécuté. La section suivante traite de la paire d’API qui existe exactement pour cela, et celle d’après traite de la seule chose qu’un test peut vérifier sans collection du tout.

Un troisième coût apparaît lors d’un audit plutôt que dans le code. Un relecteur qui veut savoir si une page fuit doit raisonner sur le seul côté fort, car le côté faible ne peut pas être énuméré. La question « combien d’entrées cette table contient-elle » n’a pas de réponse, et celle qui en a une est « combien d’objets la page détient-elle encore qui pourraient servir de clés ». Une page qui tient un registre de panneaux ouverts, une liste de requêtes en attente ou un tableau d’éléments montés détient exactement autant de clés potentielles, et les entrées faibles qui y sont attachées vivent aussi longtemps que ces listes. Lire un profil mémoire consiste donc à trouver les listes fortes, pas à trouver les tables faibles.

Ce qu’une référence forte fait à la même clé

Une entrée faible ne disparaît que lorsque plus rien d’autre ne détient la clé. La sonde place la même clé dans un Map fort et dans un WeakMap en même temps, puis abandonne la référence extérieure :

same-key-both -> strong=1 weak=value
strong-still-has-entry -> size=1

La recherche faible répond encore tant que la variable détient la clé, ce qui est la moitié attendue. La deuxième ligne est celle qui compte : après la réaffectation de la variable, la table forte signale toujours une entrée. La table faible n’a pas oublié la valeur non plus, parce que la table forte détient encore la clé, donc lire la table faible à travers cette clé renvoie toujours value. C’est le côté fort qui maintient la paire en vie, et le côté faible n’a fait que suivre.

Cette interaction est la façon la plus courante dont une page défait accidentellement sa propre collection faible. Un module qui stocke des métadonnées dans un WeakMap et pousse aussi chaque clé dans un tableau pour un panneau de débogage a rendu chaque entrée joignable en permanence, et le panneau de débogage est la raison pour laquelle rien n’est jamais collecté. Le tableau n’a pas besoin d’être utilisé ; il suffit qu’il existe et soit joignable depuis une portée active.

L’erreur inverse consiste à croire que delete est le moyen de faire disparaître une entrée. La sonde supprime une entrée puis réutilise la même clé :

delete-then-read -> undefined
reset-after-delete -> second

delete retire l’association et renvoie un booléen, et la clé elle-même n’est pas touchée, si bien qu’un set ultérieur sur la même clé fonctionne normalement. Un appelant qui attend qu’une clé supprimée devienne inutilisable, comme pourrait l’être un jeton à usage unique, sera surpris : la collection oublie la valeur, pas la clé.

La troisième erreur est d’attendre d’une collection faible qu’elle borne la mémoire. Elle ne borne rien, car elle n’a pas de taille à borner. Une page qui fuit de la mémoire à travers un WeakMap fuit à travers les valeurs, pas les clés : une valeur qui pointe vers sa propre clé, ou vers un grand tampon, maintient ce tampon en vie aussi longtemps que la clé vit. La sonde ne peut pas le voir, car elle ne détient aucun cycle de ce genre, et c’est pourquoi une revue mémoire doit examiner les valeurs aussi attentivement que les clés.

Une quatrième erreur est plus subtile et apparaît dans les applications monopage. Une page qui indexe un WeakMap par un objet de route crée une nouvelle clé à chaque navigation, si bien que la table accumule une entrée par navigation qui n’a aucune autre référence, et le collecteur est libre de les abandonner toutes. C’est exactement le comportement voulu, et c’est aussi pourquoi un compteur construit sur la même table ne diminue jamais : le compteur est le côté fort. Une page qui veut mesurer le renouvellement des navigations doit compter les navigations, pas les entrées.

Le dernier point structurel est qu’une clé faible peut être un proxy, un objet inter-royaumes ou un enregistrement gelé, et les trois se comportent de la même façon pour la rétention. Le collecteur suit la joignabilité, pas la forme, si bien que la seule chose qu’une page peut faire pour changer le résultat est d’ajouter ou de retirer une référence forte ailleurs. La collection faible est donc une suiveuse : son comportement est entièrement déterminé par du code qui ne la mentionne jamais.

Ce qu’ajoutent WeakRef et FinalizationRegistry

WeakRef et FinalizationRegistry constituent la moitié observable de la rétention faible, et la sonde utilise les deux :

weakref-deref-before -> true

new WeakRef(target) ne maintient pas target en vie, et deref() renvoie la cible tant que quelque chose d’autre la détient. La sonde vérifie d’abord le cas positif. Le cas négatif ne se produit qu’après l’exécution du collecteur, et un script peut demander cette exécution : v8.setFlagsFromString('--expose-gc') suivi de vm.runInNewContext('gc') produit une fonction gc qui marche dans un processus Node ordinaire où globalThis.gc vaut undefined.

Une exécution demandée ne suffit toujours pas pour voir undefined dans la même tâche, et une seule exécution demandée ne suffit pas non plus d’une tâche à l’autre. Une seconde sonde abandonne trois cibles, appelle gc, lit chaque référence aussitôt, puis rappelle gc un tour plus tard et relit :

deref-shape -> same_job=true after_second_gc=false | same_job=true after_second_gc=false | same_job=true after_second_gc=false

Toutes les références répondaient encore dans la tâche qui a demandé la collecte, et toutes ont répondu undefined seulement après la seconde exécution demandée. La première exécution marque les objets comme collectables ; la référence répond encore jusqu’à ce qu’une collecte ultérieure les récupère vraiment, donc un test qui affirme undefined juste après un appel à gc échoue pour une raison qui n’a rien à voir avec la rétention. La même sonde montre un rappel de FinalizationRegistry qui se déclenche après l’exécution demandée, ce qui explique qu’un rappel de registre et une vérification de deref() aient tous deux leur place dans un test qui veut prouver la collecte.

FinalizationRegistry est la forme avec rappel : la page enregistre une cible avec une valeur retenue, et le registre rappelle avec cette valeur retenue après la collecte de la cible. Le rappel n’est pas une promesse et ne s’exécute pas selon un calendrier que la page contrôle. Il peut s’exécuter bien plus tard, ou pas du tout avant le déchargement de la page, si bien qu’une page qui en dépend pour sa correction souffre d’un défaut de correction plutôt que de performance.

La paire est utile pour le diagnostic, pas pour le nettoyage, et l’ordre dans lequel un callback de diagnostic finit par s’exécuter est une question de planification traitée par microtâches et minuteries. Une page qui veut savoir si son cache d’éléments rétrécit réellement peut enregistrer chaque clé avec un compteur et journaliser le rappel, ce qui change « je crois que c’est collecté » en un nombre. Une page qui veut libérer une ressource utilise un close, un dispose ou un AbortController explicite, car ceux-ci s’exécutent quand la page le dit plutôt que quand le collecteur en décide.

Il y a une asymétrie supplémentaire à noter. Un WeakRef peut être créé pour n’importe quel objet, y compris un objet que la page détient encore fortement, et la référence n’ajoute aucune rétention par elle-même. Une page qui crée un WeakRef par élément et conserve les références dans un tableau a conservé un tableau de petits objets, ce qui est généralement acceptable, mais le tableau lui-même est fort et grandit avec le nombre d’éléments que la page a vus. La référence unique de la sonde masque cela, et la revue d’une page réelle devrait compter les références, pas seulement les cibles.

Ce à quoi une collection faible ne peut pas survivre

Une collection faible n’est pas une donnée. Elle ne peut être copiée, journalisée ni envoyée, et la sonde montre les deux échecs :

serialization -> json=ok:{} structuredClone=throws:DOMException

JSON.stringify ne lève pas d’exception ; il produit {}, car un WeakMap n’a aucune propriété énumérable propre. C’est le plus dangereux des deux, car la sortie est du JSON valide et un appelant qui la journalise voit un objet vide plutôt qu’une erreur. Un enregistrement qui affirme « le cache était vide » peut donc être produit par un cache plein, et rien dans la sortie ne distingue les deux.

structuredClone lève à la place une DOMException, ce qui est la réponse honnête : une collection faible n’a pas de sémantique de clonage, car un clone devrait décider si la copie est faible de la même manière. Une page qui fait passer un WeakMap par une frontière de worker ou par postMessage obtient cette exception plutôt qu’un objet vide silencieux, et c’est le mode d’échec pour lequel il vaut la peine de concevoir.

La même limite s’applique à un objet simple contenant un WeakMap. JSON.stringify parcourt les propriétés propres, trouve la collection faible et produit {} pour ce champ tandis que le reste de l’objet se sérialise normalement. La collection unique de la sonde est le cas simple ; une charge utile réelle a généralement la collection imbriquée, et le champ vide est facile à manquer dans une longue ligne de journal.

Consigner une lecture de rétention

Une lecture de rétention étaye une affirmation étroite : ce moteur d’exécution, ces types de clés et cette structure de références ont produit ces observations. Cinq champs la rendent vérifiable :

  1. Le moteur d’exécution, car l’ensemble des types de clés acceptés a changé quand les symboles sont devenus des clés faibles valides.
  2. Les types de clés essayés, car une TypeError au moment du set et un raté silencieux au moment du get sont des résultats différents.
  3. Les références fortes que la page détient encore, car une seule d’entre elles maintient toutes les entrées en vie.
  4. Si l’exécution du collecteur a été demandée, car un deref() qui renvoie une valeur ne prouve rien sur la collecte.
  5. Le chemin de sérialisation, car JSON.stringify renvoie un objet vide tandis que structuredClone lève une exception.

Sources publiques

Conclusion pratique

Utilisez une collection faible pour attacher des données à un objet que vous détenez déjà, et n’en attendez rien d’autre : ni taille, ni énumération, ni sérialisation, ni nettoyage qui s’exécute selon votre calendrier. Normalisez les clés en objets avant qu’elles n’atteignent la table afin qu’une clé invalide échoue à l’écriture plutôt que de disparaître à la lecture. Auditez le côté fort de la page, car un seul tableau retenu défait chaque entrée faible qu’il liste. Lorsque vous avez besoin de libérer une ressource, appelez un chemin de libération explicite, et réservez WeakRef et FinalizationRegistry au diagnostic. Pour savoir ce qui survit à une copie des données que vous cherchiez à stocker, lisez structuredClone face à JSON ; pour l’ordre d’exécution des rappels qui touchent ces entrées, lisez microtâches et minuteries.

#WeakMap#WeakSet#WeakRef#FinalizationRegistry#structuredClone

Cet article fait partie du guide : Qu'est-ce qu'une empreinte de navigateur ?

Faites passer BotBrowser de la recherche à la production

Utilisez ces guides pour comprendre le modèle, puis passez à la validation multi-plateforme, aux contextes isolés et au déploiement navigateur prêt pour l'échelle.