Клонирование объектов в JavaScript

Эта статья изначально была размещена на zsoltnagy.eu. Несмотря на то, что это сообщение 2015 года, это была самая популярная статья в моем техническом блоге в 2017 году. По этой причине, чтобы охватить более широкую аудиторию, я делюсь этим сообщением с вами на Medium. Если вам интересно прочитать исходный пост с выделением синтаксиса JavaScript, прочтите мой исходный пост здесь.

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

Клонирование может быть довольно сложным. Прототипное наследование, ссылочные типы и методы, связанные с объектом, могут потребовать специального подхода. Ограничения на клонированные данные могут упростить клонирование. Разработчики обязаны понимать и применять правильный метод клонирования в каждом конкретном случае.

Мелкая копия

Методы клонирования большинства библиотек реализованы с использованием поверхностного копирования. Одним из примеров является _.clone, метод клонирования UnderscoreJs.

Неглубокая копия: все ключи и значения полей исходного объекта копируются в новый объект.

Это определение имеет некоторые значения. Вспомните объект shopTransaction из статьи на прошлой неделе.

var shopTransaction = {
    items: [ { name: 'Astro Mint Chewing Gum' } ],
    price: 1,
    amountPaid: 1000
}
var clonedTransaction = _.clone( shopTransaction );
clonedTransaction.price = 3;
clonedTransaction.items.push( { 
    name: 'Tom&Berry Frozen Yoghurt' 
} );
console.log('clonedTransaction.price = ', clonedTransaction.price );
console.log( 'shopTransaction.price = ', shopTransaction.price );
console.log( 
    'clonedTransaction.items.length = ', 
    clonedTransaction.items.length 
);
console.log( 
    'shopTransaction.items.length = ', 
    shopTransaction.items.length 
);

Оба clonedTransaction и shopTransaction имеют одинаковые свойства. Поскольку price и amountPaid являются типами значений (числами), их значения копируются при клонировании. Свойство items является ссылочным типом. Следовательно, и shopTransaction, и clonedTransaction указывают на один и тот же массив в памяти.

Изменение значения примитивных типов в clonedTransaction не влияет на исходный объект. Однако shopTransaction.items и clonedTransaction.item - это ссылки, указывающие на один и тот же массив. Неважно, какую ссылку мы используем для добавления элемента в массив, результаты будут видны под обеими ссылками. Следовательно, результат выполнения вышеуказанного кода:

clonedTransaction.price =  3
shopTransaction.price =  1
clonedTransaction.items.length =  2
shopTransaction.items.length =  2

Изменение значения с помощью ссылки внутри клонированного объекта также изменяет исходный объект.

Оцените визуальное представление объектов, созданных на pythontutor.com. Щелкните изображение, чтобы просмотреть интерактивную версию примера.

.

Прототипное наследование

Метод расширения UnderscoreJs добавляет все свойства, унаследованные через прототип, как собственные свойства.

var proto = { protoProperty: 'proto' };
var o = Object.create( proto );
var c = _.extend({}, o);
console.log( 'o.hasOwnProperty( "protoProperty" )', o.hasOwnProperty( "protoProperty" ) );
// o.hasOwnProperty( "protoProperty" ) false
console.log( 'c.hasOwnProperty( "protoProperty" )', c.hasOwnProperty( "protoProperty" ) );
// c.hasOwnProperty( "protoProperty" ) true

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

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

Опасны ли неглубокие копии?

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

Например, в BackboneJs реализация метода моделей toJSON по умолчанию выглядит так:

toJSON: function(options) {
    return _.clone(this.attributes);
}

Я не видел жалоб на форумах BackboneJs, касающихся проблемы получения результатов метода toJSON и изменения содержимого атрибутов модели. Причина в том, что метод toJSON в основном используется для одной из двух целей:

  • Сериализация: подготовка полезной нагрузки JSON для запроса AJAX,
  • Презентация: подготовка объекта JavaScript и передача его в шаблонизатор.

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

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

Глубокая копия

