Clonando objetos en JavaScript

Valores primitivos y objetos

Cuando programamos, es usual invocar funciones y pasar argumentos, sin embargo es posible que no conozcamos el manejo que le da JavaScript a esos parámetros, motivo por el cual a veces obtenemos resultados inesperados.

💡 Primero que todo vamos a aclarar los términos argumetos y parámetros:

  • argumetos hace referencia a los valores que le enviamos a una función.
  • parámetros hace referencia a los valores que recibe la función y son especificados en la firma de la función.
    // a y b son los parámetros de suma
    function suma (a, b) {
        return a + b;
    }

    var x = 5;

    // x y 8 son los argumentos enviados a suma
    var r = suma(x, 8);

⭐ Al enviar argumentos con valor primitivo (string, number, boolean, null, undefined), el parámetro de la función que recibe el valor crea una copia by value.
Esto significa que cualquier cambio que hagamos sobre ese parámetro, sólo será válido dentro de la función y no afectará al argumento original. Veamos el siguiente ejemplo:

    function test(msg) {
        msg = "modificado";
        console.log(msg);
        return msg;
    }

    var msg1 = "JavaScript rocks!",
        msg2 = test(msg1);

    console.log(msg1); //"JavaScript rocks!"

😮 Podemos ver que después de ejecutar la función test(), el argumento msg1 sigue intacto, aunque dentro de la función fue modificado. Esto mismo ocurre cuando asignamos un valor primitivo a una variable; se crea una copia de ese valor.

    var msg1 = "JS is Awesome!",
        msg2 = msg1;

    msg1 = "JS sucks!";

    console.log("msg1 = ", msg1); // "JS sucks!"
    console.log("msg2 = ", msg2); // "JS is Awesome!"

➡ Sin embargo cuando trabajamos con objetos (Object, Array, RegExp, Date...), ya sea que lo pasemos como argumento de una función o que lo asignemos a otra variable, en realidad estamos pasando su referencia en memoria (by reference) y no una copia de su valor. 😳 Esto significa que cualquier cambio que hagamos sobre la “copia” del objeto, se verá reflejado en el objeto original. Veamos un ejemplo:

    function setCenter(x) {
        x.top = "50%";
        x.left = "50%";
    }

    var point1 = { top: 1, left: 1 };
        
    // pensamos crear una copia,
    // pero en realidad pasamos la referencia
    var point2 = point1;

    setCenter(point2);

    console.log("point1: ", point1);
    console.log("point2: ", point2);

😕 Observamos que después de invocar la función setCenter(), ambos objetos fueron modificados! Esto ocurrió porque point2 no almacena un valor primitivo, sino un Object, de tal manera que al asignarlo a otra variable, o enviarlo como parámetro a una función, en realidad estamos pasando la referencia en memoria del objeto original, y NO creando una copia.

Veamos un ejemplo más:

    function View (name) {
        "use strict";
        this.name = name || "Home";
        this.visits = 0;
    }

    View.calls = 0;

    function visit(x) {
        x.visits = 1 + (x.visits || 0);
        x.referrer = document.referrer;
        x.constructor.calls++;
    }

    var aboutView = new View("About");
    console.log(aboutView);

    visit(aboutView);
    console.log(aboutView);

Como esperábamos, después de invocar la función visit() se modificó el objeto original aboutView.

¿Y qué dice el standard?

A primitive value is a member of one of the following built-in types: Undefined, Null, Boolean, Number, and String; an object is member of the remaining built-in type: Object; and a method is a function associated with an object via a property. — ECMAScript

Un valor primitivo es miembro de uno de los siguientes tipos integrados: Undefined, Null, Boolean, Number y String; un objeto es miembro del tipo integrado restante: Object; y un método es una función asociada a un objeto mediante una propiedad

De lo anterior podemos asumir que una función también está asociada con un objeto (by reference) — Lectura recomendada: Data Types In JavaScript

¿Porqué clonar objetos?

⭐ Si queremos crear una verdadera copia, o evitar que una función modifique el objeto original, entonces debemos clonar el objeto.

Clonando un Array

