WeakMap y WeakSet: qué guardan y qué sueltan
Una sonda sobre formas fijas: qué claves acepta un WeakMap, por qué nada se puede contar, qué mantiene vivo un Map fuerte y qué no sobrevive en una colección débil.
BotBrowser Team
Quieres la documentación estructurada de Huella digital?
Este artículo forma parte de la biblioteca editorial. Para pasos de configuración, material de referencia y actualizaciones continuas, entra en la sección de docs.
BotBrowser soporta repetir un fixture autorizado del mismo origen bajo un perfil declarado y conservar un registro acotado de qué guardó una página y cuándo lo guardó, pero no puede enumerar el contenido de una colección débil, no puede leer una entrada cuya clave la página ya no mantiene y no puede convertir una entrada descartada de nuevo en un valor. Esta revisión ejecuta una sonda sobre once líneas medidas y registra qué tipos de clave acepta un WeakMap, qué hacen sus propios métodos con una clave primitiva, si un Map fuerte mantiene viva la misma clave, qué añaden WeakRef y FinalizationRegistry, y qué hace una colección débil cuando se serializa.
Resumen
- Un
WeakMapacepta objetos, funciones, arreglos y símbolos no registrados, y rechazanull, cadenas y números con unTypeError, así que la sonda imprimeaccepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError. - Ni
WeakMapniWeakSettienensize,keys,values,entriesniforEach, así que la sonda encuentranoneen ambos. - Un
Mapfuerte y unWeakMappueden contener la misma clave a la vez, y al soltar la referencia externa la entrada fuerte permanece consize=1. deleteseguido degetdevuelveundefined, y unsetposterior sobre la misma clave funciona, así que una entrada débil es reemplazable en lugar de consumida.setlanza con una clave primitiva mientrasget,hasydeletedevuelven un fallo, y por eso una ruta de lectura puede callar ante una clave inválida.WeakRef.deref()devuelve el destino mientras siga siendo alcanzable, y tras una segunda recolección solicitada devuelveundefined, que es una señal de recolección que no necesita registro.FinalizationRegistryañade una retrollamada con un valor retenido, y unPerformanceObserversobreentryTypes: ['gc']también informa de cada recolección, así que ninguno de los dos es el único observador.- Una clave registrada con
Symbol.for()se rechaza, así que la regla de los símbolos cubreSymbol()y los símbolos conocidos, pero no el registro. - Un
WeakMapse serializa a{}medianteJSON.stringifyy lanza unDOMExceptionmediantestructuredClone.
Contenido
- Qué guarda realmente una colección débil
- Qué claves acepta un WeakMap
- Por qué no se puede contar ni listar nada
- Qué le hace una referencia fuerte a la misma clave
- Qué aportan WeakRef y FinalizationRegistry
- Qué no sobrevive una colección débil
- Registra una lectura de retención
Qué guarda realmente una colección débil
Un WeakMap guarda una asociación entre una clave y un valor sin mantener viva la clave. El valor es alcanzable solo mientras la clave sea alcanzable desde algún otro lugar, y en cuanto nada más apunta a la clave, la entrada completa pasa a ser recolectable. La sonda comprueba las dos mitades de esa frase por separado, porque fallan de maneras distintas.
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 primera línea trata de la clave, la segunda de la colección y el resto de lo que ocurre a su alrededor. Leer la salida en ese orden es la forma más rápida de mantener las tres cosas separadas, porque las propiedades que la gente espera de un Map quedan repartidas entre las tres respuestas y ninguna se cumple como esperaría quien usa un Map.
La consecuencia práctica es que un WeakMap no es una caché con un límite de tamaño, y la pregunta vecina de qué valores sobreviven a una copia la responde structuredClone frente a JSON, algo que esta colección no puede hacer en absoluto. Es un lugar donde adjuntar datos a un objeto que ya se tiene, con la garantía de que lo adjunto desaparece cuando desaparece el objeto. Una página que guarda estado por elemento en un WeakMap en lugar de una propiedad del elemento no necesita código de limpieza, y una página que guarda estado por petición en un WeakMap con clave de objeto de petición obtiene lo mismo. Lo que no obtiene es ninguna forma de preguntar cuánto hay guardado, y por eso las dos secciones siguientes tratan sobre lo que se puede y lo que no se puede observar.
Qué claves acepta un WeakMap
La clave tiene que ser algo que el runtime pueda mantener de forma débil. La sonda prueba siete tipos de clave y registra cuáles sobreviven a set:
weakmap-keys -> accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError
Los objetos, las funciones y los arreglos son los casos obvios. Los símbolos son el caso interesante, porque el lenguaje cambió: un símbolo no registrado pasó a ser una clave débil válida para que un registro con clave de símbolo pudiera recolectarse igual que uno con clave de objeto. La sonda lo confirma en este runtime, y quien lea esto en un motor más antiguo debería tratar la fila de los símbolos como dependiente de la versión en lugar de universal. La excepción es el registro: una sonda aparte pone Symbol(), Symbol.for() y Symbol.iterator como claves e imprime Symbol()=accepted, Symbol.for()=TypeError y Symbol.iterator=accepted, así que un símbolo registrado no es una clave débil aunque uno no registrado sí lo sea.
null se rechaza porque no es un objeto, y las cadenas y los números se rechazan por la misma razón: son primitivas, y una primitiva no tiene una identidad que un recolector pueda observar. Quien use una cadena como identificador de clave en un WeakMap está construyendo algo que no puede funcionar, y el fallo llega en el momento de set en lugar de más tarde, que es la mitad amable del problema.
La mitad poco amable es que los métodos de lectura no fallan de la misma manera. La sonda llama a cada método con la cadena text:
primitive-key-methods -> get=undefined has=false delete=false set=throws:TypeError
Solo set lanza. get devuelve undefined, has devuelve false y delete devuelve false, y todas son las mismas respuestas que produce una clave válida pero ausente. Una ruta de lectura que acepta un identificador de quien llama no puede distinguir entre "esta clave es inválida" y "esta clave no tiene entrada", y la ruta de escritura es el único lugar donde el error se vuelve visible. Un auxiliar que normaliza cada identificador a un objeto antes de tocar el mapa cierra esa brecha, y un auxiliar que no lo hace deja un fallo silencioso en producción.
Un objeto congelado sigue siendo una clave válida, y la pertenencia se decide por identidad y no por forma. La sonda registra un objeto congelado y luego pregunta por un objeto distinto con el mismo contenido:
weakset-identity -> frozen=true copy=false
El original congelado es miembro y la copia no lo es, aunque los dos se serialicen de forma idéntica. Es la misma regla que siguen Map y Set, y vale la pena decirlo porque quien usa como clave "el objeto con identificador 1" en lugar de "este objeto" obtiene un fallo que parece un error de la colección.
Por qué no se puede contar ni listar nada
Una colección débil no puede informar de su propio contenido, porque informarlo sería justamente lo que lo mantiene vivo. La sonda pregunta a cada colección si tiene los métodos que buscaría quien usa un Map o un Set:
no-enumeration -> weakmap=none weakset=none
Ninguna de las dos colecciones tiene size, keys, values, entries ni forEach. No es una función que falte; es la definición. Una propiedad size tendría que contar las entradas que el recolector puede descartar, así que el número cambiaría sin que se ejecute código alguno, y un iterador keys() entregaría a quien llama una referencia fuerte a cada clave que produjera.
El costo práctico es que una página no puede informar "este mapa débil contiene doce entradas" en un diagnóstico. Lo que sí puede informar es el recuento de los objetos que todavía mantiene de forma fuerte, que es un número distinto y a menudo más útil: si la página guarda una lista de los elementos a los que adjuntó datos, esa lista es el lado fuerte, y el lado débil solo se puede inferir a partir de ella.
El costo también aparece en las pruebas. Una prueba que quiere demostrar que una entrada fue recolectada no puede afirmar nada sobre la colección, porque no hay nada sobre lo que afirmar. Tiene que afirmar sobre la mitad observable, que es WeakRef.deref() devolviendo undefined, y eso requiere que el recolector se haya ejecutado. La sección siguiente cubre el par de APIs que existen justamente para esto, y la posterior cubre lo único que una prueba puede comprobar sin ninguna recolección.
Hay un tercer costo que aparece en una auditoría más que en el código. Quien revisa y quiere saber si una página pierde memoria tiene que razonar solo sobre el lado fuerte, porque el lado débil no se puede enumerar. La pregunta "cuántas entradas guarda este mapa" no tiene respuesta, y la pregunta que sí la tiene es "cuántos objetos sigue manteniendo la página que podrían ser claves". Una página que mantiene un registro de paneles abiertos, una lista de peticiones pendientes o un arreglo de elementos montados está manteniendo exactamente esa cantidad de claves potenciales, y las entradas débiles adjuntas a ellos viven tanto como esas listas. Leer un perfil de memoria es, por tanto, cuestión de encontrar las listas fuertes, no de encontrar los mapas débiles.
Qué le hace una referencia fuerte a la misma clave
Una entrada débil desaparece solo cuando nada más mantiene la clave. La sonda coloca la misma clave en un Map fuerte y en un WeakMap a la vez, y luego suelta la referencia externa:
same-key-both -> strong=1 weak=value
strong-still-has-entry -> size=1
La búsqueda débil todavía responde mientras la variable mantiene la clave, que es la mitad esperada. La segunda línea es la que importa: después de reasignar la variable, el mapa fuerte sigue informando una entrada. El mapa débil tampoco olvidó el valor, porque el mapa fuerte aún mantiene la clave, así que leer el mapa débil a través de esa clave sigue devolviendo value. El lado fuerte es lo que mantiene vivo el par, y el lado débil lo siguió.
Esa interacción es la forma más común en que una página derrota sin querer su propia colección débil. Un módulo que guarda metadatos en un WeakMap y además empuja cada clave a un arreglo para un panel de depuración ha hecho que cada entrada sea alcanzable de forma permanente, y el panel de depuración es la razón de que nunca se recolecte nada. El arreglo no tiene que usarse; solo tiene que existir y ser alcanzable desde un ámbito vivo.
El error inverso es suponer que delete es la forma en que una entrada se va. La sonda borra una entrada y luego reutiliza la misma clave:
delete-then-read -> undefined
reset-after-delete -> second
delete elimina la asociación y devuelve un booleano, y la clave en sí queda intacta, así que un set posterior sobre la misma clave funciona con normalidad. Quien espera que una clave borrada quede inutilizable, como podría ocurrir con un token de un solo uso, se llevará una sorpresa: la colección olvida el valor, no la clave.
El tercer error es esperar que una colección débil limite la memoria. No limita nada, porque no tiene un tamaño que limitar. Una página que pierde memoria a través de un WeakMap la pierde a través de los valores, no de las claves: un valor que apunta de vuelta a su propia clave, o a un búfer grande, mantiene ese búfer vivo exactamente mientras viva la clave. La sonda no puede ver esto, porque no mantiene ningún ciclo así, y es la razón de que una revisión de memoria tenga que mirar los valores con el mismo cuidado que las claves.
Un cuarto error es más sutil y aparece en aplicaciones de una sola página. Una página que usa como clave un objeto de ruta crea una clave nueva en cada navegación, así que el mapa acumula una entrada por navegación que no tenga otra referencia, y el recolector es libre de descartarlas todas. Ese es exactamente el comportamiento previsto, y también la razón de que un contador construido sobre el mismo mapa nunca disminuya: el contador es el lado fuerte. Una página que quiere medir el movimiento entre navegaciones tiene que contar navegaciones, no entradas.
El último punto estructural es que una clave débil puede ser un proxy, un objeto de otro realm o un registro congelado, y los tres se comportan igual en cuanto a retención. El recolector sigue la alcanzabilidad, no la forma, así que lo único que una página puede hacer para cambiar el resultado es añadir o quitar una referencia fuerte en otro lugar. Eso convierte a la colección débil en una seguidora: su comportamiento lo determina por completo código que nunca la menciona.
Qué aportan WeakRef y FinalizationRegistry
WeakRef y FinalizationRegistry son la mitad observable de la retención débil, y la sonda usa ambos:
weakref-deref-before -> true
new WeakRef(target) no mantiene vivo target, y deref() devuelve el destino mientras algo más lo mantenga. La sonda comprueba primero el caso positivo. El caso negativo solo ocurre después de que el recolector se haya ejecutado, y un script puede pedir esa ejecución: v8.setFlagsFromString('--expose-gc') seguido de vm.runInNewContext('gc') produce una función gc que funciona en un proceso Node normal donde globalThis.gc es undefined.
Una ejecución solicitada todavía no basta para ver undefined dentro del mismo trabajo, y una sola ejecución solicitada tampoco basta entre trabajos. Una segunda sonda descarta tres destinos, llama a gc, lee cada referencia de inmediato, luego vuelve a llamar a gc un turno después y vuelve a leer:
deref-shape -> same_job=true after_second_gc=false | same_job=true after_second_gc=false | same_job=true after_second_gc=false
Todas las referencias respondieron todavía en el trabajo que solicitó la recolección, y todas respondieron undefined solo tras la segunda ejecución solicitada. La primera ejecución marca los objetos como recolectables; la referencia sigue respondiendo hasta que una recolección posterior los reclama de verdad, así que una prueba que afirme undefined justo después de una llamada a gc falla por una razón que no tiene nada que ver con la retención. La misma sonda muestra una retrollamada de FinalizationRegistry disparándose tras la ejecución solicitada, por lo que tanto una retrollamada de registro como una comprobación de deref() pertenecen a una prueba que quiera demostrar la recolección.
FinalizationRegistry es la forma con retrollamada: la página registra un destino con un valor retenido, y el registro llama de vuelta con ese valor retenido después de que el destino sea recolectado. La retrollamada no es una promesa y no se ejecuta según un calendario que la página controle. Puede ejecutarse mucho después, o no ejecutarse antes de que la página se descargue, así que una página que depende de ella para su corrección tiene un error de corrección, no uno de rendimiento.
El par es útil para diagnósticos, no para limpieza, y el orden en que finalmente corre un callback de diagnóstico es una cuestión de planificación que cubre microtareas y temporizadores. Una página que quiere saber si su caché de elementos se está encogiendo de verdad puede registrar cada clave con un contador y anotar la retrollamada, lo que convierte "creo que esto se recolecta" en un número. Una página que quiere liberar un recurso usa un close, un dispose o un AbortController explícitos, porque esos se ejecutan cuando la página lo dice y no cuando el recolector decide.
Hay una asimetría más que vale la pena registrar. Se puede crear un WeakRef para cualquier objeto, incluido uno que la página todavía mantiene de forma fuerte, y la referencia no añade retención propia. Una página que crea un WeakRef por elemento y guarda las referencias en un arreglo ha guardado un arreglo de objetos pequeños, lo cual suele estar bien, pero el arreglo en sí es fuerte y crece con la cantidad de elementos que la página haya visto. La referencia única de la sonda oculta eso, y una revisión de una página real debería contar las referencias, no solo los destinos.
Qué no sobrevive una colección débil
Una colección débil no es dato. No se puede copiar, registrar ni enviar, y la sonda muestra ambos fallos:
serialization -> json=ok:{} structuredClone=throws:DOMException
JSON.stringify no lanza; produce {}, porque un WeakMap no tiene propiedades enumerables propias. Ese es el más peligroso de los dos, porque la salida es JSON válido y quien la registra ve un objeto vacío en lugar de un error. Un registro que dice "la caché estaba vacía" puede, por tanto, haber sido producido por una caché llena, y nada en la salida distingue los dos casos.
structuredClone lanza un DOMException en su lugar, que es la respuesta honesta: una colección débil no tiene semántica de clonación, porque un clon tendría que decidir si la copia es débil de la misma manera. Una página que pasa un WeakMap por una frontera de worker o por postMessage recibe esa excepción en lugar de un objeto vacío silencioso, y ese es el modo de fallo para el que vale la pena diseñar.
El mismo límite se aplica a un objeto plano con un WeakMap dentro. JSON.stringify recorre las propiedades propias, encuentra la colección débil y produce {} para ese campo mientras el resto del objeto se serializa con normalidad. La colección única de la sonda es el caso simple; una carga útil real suele tener la colección anidada, y el campo vacío es fácil de pasar por alto en una línea de registro larga.
Registra una lectura de retención
Una lectura de retención sostiene una afirmación estrecha: este runtime, estos tipos de clave, esta estructura de referencias, produjeron estas observaciones. Cinco campos la mantienen comprobable:
- El runtime, porque el conjunto de tipos de clave aceptados cambió cuando los símbolos pasaron a ser claves débiles válidas.
- Los tipos de clave probados, porque un
TypeErroren el momento desety un fallo silencioso en el momento degetson resultados distintos. - Las referencias fuertes que la página todavía mantiene, porque cualquiera de ellas mantiene viva cada entrada.
- Si se pidió al recolector que se ejecutara, porque que
deref()devuelva un valor no demuestra nada sobre la recolección. - La ruta de serialización, porque
JSON.stringifydevuelve un objeto vacío mientrasstructuredClonelanza.
Fuentes públicas
- MDN: WeakMap
- MDN: WeakSet
- MDN: WeakRef
- MDN: FinalizationRegistry
- ECMAScript: Symbols as WeakMap keys
- BotBrowser: automation consistency reference
Conclusión práctica
Usa una colección débil para adjuntar datos a un objeto que ya tienes, y no esperes nada más de ella: ni tamaño, ni enumeración, ni serialización, ni limpieza que se ejecute según tu calendario. Normaliza las claves a objetos antes de que lleguen al mapa, para que una clave inválida falle en la escritura en lugar de desaparecer en la lectura. Audita el lado fuerte de la página, porque un solo arreglo retenido derrota cada entrada débil que enumera. Cuando necesites liberar, llama a una ruta de disposición explícita, y reserva WeakRef y FinalizationRegistry para diagnósticos. Para saber qué sobrevive a una copia de los datos que intentabas guardar, lee structuredClone frente a JSON; para el orden en que se ejecutan las retrollamadas que tocan esas entradas, lee microtareas y temporizadores.
Este artículo forma parte de la guía: ¿Qué es la huella digital del navegador?
Artículos Relacionados
structuredClone frente a JSON: qué conserva una copia profunda
Huella digitalOrden de claves: enteros ordenan, cadenas se quedan
Huella digitalOrden de array y comparadores: una función fácil de errar
Huella digitalAnálisis de URL y search params: qué cambia el viaje
Huella digitalCombinadores Promise: qué falla y qué nunca se asienta
Huella digitalKeyboard Layout API, preferencias regionales y privacidad
Lleva BotBrowser de la investigación a producción
Usa estas guías para entender el modelo y después avanzar hacia validación multiplataforma, contextos aislados y despliegue de navegador preparado para escalar.