Retour au Centre de connaissances
Empreinte

structuredClone face à JSON : copies profondes

Exécutez structuredClone et un aller-retour JSON sur dates, cartes, NaN, BigInt, cycles et références partagées pour voir ce que chaque copie garde, change, refuse ou perd.

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’un fixture autorisé de même origine sous un profil déclaré et la conservation d’observations bornées sur la façon dont une page copie un objet, mais il ne change pas la façon dont un environnement implémente l’algorithme de clone structuré, ne peut pas rendre une fonction ou un symbole clonable, et ne transforme pas le résultat d’une copie en signal d’identité. Cette revue exécute structuredClone et un aller-retour JSON sur les mêmes valeurs, puis consigne quels types chacun garde, quels nombres il change, quelles valeurs il refuse ou perd en silence, et comment se comportent les références partagées, les cycles et les prototypes.

Résumé

  • structuredClone garde Date, Map, Set et RegExp comme ces types ; un aller-retour JSON transforme une Date en chaîne, et un Map, Set ou RegExp en {}.
  • Un aller-retour JSON transforme NaN et Infinity en null et -0 en 0 ; structuredClone garde les trois, y compris le signe de -0.
  • structuredClone lève DataCloneError sur une fonction ou un symbole ; JSON perd une propriété fonction en silence.
  • structuredClone garde un BigInt et, avec l’option transfer, déplace un ArrayBuffer ; JSON.stringify lève sur un BigInt.
  • structuredClone conserve les références partagées et les cycles ; un aller-retour JSON duplique une référence partagée et lève sur un cycle.
  • Aucun ne conserve le prototype d’une classe, donc un clone perd ses méthodes sauf si le prototype est restauré à la main.

Sommaire

Schéma neutre : une valeur source alimente structuredClone et un aller-retour JSON ; structuredClone garde le type et les références partagées et lève sur les fonctions, tandis que le chemin JSON transforme Map et Set en objets vides et change les nombres spéciaux.

Types qui survivent à une copie et pas à l’autre

Les deux chemins copient des parties différentes d’une valeur. structuredClone implémente l’algorithme de clone structuré du HTML, qui a des cas explicites pour les types de plateforme ci-dessous ; un aller-retour JSON ne sait que sérialiser ce que JSON.stringify prend en charge et reconstruire ce que JSON.parse renvoie :

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 -> {}

Quatre différences en quatre lignes. Une Date devient une chaîne par JSON, donc une valeur qui était une Date avant l’aller-retour est une string après et tout appel .getTime() échoue. Un Map devient un objet vide, donc chaque entrée disparaît sans erreur. Un Set devient un objet vide, donc chaque membre disparaît sans erreur. Un RegExp devient un objet vide, perdant son motif et ses drapeaux.

Le silence est la partie dangereuse. Chacun de ces quatre résultats JSON est une valeur valide qui type comme objet, donc un code qui ne vérifie que typeof ne voit pas de problème. La perte apparaît plus tard, quand le code appelle une méthode Map sur {} ou fait de l’arithmétique sur une chaîne de date. C’est pourquoi une copie profonde doit être choisie selon ce que la valeur contient, pas par commodité : un aller-retour JSON n’est correct que pour des valeurs faites des types propres à JSON.

Les tableaux typés montrent le même motif avec un résultat plus déroutant, car JSON produit un objet qui ressemble à un tableau sans l’être :

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

structuredClone renvoie un vrai Uint8Array avec les mêmes trois valeurs, donc instanceof passe et chaque méthode de tableau typé fonctionne. La version JSON est un objet plat à clés numériques, donc instanceof Uint8Array est false, byteLength est undefined, et une boucle qui écrit par index assigne de simples propriétés. Un appelant qui sérialise des données binaires avec JSON et les relit obtient une structure qui se journalise à l’identique sous JSON.stringify et se comporte tout autrement sous toute arithmétique, donc une comparaison de sortie sérialisée ne peut pas détecter le changement. Seule une vérification de type ou de longueur sur le résultat le montre.

La forme de l’objet n’est préservée que pour les valeurs que JSON comprend déjà. Objets plats, tableaux, chaînes, nombres finis, booléens et null font l’aller-retour avec leur structure intacte, ce qui est exactement le sous-ensemble pour lequel JSON a été conçu. Tout le reste soit convertit, soit perd, soit lève. Un schéma qui énumère les types autorisés est donc le moyen le moins coûteux de choisir une méthode de copie : si le schéma ne contient que des types JSON, un aller-retour est sûr et prévisible ; s’il nomme une Date, un Map ou un tableau typé, structuredClone est le seul chemin qui le garde.

L’indépendance est la seule propriété partagée par les deux chemins, et elle mérite d’être confirmée plutôt que supposée. Un clone neuf et une copie JSON neuve reçoivent chacun leurs propres objets et tableaux imbriqués, donc muter l’un n’écrit pas sur la source :

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