Para clonar el contenido de un array, todo lo que se necesita es utilizar el método slice(), enviando el valor 0 como primer argumento, para copiar todo el array:

    var list = ["A",1,2,3,5,8,13];

    //clonamos el array original (shallow copy)
    var cloned = list.slice(0);

    //modificamos el primer elemento del array original
    list[0] = 9;

    //agregamos un elemento al inicio del array original
    list.unshift("X");

    //agregamos un elemento al final del array clonado
    cloned.push(21);

    console.log("original: ", list);
    console.log("cloned: ", cloned);

En el código anterior clonamos el array original copiando los valores primitivos, por tal motivo podemos alterar los dos arrays independientemente. Sin embargo, si el array contiene elementos de tipo Object, entonces aquellos elementos del array que no sean primitivos, almacenarán una nueva referencia al objeto original, es decir, se creará una copia de su referencia y no de su valor 😮

    var list = [{id: 1, age: 30}, {id: 2, age: 42}];

    //clonamos el array original (shallow copy)
    var cloned = list.slice(0);

    console.log("list[0]: ", list[0]);
    console.log("cloned[0]: ", cloned[0]);

    //modificamos el primer elemento del array clonado
    cloned[0].age = 21;

    //ambos fueron modificados
    console.log("original: ", list);
    console.log("cloned: ", cloned);

Para crear una copia absoluta de todos los elementos del array, debemos iterar recursivamente todos los elementos del array y clonar cada elemento (deep copy).

➡ Ver la implementación Objetos con estructura circular y Deep copying of Objects and Arrays.

Shallow copy vs Deep copy

  • Shallow copy es una copia superficial de los elementos de un array o las propiedades de un objeto, en donde la copia solo tiene un nivel de profundidad. Si un objeto tiene propiedades que son objetos, o un array contiene elementos que son objetos, entonces éstos son copiados por referencia, lo que significa que modificar alguna de sus propiedades resultará en afectar también el objeto original.
  • Deep copy es una copia a profundidad de los elementos de un array o las propiedades de un objeto, en donde la copia se hace recursivamente iterando sobre cada elemento o propiedad a clonar. De esta manera se asegura que cada elemento o propiedad clonada, tenga su propio espacio en memoria. Esto significa que al modificar los valores del objeto clonado, no se afectará el objeto original.

Shallow copy vs Deep copy


➡ Los siguientes métodos de clonación son efectivos para copiar objetos planos (que sigan el estándar JSON), no objetos que posean estructuras circulares o con apuntadores a funciones, en cuyo caso, es mejor crear un clonador que se ajuste a esos requerimientos específicos. (Vea la sección Objetos con estructura circular)

Deep copy usando JSON.parse

    /* @throws TypeError: Converting circular structure to JSON.
       @desc No copia funciones ni estructuras circulares.
       Debe respetar estrictamente los valores permitidos por JSON.
    */
    function cloneJSON (obj) {
        return JSON.parse(JSON.stringify(obj));
    }

Deep copy usando jQuery.extend

    /* @throws RangeError: Maximum call stack size exceeded.
       @desc Copia funciones, pero no estructuras circulares.
    */

    //clonar arrays con jQuery
    function jCloneArray (arr) {
        return jQuery.extend(true, [], arr);
    }

    //clonar objetos con jQuery
    function jCloneObject (obj) {
        return jQuery.extend(true, {}, obj);
    }
🐱 Poniendo todo junto…
    (function () {
        var list = [{x:2, y:3}, {x:5, y:8}],
            cloned = list.slice(0); //shallow copy

        //añadimos al final del array original
        list.push({x:13, y:21});

        console.log("Shallow copy (slice)");
        console.log("original:", list); //3 elementos
        console.log("clonado:", cloned); //2 elementos

        //modificamos el item[1] clonado
        cloned[1].x = null;
        console.log("original, ver [1]:", list);
        console.log("shallow copy slice:", cloned);

        //clonamos con deep copy
        var clone1 = cloneJSON(list),
            clone2 = jCloneArray(list);

        //modificamos el item[0] del array original
        list[0].x = "original";
        console.log("original, ver [0]:", list);
        console.log("deep copy JSON:", clone1);
        console.log("deep copy jQuery:", clone2);
    })();

Clonando DOM Nodes