При глубоком клонировании объекта все ссылки разыменовываются. Сохраняется только структура объекта, имена ключей и атомарные значения. Глубокая копия требует обхода всего объекта и создания клонированного объекта с нуля.

var shopTransaction = {
    items: [ { name: 'Astro Mint Chewing Gum' } ],
    price: 1,
    amountPaid: 1000
}
var clonedTransaction = deepCopy( shopTransaction );
clonedTransaction.price = 3;
clonedTransaction.items.push({ name: 'Tom&Berry Frozen Yoghurt' } );
console.log('clonedTransaction.price = ', clonedTransaction.price );
console.log( 'shopTransaction.price = ', shopTransaction.price );
console.log( 'clonedTransaction.items.length = ', clonedTransaction.items.length );
console.log( 'shopTransaction.items.length = ', shopTransaction.items.length );

Глубокая копия позволяет изменять член items обоих объектов транзакции по отдельности.

clonedTransaction.price = 3 shopTransaction.price = 1 clonedTransaction.items.length = 2 shopTransaction.items.length = 1

clonedTransaction.price =  3
shopTransaction.price =  1
clonedTransaction.items.length =  2
shopTransaction.items.length =  1

Визуализатор объектов на pythontutor.com показывает, что shopTransaction и clonedTransaction полностью различны: значения недоступны для обоих объектов.

.

Еще одно необходимое условие для надежной функции глубокого клонирования - это обнаружение цикла. Функции клонирования должны завершаться, даже если объект ссылается на себя. Эти объекты можно клонировать, если построена таблица поиска ссылок.

Многие методы клонирования, основанные на промежуточном представлении, терпят неудачу, поскольку промежуточное представление должно быть конечным.

Реализации с ограниченным глубоким копированием

Методы JSON

Существует очень простая реализация для создания глубоких копий объектов JavaScript. Преобразуйте объект JavaScript в строку JSON, а затем снова преобразуйте его в объект JavaScript.

var deepCopy = function( o ) {
    return JSON.parse(JSON.stringify( o ));
}

Ограничения:

  • Объект o должен быть конечным, иначе JSON.stringify выдаст ошибку.
  • Преобразование JSON.stringify должно быть без потерь. Следовательно, методы не разрешены, поскольку элементы типа function игнорируются строковым преобразователем JSON. Значение undefined также не допускается. Ключи объектов со значением undefined опускаются, а неопределенные значения в массивах заменяются на null.
  • Языковые хаки не сработают. Ассоциативные свойства массива не будут отображаться в клонированном объекте. Пример: a = []; a.b = 'language hack';. Значение a.b будет доступно в исходном объекте, но исчезнет из глубокой копии.
  • Прототипом копии становится Object. Все свойства из цепочки прототипов будут отброшены.
  • Члены объекта Date становятся строками ISO-8601, не представляющими часовой пояс клиента. Значение new Date(2011,0,1) становится "2010-12-31T23:00:00.000Z" после выполнения вышеуказанного метода глубокого копирования, если вы живете в Центральной Европе.

Я почти никогда не встречал проблем с этими ограничениями при использовании JSON методов для реализации функции глубокого копирования. По-прежнему важно знать, что потенциально может пойти не так.

jQuery Extend

var deepCopy = function( o ) {
    return $.extend( true, {}, o );
}
var shallowCopy = function( o ) {
    return $.extend( {}, o );
}

Когда первым аргументом $.extend является true, выполняется глубокое расширение. Глубокое расширение пустого объекта аналогично клонированию объекта.

Ограничения:

  • Объект o должен быть конечным, иначе $.extend выдает RangeError за превышение максимального размера стека.
  • Прототипом копии становится Object. Все свойства из цепочки прототипов будут добавлены как собственные.

Существует несколько реализаций глубокого клонирования. Большинство из них каким-то образом ограничено. Вряд ли когда-либо понадобится полагаться на общий метод глубокого клонирования. Когда объект становится слишком сложным для клонирования, это хороший знак того, что код необходимо реорганизовать.

Знайте, что вы клонируете

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

Первоначально опубликовано на www.zsoltnagy.eu 25 июля 2015 г.