Après avoir ajouté au tableau cloné, changé une propriété imbriquée et fixé une nouvelle heure sur la date clonée, la source rapporte encore un tableau de trois éléments, la valeur imbriquée d’origine et l’horodatage d’origine. Donc une copie profonde est vraiment profonde pour les valeurs qu’elle garde ; les différences ci-dessus portent sur ce qu’elle garde, pas sur le fait qu’elle isole la copie de la source.

Nombres et BigInt

Les règles numériques de JSON sont plus étroites que celles de JavaScript, et un aller-retour change en silence trois valeurs qui semblent représentables :

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 et Infinity deviennent null par JSON, car JSON n’a pas de littéral pour eux, et un appelant qui fait un aller-retour avec l’un ou l’autre récupère null sans avertissement. Le cas -0 est plus subtil : le résultat JSON est numériquement égal à -0 sous ===, donc une vérification naïve d’égalité passe, mais Object.is distingue le signe et rapporte false, tandis que structuredClone garde le signe et rapporte true. Un code qui stocke un zéro négatif pour signifier quelque chose (un zéro signé dans un calcul, par exemple) perd ce signal par JSON sans erreur.

BigInt sépare encore plus nettement les deux chemins. structuredClone le gère, et JSON.stringify lève sur lui :

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

Le TypeError de JSON.stringify est la seule panne bruyante de toute la comparaison, et il vaut d’être noté car c’est l’exception. Une valeur qui contient un BigInt ne peut pas franchir du tout une frontière JSON, tandis qu’elle franchit structuredClone inchangée. Une équipe passée à des identifiants BigInt et qui a gardé une copie profonde basée sur JSON y sera confrontée la première fois qu’un tel identifiant est copié.

Les changements numériques ci-dessus relèvent de la même catégorie de problème que couvre Précision de Number JavaScript et portabilité entre runtimes : une valeur peut être numériquement égale sous === et différer encore sous Object.is, et un chemin de copie qui sérialise est là où cette différence devient définitive.

Valeurs refusées ou perdues en silence

Pour les valeurs sans représentation de copie, les deux chemins divergent de la pire façon : l’un refuse, l’autre perd :

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 lève DataCloneError sur une fonction ou un symbole, donc la panne est immédiate et au point de la copie. Un aller-retour JSON perd la propriété fonction en silence : le résultat a v: 1 et pas f, donc un appelant qui attendait la survie de la méthode obtient un objet plat sans la méthode. Une propriété undefined est gardée par structuredClone et perdue par JSON, et les trous de tableau sont remplacés par null via JSON.

La forme générale doit être consignée comme une règle : structuredClone échoue bruyamment sur une valeur qu’il ne peut pas représenter, tandis que JSON échoue en silence en perdant ou en substituant. Pour un chemin de validation, la panne bruyante est plus facile à attraper et à corriger. Pour un chemin par lequel une valeur doit toujours survivre, la perte silencieuse est le danger, car la copie semble réussir tandis que la donnée a disparu.

Références partagées et cycles

Une copie profonde ne porte pas que sur les types ; elle porte aussi sur le graphe. Quand le même objet apparaît deux fois dans la source, les deux copies se comportent différemment :

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

structuredClone garde la référence : si a et b pointent sur le même objet avant la copie, ils pointent sur le même (nouvel) objet après, donc la forme du graphe est préservée. Un aller-retour JSON duplique l’objet partagé, car sérialiser l’écrit deux fois et parser construit deux objets séparés, donc a === b devient false. Cette différence compte pour toute valeur dont la sémantique dépend de l’aliasing, comme un nœud partagé entre deux parents, et la copie JSON casse l’alias en silence.

Les cycles sont le cas plus aigu. structuredClone gère un objet auto-référent et garde la boucle intacte, tandis qu’un aller-retour JSON lève :

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

Le self du clone pointe sur le clone, donc le cycle survit. JSON.stringify lève sur une structure cyclique car il ne peut pas représenter une imbrication infinie, et l’erreur est un TypeError. Donc une valeur avec un cycle est copiable par structuredClone et pas du tout par JSON, l’inverse du cas de la référence partagée où JSON copie mais change la forme.

Le résultat du cycle porte aussi une leçon sur les outils qui inspectent un clone. Une fonction describe récursive qui parcourt chaque propriété d’un objet cyclique sans suivre ce qui a déjà été visité récursera jusqu’au débordement de pile, donc un fixture qui imprime un graphe cyclique cloné doit s’arrêter sur un ensemble de visités plutôt que suivre chaque référence à l’aveugle. Le clone lui-même va bien ; le code qui rapporte est ce qui échoue. Cette distinction compte quand un test rapporte un RangeError sur une valeur cyclique : l’erreur peut venir de l’assistant d’assertion et non de la copie, et le correctif appartient à l’assistant, pas à l’appel de copie.

Prototypes, transferables et ce qui exige encore du soin

Aucun des deux chemins ne conserve le prototype d’une classe. Un clone perd ses méthodes et devient un objet plat ensuite :

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

La valeur clonée a les champs d’instance mais pas le prototype, donc instanceof Point est false et appeler double() échoue. Une équipe qui clone des objets de domaine via structuredClone doit encore restaurer le prototype, généralement en construisant une instance neuve depuis les champs clonés ou en fixant le prototype explicitement. La copie est structurelle, pas sémantique.

