Introducción
Esta breve entrada viene motivada por un comportamiento especial del lenguaje Javascript del que no siempre nos percatamos hasta que no lo experimentamos a través de errores inesperados y es el que refiere a cuando tratamos de copiar objetos.
En Javascript, cuando asignamos a una variable un valor de tipo String o Boolean, estamos creando una copia de dicho objeto. Sin embargo, cuando asignamos un Array o un objeto, lo que estamos es creando una referencia a dicho valor. Veamos qué significa esto…
Copia vs Referencia
Una copia de un valor, como cabe esperar, no es otra cosa sino un nuevo valor idéntico al anterior, pero completamente independiente. Esto permite manipularlos uno a uno sin que alterar al resto:
var foo = "I'm Muzzy", bar = foo; bar += ", big Muzzy!"; console.log( foo ); // I'm Muzzy console.log( bar ); // I'm Muzzy, big Muzzy! |
Bien; en el código anterior, la variable foo es de tipo String; al asignar este valor a una nueva variable bar, se crea una copia de dicho valor. Esto permite modificar bar de forma independiente sin que foo se vea afectada. Veamos ahora el tema de las referencias:
var foo = [ "Muzzy", "Bob", "Norman", "King", "Queen" ], bar = foo; // Truncate the numbers of item to 2 bar.length = 2; console.log( foo ); // [ "Muzzy", "Bob" ] console.log( bar ); // [ "Muzzy", "Bob" ] |
Una referencia es, como podemos apreciar en el ejemplo anterior, simplemente un puntero: cuando ahora se modifica el valor de la variable que apunta a la referencia, se está modificando al original y, por lo tanto, tanto foo como bar se ven afectados. Hemos perdido la independencia que teníamos en el caso anterior con la copia.
En el caso anterior, el array original tenía 5 elementos. Sin embargo, como bar ha creado una referencia, al truncar éste a solo dos elementos, en realidad se está actuando sobre el original. De ahí que finalmente ambos arrays muestren solo esos dos elementos.
Copiando arrays
Cuando lo que queremos es una copia exacta de un array para operar con él de forma independiente al original, tenemos varias formas de conseguirlo. La más sencilla sería iterar por los elementos del original e ir guardándolos en uno nuevo. Algo similar a:
for ( var i = 0, l = foo.length, bar = []; i < l; i++ ) { bar[ i ] = foo[ i ]; } |
NOTA: He metido la inicialización de bar dentro del propio bucle, pero bien pódría sacarse fuera.
Con esto tendríamos en bar una copia independiente de foo… Pero es una solución muy lenta. Probemos con otra mejorada:
var bar = foo.slice(); |
Genial! Más corto de escribir y, sobre todo, más rápido para Javascript de evaluar. El método slice devuelve los elementos de un array que se le indiquen en sus argumentos. Al no poner ninguno, los devuelve todos, por lo que tenemos una copia perfecta del array.
¿Y esto funciona con un array multidimensional? Sin problemas:
var foo = [ { "name" : "Muzzy in Gondoland", "genre" : "Animation", "year" : "1986", "characters" : [ "Muzzy", "King", "Queen", "Pricess Sylvia" ] }, { "name" : "Muzzy Come Back", "genre" : "Animation", "year" : "1989", "characters": ["Muzzy", "Bob", "Amanda", "King", "Queen" ] } ]; var bar = foo.slice(); console.log( foo ); // [ Object { ... } ] console.log( bar ); // [ Object { ... } ] |
Copiando objetos
Como hemos visto en la introducción, los objetos también se referencian en lugar de copiarse:
var foo = { "name" : "Muzzy", "genre" : "Animation", "year" : "1986", "characters" : [ "Muzzy", "King", "Queen", "Pricess Sylvia" ] }; var bar = foo; bar[ 'name' ] = "Muzzy in Gondoland"; console.log( foo[ 'name' ] ); // Muzzy in Gondoland console.log( bar[ 'name' ] ); // Muzzy in Gondoland |
Para copiar un objeto no podemos hacer uso del método slice ya que éste, es único para los arrays. Tenemos que recurrir a soluciones más sucias…
La mayoría de frameworks tipo jQuery ya implementan un método propio para copiar objetos de forma limpia (al menos a nivel de API):
var bar = $.extend( {}, foo ); |
No resulta muy intuitivo, pero básicamente se trata de extender (extend) un objeto vacío (el primer argumento) con otro dado (el objeto original). El resultado, como cabe esperar, es un nuevo objeto idéntico al primero.
Para emular esto con Javascript puro, tenemos que iterar con algún tipo de función:
function clone( obj ) { if ( obj === null || typeof obj !== 'object' ) { return obj; } var temp = obj.constructor(); for ( var key in obj ) { temp[ key ] = clone( obj[ key ] ); } return temp; } |
Con este ‘arreglo’, conseguiríamos también nuestro objetivo:
var bar = clone( foo ); bar[ 'name' ] = "Muzzy in Gondoland"; console.log( foo[ 'name' ] ); // Muzzy console.log( bar[ 'name' ] ); // Muzzy in Gondoland |
Muy aparatoso, pero funciona: se comprueba que realmente se le pasa un objeto y luego se itera por sus propiedades para asignarlas a un objeto temporal. Este ejemplo utiliza recursión para poder alcanzar tantos niveles de profundidad como se requiera.
El truco del JSON
Hay una forma quizá más sencilla de clonar objetos y es utilizando el truco del JSON: básicamente consiste en convertir un objeto en una cadena, copiar dicha cadena en la nueva variable, y reconvertir la cadena en objeto. Así, tendríamos nuestros dos elementos independientes:
var bar = JSON.parse( JSON.stringify( foo ) ); bar[ 'name' ] = "Muzzy in Gondoland"; console.log( foo[ 'name' ] ); // Muzzy console.log( bar[ 'name' ] ); // Muzzy in Gondoland |
Voilá! Funciona! Y con una única línea de código. Solo tiene una pega: la retrocompatibilidad. Si tenemos que dar soporte a entornos antiguos, la primera opción (la de la función) es la mejor. Sin embargo, si nuestro objetivo son los navegadores con menos de 10 años a cuestas, podemos optar por el JSON.
Estaría también el tema del rendimiento: JSON es mucho más lento que la función pero, a menos que estemos hablando de clonar objetos enormes, la diferencia no será crítica… Aún así, si existen muchos niveles de anidación en el objeto original, la función recursiva tampoco será especialmente rápida…
Conclusión
Copiar array y objetos en Javascript no es tan sencillo como asignar a una variable el contenido de otra. Tenemos que jugar un poco con el lenguaje para obligarlo a realizar una copia en lugar de únicamente asignar una referencia que puede provocar errores inesperados si el objetivo es manipular la copia pero preservando el original.
En este post, hemos visto algunas formas de hacerlo.
Comentar, que el método del JSON, no copiaría métodos del objeto, si es para trabajar única y exclusivamente con estructuras de datos si funciona, y me parece una buena opción 🙂
Buen apunte lo de los métodos.
Gracias!
Ahora si! Ya lo entiendo. He tenido varios problemas con todo esto y que entre unas cosas y otras había deducido una forma muy similar, pero nada como leerlo claramente!! Gracias.
Por cierto, otra forma de hacer una copia es unir el array anterior a otro vacío con concat que tras leer he comprendido que se podría hacer y va un poco en la línea del extend.
La solución del JSON.parse es la que utilizaba, antes de usar underscore :D. Este internamente usa más métodos suyos y podría asemejarse un poco a tu primera solución del recorrido de propiedades.
Interesante lo de concatenar el array a otro vacío. Ciertamente es muy similar al extend de jQuery.
Gracias!
Por cierto, acabo de ver el comentario de @epplestun , muy buen apunte! no lo sabía!!
Como todos, un artículo genial. Nunca se me hubiese ocurrido el truco de JSON
Otra buena solución podría ser la de extender mediante prototipo el objeto Object:
Si clonamos un elemento DOM, usamos diretamente el método cloneNode 🙂
@epplestun El otro día me explicaron el por que no es bueno extender el objeto de algo. Si los frameworks los hicieran, como hace PrototypeJS, sería muy intrusivo. Cada uno extendería una propiedad a su antojo por que le parece bien, y luego tus objetos andarían contaminados de cosas no estandar. No creo que eso sea muy cómodo. A mi personalmente esa conversación me convenció para no hacer un extend a no ser que sea muy muy necesario, o sea para hacer algo que ofrezca retrocompatibilidad como podría ser la función trim que en ES5 si está soportada como prototype del String y antes no.
Completamente de acuerdo: extender el prototipo de una primitiva no es buena idea en JS salvo que todo el código esté bajo control y no vaya a ser utilizado junto al de terceros en otros proyectos.
Yo limitaría esa opción a una aplicación personal cerrada en la que no voy a liberar, compartir o reutilizar el código.
Estoy de acuerdo con los dos, simplemente quería dejar otra manera de poder hacerlo 😉
Muy bueno el artículo aunque matizaría un par de puntos:
1. El `slice` no funciona con arrays multidimensionales, solo duplica la primera dimensión
2. Crear una copia exacta
Para duplicar un objeto necesitas dos cosas, copiar sus propiedades propias y prototipar su prototipo, con la excepción del array y de las funciones que tienen comportamientos especiales.
Cuando haces
Si `obj` es un objeto plano estarías invocando `Object`, que al ser nativa si es invocada sin new te devuelve un objeto. Pero si `obj` es un tipo no-nativo probablemente su constructor no devuelva nada
En cuyo caso temp sería `undefined` rompiendo el código. Supongo que tendría más sentido invocar al constructor para crear una copia
Pero en ese caso tienes otro problema, que pasa si el constructor tiene una lógica un poco compleja…
Entonces el código fallaría porque `undefined` (provider) no tiene el método `get`.
En mi opinión no debería invocarse el constructor, sino que debería prototiparse el mismo prototipo y copiar las propiedades a mano
Esto sería válido si nos quedamos en ECMAScript3, porque en ECMA5 podemos cruzarnos con accessors, a mi me pasó
Entonces tendríamos que actualizar `clone()` para que soporte properties descriptors que deja el performance por los suelos…
La conclusión es: si necesitas clonar un array usa `.slice()`, pero si necesitas copiar un objeto piensalo dos veces, probablemente hay una forma mejor de hacer lo que intentas hacer.
Graciaaaaaas!, llevaba días tratando de entender porque no funcionaba al igual que con una variable normal