Volver al Centro de conocimiento
Huella digital

structuredClone frente a JSON: qué conserva una copia profunda

Ejecuta structuredClone y un viaje por JSON sobre fechas, mapas, NaN, BigInt, ciclos y referencias compartidas para ver qué conserva, cambia, rechaza o descarta cada copia.

BotBrowser Team

Documentación

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 observaciones acotadas sobre cómo una página copia un objeto, pero no cambia cómo un entorno implementa el algoritmo de clonación estructurada, no puede hacer clonable una función o un símbolo y no convierte el resultado de una copia en una señal de identidad. Esta revisión ejecuta structuredClone y un viaje por JSON sobre los mismos valores, y luego registra qué tipos conserva cada uno, qué números cambia, qué valores rechaza o descarta en silencio, y cómo se comportan las referencias compartidas, los ciclos y los prototipos.

Resumen

  • structuredClone conserva Date, Map, Set y RegExp como esos tipos; un viaje por JSON convierte una Date en cadena, y un Map, Set o RegExp en {}.
  • Un viaje por JSON convierte NaN e Infinity en null y -0 en 0; structuredClone conserva los tres, incluido el signo de -0.
  • structuredClone lanza DataCloneError con una función o un símbolo; JSON descarta una propiedad función en silencio.
  • structuredClone conserva un BigInt y, con la opción transfer, mueve un ArrayBuffer; JSON.stringify lanza con un BigInt.
  • structuredClone conserva las referencias compartidas y los ciclos; un viaje por JSON duplica una referencia compartida y lanza con un ciclo.
  • Ninguno conserva el prototipo de una clase, así que un clon pierde sus métodos salvo que se restaure el prototipo a mano.

Contenido

Diagrama neutro: un valor fuente alimenta structuredClone y un viaje por JSON; structuredClone conserva el tipo y las referencias compartidas y lanza con funciones, mientras la ruta JSON convierte Map y Set en objetos vacíos y cambia números especiales.

Tipos que sobreviven a una copia y no a la otra

Las dos rutas copian partes distintas de un valor. structuredClone implementa el algoritmo de clonación estructurada de HTML, que tiene casos explícitos para los tipos de plataforma de abajo; un viaje por JSON solo sabe serializar lo que JSON.stringify soporta y reconstruir lo que devuelve JSON.parse:

structuredClone({d:new Date(0)}) -> {d: Date(1970-01-01T00:00:00.000Z)}
JSON round trip of Date -> {d: "1970-01-01T00:00:00.000Z"}
structuredClone({m:new Map([[1,"a"]])}) -> {m: Map(1=>"a")}
JSON round trip of Map -> {m: {}}
structuredClone({s:new Set([1,2])}) -> {s: Set(1, 2)}
JSON round trip of Set -> {s: {}}
structuredClone(/ab+/gi) -> RegExp(/ab+/gi)
JSON round trip of RegExp -> {}

Cuatro diferencias en cuatro filas. Una Date se vuelve cadena por JSON, así que un valor que era Date antes del viaje es string después y cualquier llamada a .getTime() falla. Un Map se vuelve un objeto vacío, así que cada entrada desaparece sin error. Un Set se vuelve un objeto vacío, así que cada miembro desaparece sin error. Un RegExp se vuelve un objeto vacío, perdiendo su patrón y sus banderas.

El silencio es la parte peligrosa. Cada uno de esos cuatro resultados JSON es un valor válido que tipa como objeto, así que el código que solo comprueba typeof no ve problema. La pérdida aparece después, cuando el código llama un método de Map sobre {} o hace aritmética con una cadena de fecha. Por eso una copia profunda debe elegirse por lo que contiene el valor, no por comodidad: un viaje por JSON solo es correcto para valores hechos de los tipos propios de JSON.

Los arreglos tipados muestran el mismo patrón con un resultado más confuso, porque JSON produce un objeto que parece un arreglo sin serlo:

structuredClone typed array kept: true values=1,2,3
JSON typed array kept: false shape={"0":1,"1":2,"2":3}

