В любом языке программирования есть типы данных, которыми программисты описывают субъекты, чтобы в дальнейшем работать и, в случае необходимости, обрабатывать их. Язык JavaScript не исключения, он имеет примитивные (Number, String, Boolean, Symbol и др) и ссылочные (Array, Object, Function, Maps, Sets и др) типы данных. Нужно отметить, что примитивные типы данных, являются иммутабельными — их значения не могут быть модифицированы, а только перезаписаны новым полным значением, а вот с ссылочными типами данных все наоборот. Например, объявим переменные типа Number и Object:

let num = 5;
let obj = { a: 5 };

Мы не можем модифицировать переменную num, нам лишь можно перезаписать ее значение, а вот переменную obj мы модифицировать можем:

let num = 10;
let obj = { a: 5, b: 6 };

Как видим, в первом случае мы перезаписали значение переменной, а во втором расширили объект. Отсюда делаем вывод, что примитивные типы данных невозможно расширять, а с ссылочными типами данных мы можем это делать, даже с модификатором const.

Последние можно заморозить, к примеру, с помощью Object.freeze(obj), но данная тема выходит за рамки статьи (ссылки для любознательных Object.defineProperty, защита объекта от изменения).

Как типы данных передаются в функции в JavaScript? Каждый js-программист наверняка без труда ответит на этот вопрос, но все же скажем: примитивные типы данных передаются в функцию всегда только по значению, а ссылочные всегда только по ссылке. И вот тут с последними, в некоторых ситуациях, возникают проблемы. Давайте рассмотрим пример:

const arr = [0, 1, 2, 3, 4, 5];
console.log("Array:  ", arr); // output: Array:  [0, 1, 2, 3, 4, 5]

В данном случае, мы просто объявили массив чисел и вывели его в консоли. Теперь передадим его в функцию, которая возвращает новый массив, но с добавлением некоторого значения во втором аргументе, к концу нового массива:

const arr = [0, 1, 2, 3, 4, 5];
console.log("Old array: ", arr); // "Old array: " [0, 1, 2, 3, 4, 5]
const newArr =  insertValToArr(arr, 15);
console.log("New array: ", newArr); // output: "New array: " [0, 1, 2, 3, 4, 5, 15]
console.log("Old array: ", arr); // output: "Old array: " [0, 1, 2, 3, 4, 5, 15]
function insertValToArr(arr, val) {
  const newArr = arr;
  newArr.push(val); 
  return newArr;
}

Как видим из консольных выводов, поменялся не только новый массив, но и старый. Это произошло потому что в функции insertValToArr мы просто присвоили один массив к другому const newArr = arr, а следовательно создали ссылку на существующий массив и когда попытались модифицировать уже новый массив, он сослался на область памяти старого массива и, грубо говоря, изменил ее. А так как оба массива ссылаются на одну и ту же область памяти, они и будут иметь одно и тоже значения. Давайте изменим нашу функцию, чтобы она не могла изменить старый массив:

const arr = [0, 1, 2, 3, 4, 5];
const newArr =  insertValToArr(arr, 15);
console.log("New array: ", newArr); // output: "New array: " [0, 1, 2, 3, 4, 5, 15]
console.log("Old array: ", arr); // output: "Old array: " [0, 1, 2, 3, 4, 5]
function insertValToArr(arr, val) {
  const newArr = [];
  arr.forEach((value, ind) => { newArr[ind] = value});
  newArr.push(val);
  return newArr;
}

Старый массив не изменился, потому что мы получили каждый его элемент и по отдельности присвоили значения элемента к элементам нового массива. Теперь последний имеет отдельную область памяти и если его изменить, то старого массива это никак не коснется. Но все это простые примеры и в реальных программах, скорее всего будут встречаться не только одномерные массивы, а и двумерные, реже трехмерные, еще реже четырехмерные. Преимущественно они встречаются в виде ассоциативных массивов (хеш-таблицы). В JavaScript чаще всего это объекты.