Para clonar nodos DOM (objetos HTML) se debe tener en cuenta que algunas de las propiedades del nodo hacen referencia hacia sí mismo. Veamos el siguiente ejemplo para dar mayor claridad:

    // creamos un elemento <p>
    var pnode = document.createElement("p");
        text = document.createTextNode("I AM THE WEASEL");

    pnode.id = "mytest";
    pnode.appendChild(text);
    document.body.appendChild(pnode); 

    var node1 = document.getElementById("mytest"),
        node2;

    // accedemos al nodo padre y buscamos al hijo mytest
    node2 = node1.ownerDocument.getElementById("mytest");
    node2.innerHTML = "texto modificado";
    console.log([node1, node2]);

😮 En el ejemplo anterior tenemos una estructura con referencia circular al acceder la propiedad ownerDocument, ya que ésta apunta al documento raíz de node1, y ésta a su vez, posee una referencia de nuevo hacia el nodo mytest; es decir que las variables node1 y node2 apuntan al mismo objeto.

:/ Clonar nodos DOM que poseen estructuras circulares no es posible con los métodos vistos anteriormente (JSON.parse y jQuery.extend). 💡 Para lograr esta tarea podemos utilizar el método cloneNode() o el método clone() de jQuery.

    var node1 = document.getElementById("mytest"),
        //true: deep copy, false: shallow copy
        node2 = node1.cloneNode(true);

    node1 = jQuery("#mytest");
    node2 = node1.clone(); //default: deep copy

Nota: Los elementos clonados crean un duplicado del nodo, incluyendo todos los atributos y sus valores (el atributo id debe ser modificado antes de insertar el nodo en el documento). El elemento clonado no tiene padre (.parentNode = null) hasta que sea insertado en el documento, bien sea usando appendChild(), append() o alguno de los mecanismos para la inserción de nodos.

Add elements using jQuery
DOM Insertion, Outside
DOM Insertion, Inside

Objetos con estructura circular

Clonar objetos que poseen estructuras circulares es una tarea que requiere un poco más de esfuerzo; en la sección anterior se hizo énfasis en la clonación de nodos DOM, pero ¿que pasa si el objeto que queremos clonar no es un nodo DOM, sino un objeto plano con propiedades que se referencian entre sí?… veamos el siguiente caso:

    var freeman = {
        name: "Gordon Freeman",
        character: "Freeman",
        friends: ["Barney Calhoun"]
    };

    var david = {
        name: "David Rivera",
        character: "jherax",
        friends: ["John Carmack"]
    };

    david.friends.push(freeman);
    freeman.friends.push(david);
    

Acá tenemos dos objetos con la propiedad friends en donde el objeto freeman.friends tiene una referencia hacia david, y a su vez david.friends tiene una referencia hacia freeman, por lo cual hay referencias cruzadas o estructura circular entre los dos objetos.

Creando el clonador

  1. Será una función recursiva que retorne un deep copy del objeto que recibe como primer argumento.
  2. Si es un objeto primitivo, retornamos su valor.
  3. Si el objeto es una instancia de: Date, RegExp, Function, String, Number, Boolean, entonces creamos una nueva instancia a partir de su constructor.
  4. Iteramos recursivamente cada una de las propiedades navegables del objeto, y comenzamos de nuevo en el paso 2.
    // Extiende las propiedades del objeto @source en el objeto @dest
    // Retorna un deep copy de @source
    function clonex (source, dest) {
        var prop;

        // determina si @source es un valor primitivo o una función
        if (source === null || typeof source !== "object") return source;

        // determina si @source es un DOM Node
        if (source.nodeName) return source.cloneNode(true);

        // determina si @source es una instancia de alguno de los siguientes constructores
        if (source.constructor === Date || source.constructor === RegExp || source.constructor === Function ||
            source.constructor === String || source.constructor === Number || source.constructor === Boolean) {
            return new source.constructor(source);
        }

        // si el constructor del objeto no es ninguno de los anteriores
        if (source.constructor !== Object && source.constructor !== Array) return source;

        // crea un nuevo objeto e itera recursivamente sus propiedades
        dest = dest || new source.constructor();
        for (prop in source) {
            dest[prop] = clonex(source[prop], dest[prop]);
        }
        return dest;
    }

😛 Hasta ahora nuestro clonador es muy similar a otras soluciones en la web, pero vamos a darle un valor agregado, y es que sea capaz de clonar objetos con estructura circular, ya que si intentamos clonar los objetos freeman o david, obtendremos RangeError: Maximum call stack size exceeded.

    //RangeError: Maximum call stack size exceeded
    var cloned = clonex(freeman);

