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

  • В объекте могут лежать массивы и их лучше копировать как массивы
  • В объекте могут быть поля с символом в качестве ключа
  • У полей объекта бывают дескрипторы отличные от дефолтного
  • В полях объекта могут лежать функции и их тоже нужно клонировать
  • У объекта наконец бывает прототип отличный от Object.prototype

Кому влом читать, поместил под спойлер полный код
function deepClone(source) {
	return ({
		'object': cloneObject,
		'function': cloneFunction
	}[typeof source] || clonePrimitive)(source)();
}

function cloneObject(source) {
	return (Array.isArray(source)
		? () => source.map(deepClone)
		: clonePrototype(source, cloneFields(source, simpleFunctor({})))
	);
}

function cloneFunction(source) {
	return cloneFields(source, simpleFunctor(function() {
		return source.apply(this, arguments);
	}));
}

function clonePrimitive(source) {
	return () => source;
}

function simpleFunctor(value) {
	return mapper => mapper ? simpleFunctor(mapper(value)) : value;
}

function makeCloneFieldReducer(source) {
	return (destinationFunctor, field) => {
		const descriptor = Object.getOwnPropertyDescriptor(source, field);
		return destinationFunctor(destination => Object.defineProperty(destination, field, 'value' in descriptor ? {
			...descriptor,
			value: deepClone(descriptor.value)
		} : descriptor));
	};
}

function cloneFields(source, destinationFunctor) {
	return (Object.getOwnPropertyNames(source)
		.concat(Object.getOwnPropertySymbols(source))
		.reduce(makeCloneFieldReducer(source), destinationFunctor)
	);
}

function clonePrototype(source, destinationFunctor) {
	return destinationFunctor(destination => Object.setPrototypeOf(destination, Object.getPrototypeOf(source)));
}

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

Сама функция deepClone будет принимать 1 аргумент source — источник из которого будем клонировать, а возвращать будет его глубокий клон со всеми указанными выше особенностями:

function deepClone(source) {
	return ({
		'object': cloneObject,
		'function': cloneFunction
	}[typeof source] || clonePrimitive)(source)();
}

Тут все просто, в зависимости от типа данных в source выбирается функция которая умеет его клонировать, и в нее передается сам source.

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

function simpleFunctor(value) {
	return mapper => mapper ? simpleFunctor(mapper(value)) : value;
}

Он умеет делать 2 вещи — map (если ему передана функция mapper) и extract (если ничего не передано).

Теперь разберем сами вспомогательные функции cloneObject, cloneFunction и clonePrimitive. Каждая из них принимает 1 аргумент source конкретного типа и возвращает его клон.

Реализация cloneObject должна учитывать, что массивы имеют так же тип object, ну а в других случаях должна клонировать поля и прототип. Вот ее реализация:

function cloneObject(source) {
	return (Array.isArray(source)
		? () => source.map(deepClone)
		: clonePrototype(source, cloneFields(source, simpleFunctor({})))
	);
}

Массив можно скопировать с помощью метода slice, но так как у нас глубокое клонирование, и массив может содержать не только примитивные значения, используется метод map с описанной выше deepClone в качестве аргумента.

Для других же объектов мы создаем новый объект и оборачиваем его в наш функтор описанный выше, клонируем поля (вместе с дескрипторами) с помощью вспомогательной функции cloneFields, а затем клонируем прототип с помощью clonePrototype.

Вспомогательные функции я опишу ниже. А пока рассмотрим реализацию cloneFunction:

function cloneFunction(source) {
	return cloneFields(source, simpleFunctor(function() {
		return source.apply(this, arguments);
	}));
}

Просто склонировать функцию со всей логикой нельзя. Но можно обернуть ее в другую функцию, которая вызывает исходную со всеми аргументами и контекстом, и возвращает ее результат. Такой «клон» конечно будет удерживать исходную функцию в памяти, зато сам будет «весить» мало и полностью воспроизведет исходную логику. Клонированную функцию завернем в функтор и используя cloneFields скопируем в него все поля из исходной функции, так как функция в JS это тоже объект, просто вызываемый, а следовательно может хранить в себе поля.

Потенциально у функции может быть и прототип отличный от Function.prototype, но я не стал рассматривать этот крайний случай. Одна из прелестей ФП в том, что мы легко можем добавить новую обертку над существующей функцией, чтобы реализовать необходимый функционал.

Последний строительный кирп��чек clonePrimitive служит для клонирования примитивных значений. Но так как примитивные значения копируются по значению (или по ссылке, но являются иммутабельными в некоторых реализациях JS движков), мы можем просто их скопировать. Но так как от нас ждут не чистое значение, а значение обернутое в функтор, который умеет extract при вызове без аргументов, то мы обернем наше значение в функцию:

function clonePrimitive(source) {
	return () => source;
}

Теперь реализуем вспомогательные функции, которые использовались выше — clonePrototype и cloneFields

Для клонирования прототипа clonePrototype будет просто извлекать прототип из исходного объекта и, совершая map операцию над полученным функтором, устанавливать его в целевой объект:

function clonePrototype(source, destinationFunctor) {
	return destinationFunctor(destination => Object.setPrototypeOf(destination, Object.getPrototypeOf(source)));
}

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

function cloneFields(source, destinationFunctor) {
	return (Object.getOwnPropertyNames(source)
		.concat(Object.getOwnPropertySymbols(source))
		.reduce(makeCloneFieldReducer(source), destinationFunctor)
	);
}

makeCloneFieldReducer должна создать нам функцию-редьюсер, которую можно было бы отдать в метод reduce на массиве всех полей исходного объекта. В качестве аккумулятора будет использоваться наш функтор, хранящий целевой объект. Редьюсер должен извлечь дескриптор из поля исходного объекта и назначить его в поле целевого объекта. Но тут важно учесть, что дескрипторы бывают двух видов — с value и с get/set. Очевидно, что value нужно клонировать, а вот с get/set такой потребности нет, такой дескриптор можно отдать как есть:

function makeCloneFieldReducer(source) {
	return (destinationFunctor, field) => {
		const descriptor = Object.getOwnPropertyDescriptor(source, field);
		return destinationFunctor(destination => Object.defineProperty(destination, field, 'value' in descriptor ? {
			...descriptor,
			value: deepClone(descriptor.value)
		} : descriptor));
	};
}

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

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