Давайте рассмотрим стандартные способы копирования объектов, которые предоставляет JavaScript — Object.assign() используется для копирования значений всех собственных перечисляемых свойств из одного или более исходных объектов в целевой объект. После копирования он возвращает целевой объект. Рассмотрим его:

const obj = { a: 1 };
const newObj = Object.assign({}, obj);
console.log(newObj); // output: { a: 1, b: 2 }
console.log(obj); // output: { a: 1, b: 2 }

И снова старая проблема, мы ссылаемся на одну и ту же область памяти, что приводит к модификации двух объектов сразу — изменяя один будет изменяться и другой. Что же делать, если нам нужно получить копию сложного объекта (с множественным разветвлением) и при этом, изменяя объект, не модифицировать другой? Ответить на этот вопрос и есть цель данной статьи. Дальше мы рассмотрим, как написать собственный метод глубокого клонирования (копирования) объектов любого ветвления. Преступим к написанию кода.

1 шаг: объявляем и инициализируем объект Z, а также делаем консольный вывод для сравнения до и после клонирования:

const Z = {
  a: 5,
  b: { g: 8, y: 9, t: { q: 48 } },
  x: 47,
  l: { f: 85, p: { u: 89, m: 7 }, s: 71 },
  r: { h: 9, a: 'test', s: 'test2' }
};
console.log('Z object before cloning: ', Z);

image

2 шаг: присваиваем объект Z объекту refToZ для того, чтобы показать разницу между обычным присваиванием и глубоким клонированием:

const refToZ = Z;

3 шаг: присваиваем объект Z объекту Y с помощью функции deepClone и добавим новое свойство к объекту Y. После чего выведем эти два объекта в консоли:

const Y = deepClone(Z);
function deepClone(obj) {
  const clObj = {};
  for(const i in obj) {
    if (obj[i] instanceof Object) {
      clObj[i] = deepClone(obj[i]);
      continue;
    }
    clObj[i] = obj[i];
  }
  return clObj;
}
Y.addnlProp = { fd: 45 };
console.log('Z object after cloning: ', Z);
console.log('Y object: ', Y);

image

image

В консоли мы отчетливо видим, что изменяя объект Y, добавив новое свойство, мы не изменяем объект Z и последний не будет иметь свойство addnlProp в своем теле.

4 шаг: изменим свойство x, которое есть в теле объектах Z и Y и снова выведем оба объекта в консоль:

Y.x = 76;
console.log('Y object: ', Y);
console.log('Z object: ', Z);

image

image

Изменяя одно и то же свойство в объекте Y, мы не затрагиваем свойство в теле Z.

5 шаг: на последнем шаге мы просто для сравнения к объекту refToZ добавим свойство addToZ со значением 100 и выведем все три объекта в консоль:

refToZ.addToZ = 100;
console.log('refToZ object: ', refToZ); 
console.log('Z object: ', Z); 
console.log('Y object: ', Y); 

image

image

image

Изменив объект refToZ мы изменили и Z, однако Y не затронули. Отсюда сделаем вывод, что наша функция создает независимый новый объект со свойствами и их значениями из существующего объекта (код реализации функции deepClone можно найти на CodePen).

Немного остановимся над реализацией данной функции. Последняя находит, любую вложенность объекта, даже не зная ее. Как она это делает? Все дело в том, что в данном случае мы применяем известный алгоритм для графов — поиск в глубину. Объект — граф, который имеет одну или множество веток, которые в свою очередь могут иметь свои ветки и тд. Чтобы нам найти все нам нужно зайти в каждую ветку и продвигаться в ее глубь, таким образом мы найдем каждый узел в графе и получим его значения. Поиск в глубину можно реализовать 2 способами: рекурсией и с помощью цикла. Второй может оказаться быстрее, так как не будет заполнять стек вызовов, что в свою очередь делает рекурсия. В нашей реализации функции deepClone мы применили комбинацию рекурсию с циклом. Если хотите почитать книги об алгоритмах, то советую начать Адитъя Бхаргава «Грокаем алгоритмы» или более углубленное Томас Кормен «Алгоритмы: построение и анализ».