💡 Lo que este mensaje dice, es que hay una iteración infinita y se alcanzó el máximo de llamadas en el stack para la función recursiva cuando intentaba clonar los objetos de la propiedad friends.

Estructura Circular

Clonando objetos con estructura circular

💡 Una forma de solucionar este problema, es mantener un caché con los objetos clonados, de manera que si ya se creó, entonces pasamos la referencia de ese objeto y así evitamos que se intente crear infinitamente. Utilizaremos un closure que nos proporcionará un estado interno privado para el clonador.

/**
 * Clona un objeto (deep-copy) con soporte para referencias circulares
 *
 * @param  {any} source: el objeto a clonar
 * @param  {Object} dest: (opcional) objeto a extender
 * @return {any} el nuevo objeto clonado
 */
var clonex = (function() {
  var toString = Object.prototype.toString;
  var CONSTRUCTORS = [Date, RegExp, Function, String, Number, Boolean];

  // compara un objeto contra el contexto de ejecución
  function compare(obj) {
    return obj === this;
  }

  function cloner (source, dest, cache) {
    var prop;
    // determina si @source es un valor primitivo o una funcion
    if (source === null || typeof source !== "object") return source;
    // revisa si @source ya ha sido guardado en cache
    if (toString.call(source) === "[object Object]") {
      if (cache.some(compare, source)) return source;
      // guarda en cache la referencia de los objetos creados
      // para prevenir la excepción de estructura circular
      cache.push(source);
    }
    // determina si @source es un DOM Node
    if (source.nodeName) return source.cloneNode(true);
    // determina si @source es una instancia de algún constructor
    if (~CONSTRUCTORS.indexOf(source.constructor))
        return new source.constructor(source);
    if (source.constructor !== Object && source.constructor !== Array)
        return source;
    // crea un nuevo objeto e itera recursivamente sus propiedades
    dest = dest || new source.constructor();
    for (prop in source) {
      dest[prop] = cloner(source[prop], dest[prop], cache);
    }
    return dest;
  }

  // función retornada en el closure
  return function clonex (source, dest) {
    return cloner(source, dest, []);
  };

}());

🐱 Ahora utilicemos nuestro súper clonador 😀

    var freeman, david, cloned;

    function Freeman() {
        "use strict";
        this.name = "Gordon Freeman";
        this.character = "Freeman";
        this.friends = ["Barney Calhoun"];
    }

    david = {
        name: "David",
        character: "jherax",
        friends: ["John Carmack"],
        languages: /javascript|c#|java|vb|sql/i,
        greeting: function() { return "Hi, I am " + this.name },
        info: { birth: new Date() }
    };

    //creamos una instancia
    freeman = new Freeman();

    //creamos la referencia circular
    freeman.friends.push(david);
    david.friends.push(freeman);

    //clonamos el objeto
    cloned = clonex(david);

    //modificamos propiedades del objeto clonado
    cloned.name = "David Rivera";
    cloned.friends.push("Jim Rynor");

    //vemos que el objeto original NO fue modificado
    console.log("original:", david.name, david.friends);
    console.log("clonado:", cloned.name, cloned.friends);

Conclusión

Es importante conocer cómo JavaScript maneja los valores primitivos y los objetos. La intención de éste artículo fue demostrar la importancia que tiene la clonación de objetos para mejorar la calidad de nuestro código, evitando errores comunes o “extraños” comportamientos en nuestra aplicación.

⭐ Recomiendo leer los siguientes artículos:

😎 Happy coding!

Advertisements

10 thoughts on “Clonando objetos en JavaScript

  1. Francamente, muy didáctico. Es muy fácil cometer errores pensando que estamos haciendo lo correcto pero cuando analizamos en profundidad el problema vemos lo equivocados que estamos.
    Muy brillante tu artículo.

  2. Martín Alejandro says:

    Articulos como este deberiamos hacer todos, muchas felicidades, me encanto leer tu articulo, muy bien explicado, y muy buenos ejemplos. Mis admiraciones, sigue así!!

    • Gracias Martín. Este tipo de artículos explicando utilerías me ha gustado, escribiré otros del mismo estilo.

  3. Mi mas sincera enhorabuena…
    Excepcional nivel de JavaScript…
    Da gusto leer determinados fragmentos de código.
    Todo un recital!

Comentarios

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s