L’option transfer est ce qui fait de structuredClone plus qu’une copie profonde. Passer un tampon dans la liste transfer le déplace au lieu de le copier, et le tampon source est détaché :

transfer: source byteLength=0 clone byteLength=8

La source rapporte une longueur nulle car la propriété a migré vers le clone, donc la source ne peut plus être utilisée. C’est une optimisation délibérée pour un grand tampon remis d’un endroit à un autre, et c’est aussi un piège : un appelant qui transfère un tampon puis lit la source obtient une vue vide, sans erreur. Une lecture de copie doit consigner si un transfert a eu lieu, car un transfert change la source autant qu’il produit le clone.

La règle de transfert décide aussi si une copie est une copie. Sans liste transfer, un tampon est dupliqué comme toute autre valeur, donc la source garde ses octets et le clone reçoit les siens, au prix d’un doublement de la mémoire pendant la copie. Avec une liste transfer, la propriété migre et la mémoire totale reste plate, ce qui compte pour un tampon assez grand pour être la raison de la lenteur de la copie. Donc le même appel a deux profils très différents selon une option, et un fixture qui ne mesure que le clone ne peut pas dire ce qui s’est passé ; il doit consigner la longueur de la source après.

Les messages de worker utilisent le même algorithme de clone structuré, donc chaque résultat de cet article se transpose à des données envoyées à un worker et lues dans le worker. La différence est qu’une frontière de worker applique aussi les règles de transfert automatiquement pour un tampon listé comme transférable, et un tampon envoyé deux fois sans transfert est copié deux fois. Une équipe qui déplace un grand tampon entre la page et un worker puis lit côté page trouvera un tampon vide, exactement comme dans le transfert en mémoire ci-dessus, et la panne ressemble à des données corrompues plutôt qu’à un transfert qui a vidé la source.

Il y a une autre asymétrie à consigner car elle casse le code de comparaison. structuredClone garde les propriétés undefined et JSON les perd, donc deux objets qui semblent égaux sous une comparaison JSON peuvent différer sous un clone structuré :

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

Un test qui compare deux valeurs en les passant d’abord par JSON traitera un objet avec u: undefined et un objet sans u comme égaux, car les deux sérialisent en {}. Le même test sur des clones structurés verra un objet avec une clé et un sans. Donc un assistant de comparaison bâti sur JSON est plus strict sur certains types et plus laxiste sur d’autres que le modèle de valeurs ne le permet, et un fixture doit comparer les valeurs clonées directement plutôt que les passer par JSON pour comparer.

Consigner une lecture de copie

Une lecture de copie qui survit à une revue dit ce que la copie garde et ce qu’elle perd. Six champs couvrent la partie observable :

  1. La méthode de copie et l’environnement, pour qu’un lecteur sache si l’algorithme est un clone structuré ou JSON.
  2. La liste des types de valeur dans la source, et lesquels sont encore le même type après la copie.
  3. Les valeurs numériques spéciales avant et après, y compris NaN, Infinity et le signe de -0.
  4. Si une fonction, un symbole ou un BigInt était présent, et si la copie l’a refusé ou perdu.
  5. Si la source avait des références partagées ou des cycles, et s’ils ont survécu.
  6. Pour un tampon, s’il a été transféré, et la longueur de la source après le transfert.

Ce qu’une telle lecture ne peut pas montrer, c’est quoi que ce soit sur une copie à travers une frontière de worker ou vers un stockage persistant. Une valeur peut se cloner proprement en mémoire et échouer à se sérialiser dans une base de données, et un message à un worker utilise le même algorithme de clone structuré mais avec ses propres règles de transfert. Pour la question plus large de la façon dont le modèle de données d’une page interagit avec ce qu’elle rapporte, voir modèle de stockage du navigateur et analyse d’URL et search params, qui couvre un autre endroit où une valeur ne survit pas à un aller-retour naïf.

Deux habitudes gardent la lecture honnête dans le temps. D’abord, consignez la liste des types et le résultat de référence partagée, pas seulement si la copie a renvoyé sans lever, car une copie qui réussit tout en perdant un Map est une panne que la vérification de la levée manque. Ensuite, incluez au moins un -0, une référence partagée et un cycle dans un fixture qui dépend d’une copie profonde, pour que les substitutions silencieuses et la rupture d’alias ne passent pas une suite verte.

Sources publiques

Conclusion

Choisissez la copie selon ce que la valeur contient : utilisez structuredClone pour Date, Map, Set, RegExp, BigInt, les références partagées et les cycles, et un aller-retour JSON seulement pour des valeurs faites des types propres à JSON. Attendez-vous à ce que structuredClone échoue bruyamment sur une fonction ou un symbole et à ce que JSON les perde en silence, et à ce que JSON transforme NaN et Infinity en null et -0 en 0. Restaurez un prototype de classe à la main après toute copie structurelle, et consignez si un tampon a été transféré, pour que la source vidée ne soit pas une surprise.

#structuredClone#JSON#Copie Profonde#DataCloneError#Transferables

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.