Подведем итоги, мы вспомнили об типах данных в JavaScript и как они передаются в функции. Рассмотрели простой пример независимого клонирования простого одномерного массива. Рассмотрели одну из стандартных реализации языка для копирования объектов и в итоге написали маленький (по размеру) функцию для независимого глубокого клонирования сложных объектов. Подобная функция может найти свое применения как на стороне сервера (Node js), что более вероятно, так и на клиенте. Надеюсь данная статья была полезна для вас. До новых встреч.

Комментарии (36)


  1. Sombressoul
    18.12.2019 17:54

    let originalObject = {a: 1, b: 2, c: {d: 3, e: 4}};
    let clonedObject = JSON.parse(JSON.stringify(originalObject));
    
    clonedObject.c.d = 7;
    
    console.dir(originalObject); // {"a":1,"b":2,"c":{"d":3,"e":4}}
    console.dir(clonedObject); // {"a":1,"b":2,"c":{"d":7,"e":4}}
    


    И зачем огород городить? :)

    Другая тема — копирование методов…


    1. Sombressoul
      18.12.2019 19:43

      Разница в производительности: тут тест (без смс и регистрации) — преобразование в JSON и обратно — медленнее в 10 раз.


      1. magam9 Автор
        18.12.2019 21:04

        Не знал про данный сервис для проверки скорости функций через бечмарк, буду юзать теперь, спасибо за отзыв )


      1. vp_arth
        17.12.2019 22:50

        Разница не всегда такая большая.
        Вопрос ещё и в том, а нужна ли этой функции высокая производительность в каждом конкретном случае.
        Иногда сериализация/десериализация таки самый подходящий путь.


  1. dolovar
    18.12.2019 17:55

    Поиск на Хабре.
    Поиск в Гугле.

    P.S. Если искать по сути, а не по названию функции, то результатов может оказаться немного больше.


  1. aalekhin
    18.12.2019 21:04

    Статья полезна для понимания как хранятся «ссылочные» типы данных, а что касается глубокого клонирования, то я еще не встречал ничего лучше:
    const obj = { ... }
    const clObj = JSON.parse(JSON.stringify(obj))


    1. magam9 Автор
      18.12.2019 21:11

      Комментарием выше пользователь Sombressoul сделал фаст-тест функции, которую я предложил и через JSON.parse, огромное спасибо ему за это. Как оказалась JSON.parse уступает в производительности в 10 раз. Ссылка натест


    1. greatkir
      18.12.2019 22:40

      JSON не может содержать функции. А значит, склонировать без потерь получится не любой объект...


    1. Sirion
      17.12.2019 23:10

      Вероятно, у вас не очень хороший кругозор. Потому что клонирование через JSON хорошо ровно одним — простотой реализации на коленке. Всем остальным плохо.


    1. dolovar
      18.12.2019 10:35

      не встречал ничего лучше
      Навскидку:

      JSON.parse(JSON.stringify(undefined));

      let d = new Date();
      JSON.parse(JSON.stringify(d));

      let child = {}, parent = {};
      child.parent = parent; parent.child = child;
      JSON.parse(JSON.stringify(child));


  1. surefire
    18.12.2019 21:35
    +3

    Как бы сказать помягче?
    На выходе из вашей функции получается совсем не то, что должно быть.


    Z object after cloning:  { a: [ 1, 2, 3 ] }
    Y object:  { a: { '0': 1, '1': 2, '2': 3 }, addnlProp: { fd: 45 } }


    1. magam9 Автор
      18.12.2019 22:17

      Да, полностью согласен, есть недоработка работы с массивом. Хоть и писал и писал ее для объекта, но про такое как массив не стоило забывать. Спасибо, за отличный тык носом )


      1. Sirion
        17.12.2019 23:11

        Вообще, прошу прощения за немного злобный комментарий, но перед тем, как учить, желательно убедиться, что научишь хорошему.


        1. magam9 Автор
          17.12.2019 23:31

          Не думаю, что вы со зла пишете )
          Наоборот критика — шикарно, особенно, когда конструктивная. В комментариях мне указали на мои ошибки, где я не прав, даже предложили как улучшить метод для клонирования и это здорово. Во время обсуждения мы учимся и проверяем свои силы. Так что все окей


    1. magam9 Автор
      17.12.2019 22:56
      -1

      Ваш комментарий меня побудил немного изменить функция для клонирования

      function deepClone(obj) {
        const test1 = { a: { d: [1, 2, 3]}, b: 5 };
      const test2 = deepClone(test1);
      test2.a.d.push('arr')
      test2.b = 9;
      console.log('test1 object: ', test1);
      console.log('test2 object: ', test2);
      
      function deepClone(obj) {
        const clObj = {};
        for(const i in obj) {
          if (obj[i] instanceof Object && !(obj[i] instanceof Array)) {
            clObj[i] = deepClone(obj[i]);
            continue;
          }
          clObj[i] = obj[i];
        }
        return clObj;
      }

      Добавив одно условие, я скопировал массив верно, но при этом он остался зависимым от массива из другого объекта. Хотя подобное следовало ожидать, но все же интересная особенность

      image

      Все таки добью эту функцию, чтобы работала корректно не только с объектами, а как минимум еще с массивом. После чего внесу правки в статью. Еще раз, спасибо вам за отзыва )


      1. vp_arth
        17.12.2019 23:11
        +1

        Вы не «скопировали массив верно». Это тот же самый массив.

        По хорошему, Вам нужен вызов `obj[i].map(deepClone)`, в массиве могут быть не только скаляры.


        1. magam9 Автор
          17.12.2019 23:17

          Я имею ввиду структура массива осталось верной, он не перевел его в объект изменив числовую индексацию на строковую. А так да, ссылается на один и тот же массив.
          Отличная идея с map, нужно попробовать ваш вариант на практике и посмотреть на результат, спасибо )


          1. vp_arth
            17.12.2019 23:55

            Не за что. :)

            Ещё один совет: когда наиграетесь с этим велосипедом, поищите удовлетворяющую Вас open source альтернативу и используйте её — ни к чему тратить силы на самостоятельную поддержку подобных функций.

            На примере deepClone: рано или поздно Вы столкнётесь с циклическими ссылками и начнёте решать эту проблему. Есть несколько способов её решения, Вам придётся выбирать. Потом будут новые проблемы, возможно захочется сохранить прототипы… Путь даже такой элементарной штучки довольно долог. Но в существующих реализациях зачастую весь этот путь уже пройден. И для решения вновь возникающих проблем у Вас есть огромный штат добровольцев(включая Вас).

            В общем, если проблема не академическая или «на поиграться», всегда стоит начинать с поиска существующих решений. Жизнь одна, если каждый будет распыляться на подобные мелочи. ни у кого не хватит времени создать что-то стоящее.

            Хм, растёкся я тут мыслью по древу. извините.


            1. Zenitchik
              18.12.2019 15:58
              +1

              мыслью по древу

              Мысью. Т.е. — белкой.


              1. vp_arth
                18.12.2019 16:13

                Боян растекался мысью, а я вот мыслью.

                Благодарю, не знал.


  1. Dartess
    18.12.2019 22:10

    Думаю, в комментариях есть место ещё и для такой ссылочки: Deep-copying in JavaScript


  1. DarkGenius
    17.12.2019 23:20

    Я бы еще рассмотрел кейс клонирования объектов с сохранением их прототипов: объекты могут содержать методы, которые мы хотим иметь и у клонов.


    1. magam9 Автор
      17.12.2019 23:32

      Отличный вариант, нужно подумать над этим. Хорошая пища для размышления, спасибо )


  1. apapacy
    18.12.2019 01:41

    Каждый js-программист наверняка без труда ответит на этот вопрос, но все же скажем: примитивные типы данных передаются в функцию всегда только по значению, а ссылочные всегда только по ссылке.

    Я не отношусь к категории "каждый программист" потому что думаю что в JS все параметры передаются в функцию по значению. Просто для объектов в качестве значения выступает ссылка на объект. Вы внутри функции можете поменять поля в этом объекте. Но не сможете присвоить параметру новое значение.


    Второй момент про алгоритм клонирования. Вцелом направление правильное, на как минимум нужно учес еще и клонирование Array. Хотя если по-хорошему то нужно еще как-то работать с прототипами а также и с другими типами JS


    То есть если у Вас объект Dog то его кот также должен быть объектом типа Dog


    1. magam9 Автор
      18.12.2019 10:28

      Да полностью согласен с вами, нужно учитывать множество факторов при алгоритма клонирования. Хочу отметить, что аргументы в функцию все таки передаются двумя способами, на мой взгляд. Просто по ссылке они передаются не явным образом. Мы не можем явно передать аргумент данным способом как это делается в C# или C++, та и в PHP. Это как преобразование типов в JS — компилятор автоматически это сделает все за нас и преобразует в нужный тип, если это будет возможно


      1. apapacy
        20.12.2019 02:09

        Способ один — значение. В качестве значения используется ссылка. Тем кто не программировал на более древних языках (Пскаль, Си, PL/1) это разница не совсем ясна
        Еси переменная передается по ссылке то ее значение меняется если его изменить внутри функции. В JS Вы можете изменить сам объект и это будет видно извне.


        Но если вы сделаете так


        var obj0 = {}
        function(obj1) {
        obj1 = {}
        return obj1
        }
        obj0 !== obj1 // true


        Если бы объект передалася по ссылке то


        obj0 === obj1 // true


      1. vp_arth
        20.12.2019 10:55

        Просто по ссылке они передаются неявным образом.

        Передача переменной по ссылке на примере php: 3v4l.org/TQXg1

        В javascript ничего подобного нет, если вы что-то присваиваете переменной, старое значение теряется.
        Просто для объектов передаётся значение указателя на тот же объект, что позволяет мутировать этот объект, но не позволяет изменить саму переменную.


  1. andres_kovalev
    18.12.2019 02:36

    _.deepClone() ?


  1. adictive_max
    18.12.2019 05:27

    let num = 10;
    let obj = { a: 5, b: 6 };
    Как видим, в первом случае мы перезаписали значение переменной, а во втором расширили объект.
    Точно расширили?


  1. hrie
    18.12.2019 10:35

    function insertValToArr(arr, val) {
      const newArr = [];
      arr.forEach((value, ind) => { newArr[ind] = value});
      newArr.push(val);
      return newArr;
    }
    

    forEach здесь не нужен, лучше использовать map:
    function insertValToArr(arr, val) {
      const newArr = arr.map((value) => value);
      newArr.push(val);
      return newArr;
    }
    

    А ещё лучше сократить до однострочника:
    const insertValToArr = (arr, val) => arr.map((value) => value).concat(val);
    


    И в JS есть 7 примитивных типов данных и всего один ссылочный — Object (MDN). Array, Function, Maps, Sets и т. д. — это всё реализации объекта.


    1. magam9 Автор
      18.12.2019 10:41

      У вас отличные варианты для реализации функции insertValToArr. От себя могу добавить, что можно подобное сделать и через spread, но данный вариант скорее подойдет для одномерных массивов.

      function insertValToArr(arr, val) {
        return [...arr, val];
      }


      1. hrie
        18.12.2019 12:56

        Да, про спреды я не подумал.
        А вариант с map, действительно легко адаптируется для клонирования многомерных массивов:

        const insertValToArr = (param) => (
            Array.isArray(param) ? param.map(insertValToArr) : param
        );
        


  1. authoris
    18.12.2019 23:07

    Добавлю еще, что проверка по `instanceof` не всегда будет `true` для объектов.

    Мы всегда можем создать объект без прототипа через Object.create. Так как у такого объекта не будет прототипа Object, то и логично, что instanceof не найдет его там.

    const a = Object.create(null);
    console.log(a instanceof Object) // false
    const b = {};
    console.log(b instanceof Object) // true
    


    1. Varim
      18.12.2019 23:31

      Как тогда определить что это «объект»?


      1. vp_arth
        19.12.2019 01:09

        typeof a === 'object'?


        1. Zenitchik
          19.12.2019 15:05
          +1

          typeof a === 'object && a!==null