structuredClone devuelve un Uint8Array real con los mismos tres valores, así que instanceof pasa y todo método de arreglo tipado funciona. La versión JSON es un objeto plano con claves numéricas, así que instanceof Uint8Array es false, byteLength es undefined, y un bucle que escribe por índice asigna propiedades planas. Quien serializa datos binarios con JSON y los lee de vuelta obtiene una estructura que se registra igual bajo JSON.stringify y se comporta totalmente distinto bajo cualquier aritmética, así que una comparación de salida serializada no puede detectar el cambio. Solo una comprobación de tipo o de longitud sobre el resultado lo muestra.

La forma del objeto solo se conserva para valores que JSON ya entiende. Objetos planos, arreglos, cadenas, números finitos, booleanos y null sobreviven a un viaje con su estructura intacta, que es exactamente el subconjunto para el que se diseñó JSON. Todo lo demás o convierte, o descarta, o lanza. Un esquema que enumera los tipos permitidos es por tanto la forma más barata de elegir un método de copia: si el esquema solo contiene tipos JSON, un viaje es seguro y predecible; si nombra una Date, un Map o un arreglo tipado, structuredClone es la única ruta que lo conserva.

La independencia es la única propiedad que comparten ambas rutas, y vale confirmarla en vez de suponerla. Un clon nuevo y una copia JSON nueva reciben cada uno sus propios objetos y arreglos anidados, así que mutar uno no escribe sobre la fuente:

clone independent: list=true nested=true date=true

Tras añadir al arreglo clonado, cambiar una propiedad anidada y fijar una nueva hora en la fecha clonada, la fuente sigue reportando un arreglo de tres elementos, el valor anidado original y la marca de tiempo original. Así que una copia profunda realmente es profunda para los valores que conserva; las diferencias de arriba tratan sobre qué conserva, no sobre si aísla la copia de la fuente.

Números y BigInt

Las reglas numéricas de JSON son más estrechas que las de JavaScript, y un viaje cambia en silencio tres valores que parecen representables:

structuredClone({n:NaN,i:Infinity,z:-0}) -> {n: NaN, i: Infinity, z: -0}
JSON round trip of NaN/Infinity/-0 -> {n: null, i: null, z: 0}
Object.is(JSON -0, -0) -> false
Object.is(structuredClone -0, -0) -> true

NaN e Infinity se vuelven null por JSON, porque JSON no tiene literal para ellos, y quien hace un viaje con cualquiera de ellos recibe null sin aviso. El caso -0 es más sutil: el resultado JSON es numéricamente igual a -0 bajo ===, así que una comprobación ingenua de igualdad pasa, pero Object.is distingue el signo y reporta false, mientras structuredClone conserva el signo y reporta true. El código que guarda un cero negativo para significar algo (un cero con signo en un cálculo, por ejemplo) pierde esa señal por JSON sin error.

BigInt separa aún más las dos rutas. structuredClone lo maneja, y JSON.stringify lanza con él:

structuredClone(10n) -> BigInt(10)
JSON.stringify(10n) -> THREW TypeError

El TypeError de JSON.stringify es el único fallo ruidoso de toda la comparación, y vale notarlo porque es la excepción. Un valor que contiene un BigInt no puede cruzar una frontera JSON en absoluto, mientras cruza structuredClone sin cambios. Un equipo que pasó a identificadores BigInt y mantuvo una copia profunda basada en JSON lo encontrará la primera vez que copie tal identificador.

Los cambios numéricos de arriba son la misma clase de problema que cubre Precisión de Number en JavaScript y portabilidad entre runtimes: un valor puede ser numéricamente igual bajo === y aun diferir bajo Object.is, y una ruta de copia que serializa es donde esa diferencia se vuelve permanente.

Valores que se rechazan o se descartan en silencio

Para los valores que no tienen representación de copia, las dos rutas divergen de la peor forma posible: una rechaza, la otra descarta:

structuredClone(()=>1) -> THREW DataCloneError
structuredClone(Symbol("x")) -> THREW DataCloneError
JSON round trip of function property -> {v: 1}
structuredClone({u:undefined}) -> {u: undefined}
JSON round trip of {u:undefined} -> {}
JSON round trip of [1,,3] -> [1, null, 3]

