Introduction
Le Javascript est un langage souple qui peut paraître facile au premier abord. Mais je vous le dis : en réalité, il recèle de subtilités qui peuvent rapidement mettre le bazar si on ne les maîtrise pas !
Une de ces subtilités dont on va parler aujourd'hui, c'est le clonage d'objets. Et pour changer un peu des moutons, on va cloner des objets robots (n'en déplaise à Will Smith, alias Del Spooner) ! C'est parti !
Le clonage naïf
Partons du principe que l'on dispose d'une instance d'un premier prototype de robot, et que l'on veuille alors le cloner dans une production. Ici, on représentera une production par un tableau <span class="css-span">production</span> contenant nos robots :
(Notez que pour aider à modéliser le problème, j'utiliserai du Typescript)
Résultat de la console :
<pre><code>------- Production check -------
robot 1 = {id: 10, isConform: true}
robot 2 = {id: 10, isConform: true}
robot 3 = {id: 10, isConform: true}
robot 4 = {id: 10, isConform: true}
...</code></pre>
Quoi ?! Mais ils ont tous le même identifiant ! 😱
Si vous le souhaitez, vous pouvez vous exercer à débugger le code ci-dessus en ajoutant du code et des logs.
Et en effet, il y a bien un problème dans cette production : en JS, l'opérateur <span class="css-span">=</span> n'effectue pas de copie d'un objet ou d'un array à proprement parler, mais uniquement de la référence de cet objet en question.
Ainsi, <span class="css-span">const newRobot = robotProto</span>; ne crée pas un nouvel objet mais référence le robot proto. A chaque tour de boucle.
Du coup, sans le savoir, on a toujours affecté le même objet dans toutes les cases du tableau, et par conséquent modifié le même objet à chaque itération. Au final, on se retrouve évidemment avec la dernière valeur enregistrée : 10.
Cette explication est d'ailleurs vérifiable avec l'opérateur de comparaison <span class="css-span">===</span> :
<pre><code>// 2 contenus identiques mais dans des objets différents
const robot1 = { id: 0, isConform: true };
const robot2 = { id: 0, isConform: true };
console.log(robot1 === robot2); // false => pas les mêmes objets
//...et si on revient à notre production râtée
console.log(production[0] === production[9]); // true => c'est le même objet !</code></pre>
Bon, comment corriger cette production ?
Nouvel objet et spread operator
Une solution consiste à utiliser le spread operator. Combiné aux accolades <span class="css-span">{}</span> pour déclarer un nouvel objet, le spread operator <span class="css-span">...</span> permet de copier tous les champs de l'objet <span class="css-span">robotProto</span>. Très efficace si l'on a un paquet de champs à copier d'un coup !
Résultat de la console :
<pre><code>------- Production check -------
robot 1 = {id: 1, isConform: true}
robot 2 = {id: 2, isConform: true}
robot 3 = {id: 3, isConform: true}
robot 4 = {id: 4, isConform: true}
...</code></pre>
Aah c'est mieux !
Dans notre cas, cette solution minimaliste convient parfaitement !
Sauf que cette technique a des limites... En effet, le spread operator souffre du même défaut que l'opérateur <span class="css-span">=</span> : il ne clone pas réellement les champs qui sont de type objets et arrays 😟
Ainsi, si l'on enrichit la structure de données avec des objets à plusieurs niveaux, on retombe sur le problème des références aux niveaux plus profonds :
<pre><code>interface Robot {
id: number;
isConform: boolean;
head: {
eyesColor: string;
};
body: {
material: string;
};
}
const complexRobot: Robot = {
id: 0,
isConform: true,
head: {
eyesColor: 'blue'
};
body: {
material: 'carbon'
};
};
const badClone = {...complexRobot};
console.log(badClone.head === complexRobot.head); //true => on a encore le même objet</code></pre>
La méthode stringify
Une solution consiste à transformer un objet en string, puis le re-parser successivement :
<pre><code>const newRobot = JSON.parse(JSON.stringify(complexRobot));</code></pre>
Cela peut paraître empirique, je vous l'accorde. Pourtant dans beaucoup de cas, c'est le meilleur équilibre entre fiabilité et rapidité d'exécution dans la catégorie du clonage en profondeur.
Gros warning cependant : du fait de la transformation intermédiaire en string, les types correctement pris en charge sont très limités. Les fonctions par exemple seront tout simplement perdues !
structuredClone
La fonction structuredClone est une nouveauté supportée par tous les principaux navigateurs depuis début 2022. Elle permet de cloner automatiquement et en profondeur tout le contenu d'un objet :
<pre><code>const newRobot = structuredClone(complexRobot);</code></pre>
L'avantage par rapport au <span class="css-span">JSON.stringify</span>, c'est qu'elle prend en charge plus d'objets spéciaux comme les <span class="css-span">Date</span>, <span class="css-span">RegExp</span> ou <span class="css-span">Set</span>, mais toujours pas de prise en charge de types spéciaux comme les fonctions. Voir ici la liste exhaustive des types supportés.
Toute puissance ayant un coût, c'est également la méthode standard la plus lente à exécuter pour le clonage.
L'historique Object.assign
Parce qu'il faut respecter les anciens 😄, je terminerai cette liste en évoquant <span class="css-span">Object.assign</span>.
Très similaire au spread operator, il a l'avantage de pouvoir modifier un objet déjà instancié tout en se conformant au type d'origine et à ses méthodes lorsque c'est une classe. Personnellement, je trouve qu'avec le Typescript cette méthode a moins de cas d'usage, car on va utiliser des types (comme l'<span class="css-span">interface</span>) qui vont déjà nous aider à garantir la conformité d'une structure de données avec ses attributs et ses fonctions.
<pre><code>// usage basique dans notre cas
const newRobot = Object.assign({}, complexRobot);</code></pre>
Mais tout comme le spread operator, il ne clone qu'au premier niveau, et ne copie que les références des objets et arrays.
Conclusion
De manière générale, il ne faut cloner que s'il y a un réel besoin. Si par défaut le clonage des objets et arrays n'est pas automatique, c'est qu'il y a une raison : l'optimisation.
Mais dans le cas où le clonage est nécessaire, il vaut mieux choisir :
- le spread operator pour une copie à un seul niveau (sans objets ou arrays)
- l'<span class="css-span">Object.assign</span> pour une assignation à un seul niveau (sans objets ou arrays) plutôt pour des classes
- le JSON parse/stringify pour une copie en profondeur rapide (mais sans objets spéciaux)
- le structuredClone pour une copie en profondeur plus complète mais plus lente
Et si aucune de ces méthodes standard n'est satisfaisante, je vous invite à chercher du côté d'une librairie comme lodash, ou à implémenter votre propre méthode de clonage adaptée à votre cas d'usage !
Comme toujours, il faut s'adapter à chaque situation...
Jean-Noël aime sa guitare, sa PS4 (quoi de mieux que de réaliser un super combo sur sa manette) et coder... mais de préférence en Typescript !
Et oui, le Typescript, c’est son dada ! Il y a une très forte interopérabilité, le typage apporte de la rigueur au JS, et tout fonctionne très bien, très vite ! En revanche, quand il s’agit d’algorithmes, il préfère le Rust ! C’est le seul langage pour lequel, lorsqu’il a réussi à compiler, il peut respirer en criant : « MON CODE EST SUUUUR !!! ».
On ne sait pas si c’est pour pouvoir rédiger des tas d’articles pour le blog YOUNUP mais il nous a confié rêver de pouvoir regénérer ses cellules pour une jeunesse et un savoir sans limite. Un mix entre le film « Bienvenue à Gattaca » et les écrits de « Laurent Alexandre » ?