structuredClone lanza DataCloneError con una función o un símbolo, así que el fallo es inmediato y en el punto de la copia. Un viaje por JSON descarta la propiedad función en silencio: el resultado tiene v: 1 y no tiene f, así que quien esperaba que el método sobreviviera obtiene un objeto plano sin el método. Una propiedad undefined la conserva structuredClone y la descarta JSON, y los huecos de arreglo se reemplazan por null vía JSON.

La forma general debe registrarse como regla: structuredClone falla ruidosamente con un valor que no puede representar, mientras JSON falla en silencio descartando o sustituyendo. Para una ruta de validación, el fallo ruidoso es más fácil de atrapar y corregir. Para una ruta por la que un valor siempre debe sobrevivir, el descarte silencioso es el peligro, porque la copia parece exitosa mientras el dato se perdió.

Referencias compartidas y ciclos

Una copia profunda no trata solo de tipos; también del grafo. Cuando el mismo objeto aparece dos veces en la fuente, las dos copias se comportan distinto:

structuredClone shared reference identical: true
JSON shared reference identical: false

structuredClone conserva la referencia: si a y b apuntan al mismo objeto antes de la copia, siguen apuntando al mismo objeto (nuevo) después, así que la forma del grafo se preserva. Un viaje por JSON duplica el objeto compartido, porque serializar lo escribe dos veces y parsear construye dos objetos separados, así que a === b se vuelve false. Esa diferencia importa para cualquier valor cuya semántica dependa del aliasing, como un nodo compartido entre dos padres, y la copia JSON rompe el alias en silencio.

Los ciclos son el caso más agudo. structuredClone maneja un objeto autorreferente y conserva el bucle intacto, mientras un viaje por JSON lanza:

structuredClone(cycle) -> ok, self===clone: true
JSON round trip of cycle -> THREW TypeError

El self del clon apunta al clon, así que el ciclo sobrevive. JSON.stringify lanza con una estructura cíclica porque no puede representar anidamiento infinito, y el error es un TypeError. Así que un valor con un ciclo es copiable por structuredClone y no por JSON en absoluto, lo opuesto al caso de la referencia compartida donde JSON copia pero cambia la forma.

El resultado del ciclo también trae una lección sobre las herramientas que inspeccionan un clon. Una función describe recursiva que recorre cada propiedad de un objeto cíclico sin registrar lo ya visitado recurse hasta desbordar la pila, así que un fixture que imprime un grafo cíclico clonado debe parar en un conjunto de visitados en vez de seguir cada referencia a ciegas. El clon en sí está bien; el código que informa es el que falla. Esa distinción importa cuando un test reporta un RangeError sobre un valor cíclico: el error puede venir del ayudante de aserción y no de la copia, y la solución pertenece al ayudante, no a la llamada de copia.

Prototipos, transferibles y lo que aún requiere cuidado

Ninguna de las dos rutas conserva el prototipo de una clase. Un clon pierde sus métodos y es un objeto plano después:

structuredClone prototype is Point: false
structuredClone has double method: undefined

El valor clonado tiene los campos de instancia pero no el prototipo, así que instanceof Point es false y llamar double() falla. Un equipo que clona objetos de dominio por structuredClone todavía tiene que restaurar el prototipo, normalmente construyendo una instancia nueva desde los campos clonados o fijando el prototipo explícitamente. La copia es una copia estructural, no semántica.

La opción transfer es lo que hace a structuredClone más que una copia profunda. Pasar un búfer en la lista transfer lo mueve en vez de copiarlo, y el búfer fuente queda desacoplado:

transfer: source byteLength=0 clone byteLength=8

La fuente reporta longitud cero porque la propiedad se movió al clon, así que la fuente ya no se puede usar. Esa es una optimización deliberada para un búfer grande que se entrega de un lugar a otro, y también una trampa: quien transfiere un búfer y luego lee de la fuente obtiene una vista vacía, sin error. Una lectura de copia debe registrar si ocurrió una transferencia, porque una transferencia cambia la fuente además de producir el clon.

La regla de transferencia también decide si una copia es copia. Sin lista transfer, un búfer se duplica como cualquier otro valor, así que la fuente conserva sus bytes y el clon recibe los suyos, a costa de duplicar la memoria durante la copia. Con lista transfer, la propiedad se mueve y la memoria total queda plana, lo que importa para un búfer lo bastante grande como para ser la razón de que la copia fuera lenta. Así que la misma llamada tiene dos perfiles muy distintos según una opción, y un fixture que solo mide el clon no puede saber cuál ocurrió; tiene que registrar la longitud de la fuente después.

Los mensajes de worker usan el mismo algoritmo de clonación estructurada, así que todo resultado de este artículo se traslada a datos enviados a un worker y leídos en el worker. La diferencia es que una frontera de worker aplica también las reglas de transferencia automáticamente para un búfer listado como transferible, y un búfer enviado dos veces sin transferencia se copia dos veces. Un equipo que mueve un búfer grande entre la página y un worker y luego lee del lado de la página encontrará un búfer vacío, exactamente como en la transferencia en memoria de arriba, y el fallo parece datos corruptos en vez de una transferencia que vació la fuente.

Hay otra asimetría que vale registrar porque rompe código de comparación. structuredClone conserva las propiedades undefined y JSON las descarta, así que dos objetos que parecen iguales bajo una comparación JSON pueden diferir bajo un clon estructurado:

structuredClone({u:undefined}) -> {u: undefined}
JSON round trip of {u:undefined} -> {}

Un test que compara dos valores pasándolos primero por JSON tratará un objeto con u: undefined y un objeto sin u como iguales, porque ambos serializan a {}. El mismo test sobre clones estructurados verá un objeto con una clave y otro sin ella. Así que un ayudante de comparación construido sobre JSON es más estricto con algunos tipos y más laxo con otros de lo que el modelo de valores permite, y un fixture debe comparar los valores clonados directamente en vez de pasarlos por JSON para compararlos.

Registra una lectura de copia

Una lectura de copia que sobrevive a una revisión dice qué conserva la copia y qué descarta. Seis campos cubren la parte observable:

  1. El método de copia y el entorno, para que un lector sepa si el algoritmo es clonación estructurada o JSON.
  2. La lista de tipos de valor en la fuente, y cuáles siguen siendo el mismo tipo tras la copia.
  3. Los valores numéricos especiales antes y después, incluidos NaN, Infinity y el signo de -0.
  4. Si había una función, un símbolo o un BigInt, y si la copia lo rechazó o lo descartó.
  5. Si la fuente tenía referencias compartidas o ciclos, y si sobrevivieron.
  6. Para un búfer, si se transfirió, y la longitud de la fuente tras la transferencia.

Lo que tal lectura no puede mostrar es nada sobre una copia a través de una frontera de worker o hacia almacenamiento persistente. Un valor puede clonarse limpiamente en memoria y aún fallar al serializarse a una base de datos, y un mensaje a un worker usa el mismo algoritmo de clonación estructurada pero con sus propias reglas de transferencia. Para la pregunta más amplia de cómo el modelo de datos de una página interactúa con lo que informa, consulta modelo de almacenamiento del navegador y análisis de URL y search params, que cubre otro lugar donde un valor no sobrevive a un viaje ingenuo.

Dos hábitos mantienen la lectura honesta con el tiempo. Primero, registra la lista de tipos y el resultado de referencia compartida, no solo si la copia devolvió sin lanzar, porque una copia que tiene éxito mientras descarta un Map es un fallo que la comprobación de lanzamiento no ve. Segundo, incluye al menos un -0, una referencia compartida y un ciclo en un fixture que dependa de una copia profunda, para que las sustituciones silenciosas y la ruptura de alias no pasen una suite verde.

Fuentes públicas

Conclusión

Elige la copia por lo que contiene el valor: usa structuredClone para Date, Map, Set, RegExp, BigInt, referencias compartidas y ciclos, y usa un viaje por JSON solo para valores hechos de los tipos propios de JSON. Espera que structuredClone falle ruidosamente con una función o un símbolo y que JSON lo descarte en silencio, y espera que JSON convierta NaN e Infinity en null y -0 en 0. Restaura un prototipo de clase a mano tras cualquier copia estructural, y registra si un búfer se transfirió, para que la fuente que quedó vacía no sea una sorpresa.

#structuredClone#JSON#Copia Profunda#DataCloneError#Transferibles

Este artículo forma parte de la guía: ¿Qué es la huella digital del navegador?

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.