「clone」タグアーカイブ

【JavaScript】オブジェクトをDeepCopyするclone関数を書いた

*大幅にコードを変えたため、この記事を書き直した。(2015/7/5)。(古い記事はこちら)

JavaScriptのオブジェクトは参照渡しなので複製したい時は自分で同じオブジェクトを作らないといけない。そこでオブジェクトをディープコピーするclone関数を作ってみた。コードは以下。GitHubにもある。

//オブジェクトをディープコピーするための関数;
//第一引数はコピーさせたいオブジェクトを渡す;
//第二引数はオブジェクトをどの程度同質にするかをオブジェクトで指定;
//例えば{descriptor: false, extensible: false}と指定すると
//ディスクリプタはコピー元のオブジェクトと同じにならない(全てtrueになる)、
//そして、オブジェクトの拡張可属性(frozen,sealedなど)は同じにならず、全て拡張可になる;
//指定しなければ全てコピー元のオブジェクトと同じになる;
//第三引数はコピーさせたくない型(親のprototype)を配列で渡す;
//第四引数はコピーさせたくないオブジェクトを配列で渡す;

//使い方;
//clone(object, homogeneity, excludedPrototypes, excludedObjects);
var clone = (function() {
	//引数の型を返す関数;
	var typeOf = function(operand) {
		return Object.prototype.toString.call(operand).slice(8, -1);
	};

	//引数がプリミティブかオブジェクトか判定;
	var isPrimitive = function(type) {
		if(type === null) {
			return true;
		}
		if(typeof type === 'object' || typeof type === 'function') {
			return false;
		}
		return true;
	};

	//アクセサプロパティかデータプロパティか判定;
	var isAccessorDescriptor = function(descriptor) {
		return 'get' in descriptor;
	};

	//descriptorを同じにせず、get,set,value以外のdescriptor全てtrueのプロパティを定義;
	var defineProperty = function(cloneObject, propName, descriptor, cloneParams) {
		//cloneの引数が多すぎるのでbindする;
		var boundClone = function(object) {
			return clone(object, cloneParams.homogeneity, cloneParams.excludedPrototypes, cloneParams.excludedObjects, cloneParams.memo);
		};

		if(isAccessorDescriptor(descriptor)) {
			//アクセサプロパティの場合;
			Object.defineProperty(cloneObject, propName, {
				get: boundClone(descriptor.get),
				set: boundClone(descriptor.set),
				enumerable: true,
				configurable: true,
			});
		}else {
			//データプロパティの場合;
			Object.defineProperty(cloneObject, propName, {
				value: boundClone(descriptor.value),
				enumerable: true,
				configurable: true,
				writable: true,
			});
		}
		return cloneObject;
	};

	//descriptorが同じプロパティを定義する;
	var equalizeDescriptor = function(cloneObject, propName, descriptor, cloneParams) {
		//cloneの引数が多すぎるのでbindする;
		var boundClone = function(object) {
			return clone(object, cloneParams.homogeneity, cloneParams.excludedPrototypes, cloneParams.excludedObjects, cloneParams.memo);
		};

		if(isAccessorDescriptor(descriptor)) {
			//アクセサプロパティの場合;
			Object.defineProperty(cloneObject, propName, {
				get: boundClone(descriptor.get),
				set: boundClone(descriptor.set),
				enumerable: descriptor.enumerable,
				configurable: descriptor.configurable,
			});
		}else {
			//データプロパティの場合;
			Object.defineProperty(cloneObject, propName, {
				value: boundClone(descriptor.value),
				enumerable: descriptor.enumerable,
				configurable: descriptor.configurable,
				writable: descriptor.writable,
			});
		}
		return cloneObject;
	};

	//objectの拡張可属性を同じにする;
	var equalizeExtensible = function(object, cloneObject) {
		if(Object.isFrozen(object)) {
			Object.freeze(cloneObject);
			return;
		}
		if(Object.isSealed(object)) {
			Object.seal(cloneObject);
			return;
		}
		if(Object.isExtensible(object) === false) {
			Object.preventExtensions(cloneObject);
			return
		}
	};

	var cloneNode = function(node) {

		var cloneScript = function(element) {
			var script = document.createElement('script');
			var attrs = element.attributes;
			for(var i = 0, len = attrs.length; i < len; i++) {
				var attr = attrs[i];
				script.setAttribute(attr.name, attr.value);
			}
			script.innerHTML = element.innerHTML;
			return script;
		};

		var cloneNode = function(node) {
			//script要素は再評価するためにcloneScriptでcloneする;
			if(node.tagName === 'SCRIPT') {
				return cloneScript(node);
			}
			//cloneNodeで要素をcloneする;
			var clone = node.cloneNode();
			//子要素があれば再帰的に追加;
			if(node.firstChild) {
				var childNodes = node.childNodes;
				for(var i = 0, len = childNodes.length; i < len; i++) {
					clone.appendChild(cloneNode(childNodes[i]));
				}
			}
			return clone;
		};

		return cloneNode;
	}();

	//型を作成するオブジェクト;
	var sameTypeCreater = {
		//引数のobjectの型と同一の型を返すメソッド;
		create: function(object) {
			var type = typeOf(object);
			var method = this[type];

			//ここで列挙されていない型は対応していないので、nullを返す;
			if(method === undefined) {
				return null;
			}
			return this[type](object);
		},
		Object: function(object) {
			//自作クラスはprototype継承される
			return Object.create(Object.getPrototypeOf(object));
		},
		Array: function(object) {
			return new Array();
		},
		Function: function(object) {
			//ネイティブ関数オブジェクトはcloneできないのでnullを返す;
			try {
				var anonymous;
				eval('anonymous = ' + object.toString());
				return anonymous;
			}catch(e) {
				return null;
			}
		},
		Error: function(object) {
			new Object.getPrototypeOf(object).constructor();
		},
		Date: function(object) {
			new Date(object.valueOf());
		},
		RegExp: function(object) {
			new RegExp(object.valueOf());
		},
		Boolean: function(object) {
			new Boolean(object.valueOf());
		},
		String: function(object) {
			new String(object.valueOf());
		},
		Number: function(object) {
			new Number(object.valueOf());
		},
	};

	//memoオブジェクトを作る関数;
	//一度コピーされたオブジェクトはmemoオブジェクトに保存され;
	//二度コピーすることがないようにする(循環参照対策);
	var createMemo = function() {
		var memo = new Object();
		var types = ['Object', 'Array', 'Function', 'Error', 'Date', 'RegExp', 'Boolean', 'String', 'Number'];
		types.forEach(function(type) {
			memo[type] = {
				objects: [],
				cloneObjects: []
			};
		});
		return memo;
	};

	//実際に呼ばれる関数;
	//objectのプロパティを再帰的にコピーし、cloneObjectを返す;
	function clone(object, homogeneity, excludedPrototypes, excludedObjects, memo) {
		//プリミティブ型はそのまま返す;
		if(isPrimitive(object)) {
			return object;
		}
		//cloneしたくない型を持つobjectであれば、参照で返す;
		if(excludedPrototypes.indexOf(Object.getPrototypeOf(object)) !== -1) {
			return object;
		}
		//cloneしたくないobjectであれば、参照で返す;
		if(excludedObjects.indexOf(object) !== -1) {
			return object;
		}

		//Nodeオブジェクトは自作関数cloneNodeに処理を任せる;
		if(object instanceof Node){
			return cloneNode(object);
		}

		//objectと同一の型を持つcloneObjectを作成する;
		var cloneObject =  sameTypeCreater.create(object);
		//cloneObjectがnullなら対応していないので参照で返す;
		if(cloneObject === null) {
			return object;
		}

		//循環参照対策 objectが既にmemoに保存されていれば内部参照なので、値渡しではなくcloneObjectに参照先を切り替えたobjectを返す;
		var type = typeOf(object);
		var index = memo[type]['objects'].indexOf(object);
		if(index !== -1) {
			return memo[type]['cloneObjects'][index];
		}

		//循環参照対策 objectはcloneObjectとセットでmemoに追加;
		memo[type]['objects'].push(object);
		memo[type]['cloneObjects'].push(cloneObject);


		var propNames = Object.getOwnPropertyNames(object);
		var cloneParams = {
			homogeneity: homogeneity,
			excludedPrototypes: excludedPrototypes,
			excludedObjects: excludedObjects,
			memo: memo,
		};
		//objectのすべてのプロパティを再帰的にcloneして、cloneObjectのプロパティに加える;
		propNames.forEach(function(propName) {
			var descriptor = Object.getOwnPropertyDescriptor(object, propName);

			if(propName in cloneObject) {
				//オブジェクト生成時に自動的に定義されるネイティブプロパティ(lengthなど)なら
				//ディスクリプタも同一にしてプロパティの内容をクローンする;
				equalizeDescriptor(cloneObject, propName, descriptor, cloneParams);
				return;
			}

			//descriptorを全く同じにするか;
			if(homogeneity.descriptor === false) {
				//同じにしないならプロパティの内容だけクローンする;
				defineProperty(cloneObject, propName, descriptor, cloneParams);
			}else {
				//ディスクリプタも同一にしてプロパティの内容をクローンする;
				equalizeDescriptor(cloneObject, propName, descriptor, cloneParams);
			}
		});

		//objectの拡張可属性(preventExtensible, isSealed, isFrozen)を同一にするか;
		if(homogeneity.extensible !== false) {
			equalizeExtensible(object, cloneObject);
		}

		//クローンしたオブジェクトを返す;
		return cloneObject;
	}

	return function(object, homogeneity, excludedPrototypes, excludedObjects) {
		if(homogeneity === null || typeof homogeneity !== 'object') {
			homogeneity = {};
		}
		if(! Array.isArray(excludedPrototypes)) {
			excludedPrototypes = [];
		}
		if(! Array.isArray(excludedObjects)) {
			excludedObjects = [];
		}
		return clone(object, homogeneity, excludedPrototypes, excludedObjects, createMemo());
	};
})();

See the Pen PqRWmw by shigure (@webkatu) on CodePen.

使い方と引数の説明

var cloneObject = clone(object, homogeneity, excludedPrototypes, excludedObjects);
  • 第一引数「object」

    第一引数はコピーしたいオブジェクトを指定する。ただ単にディープコピーしたいのであれば第一引数だけ使えばいい。

    			var a = {a: function() {}, b: [], c: {}};
    			var b = clone(a);
    
    			//参照渡しではなく全てコピーされている;
    			console.log(b === a); //false;
    			console.log(b.a === a.a); //false;
    			console.log(b.b === a.b); //false;
    			console.log(b.c === a.c); //false;
    			
  • 第二引数「homogeneity」

    第二引数はオブジェクトの拡張可属性(Object.preventExtensible, isSealed, isFrozen)や、プロパティのディスクリプタ(enumerableなど)を元のオブジェクトと同一にするかどうかをオブジェクトで指定する。なぜ、この引数を用意したかというと、JavaScriptでは一度拡張不可にしたオブジェクトを再び拡張可にする方法はないからだ。しかし、オブジェクトをクローンすれば中身が同じオブジェクトを拡張可で作り直せるためこの引数を用意した。

    {extensible: false}と引数に指定すると拡張可属性は同一にされず、全て拡張可のcloneObjectが返る。

    {descriptor: false}と指定するとプロパティのディスクリプタはget, set, valueを除いて(enumerable, configurable, writable)全てtrueになる。

    何も指定しないと、全て元のオブジェクトと同一のオブジェクトが返る

    			var a = {};
    			//ディスクリプタ全てfalse;
    			Object.defineProperty(a, 'prop', {
    				get: function() {},
    				enumerable: false,
    				configurable: false,
    			});
    			//オブジェクトを凍結;
    			Object.freeze(a);
    
    			//拡張可属性とディスクリプタは元のオブジェクトと同一にしない;
    			var b = clone(a, {extensible: false, descriptor: false});
    			var descriptor = Object.getOwnPropertyDescriptor(b, 'prop');
    
    			console.log(descriptor.enumerable); //true → デフォルトのディスクリプタ;
    			console.log(descriptor.configurable); //true → デフォルトのディスクリプタ;;
    			console.log(Object.isFrozen(b)); //false → オブジェクトは凍結されていない;
    			
  • 第三引数「excludedPrototypes」

    第三引数はコピーせずにそのまま参照で渡したいオブジェクトの型を配列で指定する。ここでいうオブジェクトの型とは親のprototypeのことで、Object.getPrototypeOf(object)で得られるprototypeのことだ。例えば、関数オブジェクトをコピーしたくない場合は、[Function.prototype]と指定すればいい。後述するが、自作クラスのインスタンスもクラスのprototypeを指定すれば参照渡しされるようになる。

    			var a = {a: function() {}, b: [], c: {}};
    			//Function型とArray型はコピーしない;
    			var b = clone(a, null, [Function.prototype, Array.prototype]);
    
    			console.log(b.a === a.a); //true → 参照で渡された;
    			console.log(b.b === a.b); //true → 参照で渡された;
    			console.log(b.c === a.c); //false → 第三引数で指定していないObject型はコピーされた;
    			

    ちなみにprototypeチェーンは辿らない。あくまでも直接の親のprototypeしか見ない。

  • 第四引数「excludedObjects」

    第四引数はコピーしたくないオブジェクトを直接配列で指定する。

    			var a = {a: function() {}, b: [], c: {}};
    			var b = clone(a, null, null, [a.a, a.b]);
    
    			console.log(b.a === a.a); //true → 第四引数にあるオブジェクトなので参照で渡された;
    			console.log(b.b === a.b); //true → 第四引数にあるオブジェクトなので参照で渡された;
    			console.log(b.c === a.c); //false → 通常どおりコピーされた;
    			

対応している型とオブジェクト

clone関数が対応している型を以下に書いていく。オブジェクトの型はObject.prototype.toString().call(object)で返ってきた文字列で判別している。

  • Object

    ユーザーが「new Object()」や「{}」リテラルで作成したObjectや自作クラスからnewでインスタンス化したオブジェクト、Object.create()で継承したオブジェクト全てに対応している。

    			var a = {a: {}, b: [{}]};
    			var b = clone(a);
    
    			console.log(a.a === b.a); //false;
    			console.log(a.b === b.b); //false;
    			console.log(a.b[0] === b.b[0]); //false;
    			
  • Array

    Arrayオブジェクトも同様。ユーザーが作ったArrayオブジェクトはコピーされる。

    			var a = [[{}], {a: []}];
    			a.a = {};
    			var b = clone(a);
    
    			console.log(a[0] === b[0]); //false;
    			console.log(a[0][0] === b[0][0]); //false;
    			console.log(a[1] === b[1]); //false;
    			console.log(a[1].a === b[1].a); //false;
    			console.log(a.a === b.a); //false;
    			
  • Function

    ユーザーが作成したfunctionオブジェクトはコピーされる。

    			var a = function() {};
    			a.a = {a: function f() {}};
    			var b = clone(a);
    
    			console.log(a === b); //false;
    			console.log(a.a === b.a); //false;
    			console.log(a.a.a === b.a.a); //false;
    			

    ビルトイン関数やビルトインメソッドはコピーされず参照で渡される。

    後述するが、クロージャを使ってる関数オブジェクトは、変数に外からアクセスできないため同じ動作にはならない。

  • Error, Date, RegExp, Boolean, String, Number

    いずれもコピーされる

  • Node

    NodeはcloneNodeを使ってコピーされる。しかし、通常のcloneNode(true)ではNodeを入れ替えた時にscript要素が実行されないのでscript要素はcloneScriptを使ってコピーしている。つまり、cloneされたNodeを代入するとscript要素内のスクリプトも再実行される。

対応していないオブジェクト

基本的に対応しているオブジェクト以外はコピーされず参照で渡される。Math、JSON、arguments、Event、screen、navigator、window、「Node以外のブラウザオブジェクト」などなどは参照で渡される。

また、ビルトイン関数、標準で用意されているメソッドも参照で渡される。setTimeout、eval、Array.prototype.sliceなどは参照で渡される。

その他

  • 循環参照、内部参照

    循環参照や内部参照はcloneObjectでの循環参照や内部参照に切り替えられる。循環参照のオブジェクトが渡されてもスタックオーバーフローはしない。

    			var a = {};
    			a.a = a;
    			var b = clone(a); //スタックオーバーフローしない;
    
    			console.log(a === b) //false;
    			console.log(a.a === b.a) //false; →循環参照先が適切に切り替えられる;
    			console.log(b === b.a) //true → b内で循環参照している;
    			
  • 自作クラス(コンストラクタ)などからprototype継承しているインスタンスオブジェクト

    これもコピーされる。コピーされるcloneObjectはコピー元のインスタンスオブジェクト同様、同じ親からprototype継承をする。正確に言えば、Object.getPrototypeOf()でコピー元のオブジェクトのprototypeを取得し、それを継承する。また、Object.getOwnPropertyNamesで列挙属性関係なく自分で持つプロパティはすべてコピーされる。

    			//newでインスタンスを作る例;
    			var Class = function() {};
    			var instance = new Class();
    			instance.prop = 1;
    			var cloneIns = clone(instance);
    
    			console.log(cloneIns === instance); //false → インスタンスは別;
    			console.log(cloneIns.__proto__ === instance.__proto__); //true → 両方共Class.prototypeを参照している;
    			console.log(cloneIns.prop); //1 → インスタンスプロパティは通常どおりコピーされる;
    
    			//Object.createでインスタンスを作る例;
    			var Person = {};
    			var child = Object.create(Person);
    			child.age = 12;
    			var brother = clone(child);
    
    			console.log(brother === child); //false;
    			console.log(brother.__proto__ === child.__proto__); //true → 両方共Personを参照している;
    			console.log(brother.age); //12 → インスタンスプロパティは通常どおりコピーされる;
    			

    以上のようにほぼ同じインスタンスが作成される。ほぼと書いたのはコンストラクタの隠蔽されている変数にはアクセスできないからで全く同じインスタンスが作成されるわけではないのだ。もしインスタンスをコピーせずに参照で渡したいのであれば、使い方と引数のセクションにも書いたように、cloneの第三引数に、継承しているprototypeを配列で指定する。

    			var Class = function() {};
    			var instance = new Class();
    
    			var cloneIns = clone(instance, null, [Class.prototype]);
    			console.log(cloneIns === instance); //true → コピーされず参照で渡された;
    			

    この第三引数は、主に自作クラスのインスタンスを個別で扱うために用意したが、Functionオブジェクトなど大抵の場合コピーする必要性があまりないオブジェクトも指定できるようになっている。

  • アクセサプロパティ(getterとsetter)

    アクセサプロパティはgetterもsetterも関数オブジェクトとしてコピーされる。

  • 関数オブジェクトのコピーについて

    clone関数は関数オブジェクトもコピーするが、クロージャを使ったプライベートな変数は外から参照できないため、クロージャを利用している関数は不完全にコピーされる。具体的に言うと、関数内のクロージャ変数の参照先がグローバルになる。そのため、特に関数オブジェクトをコピーする必要がないなら、clone関数の第三引数に[Function.prototype]と指定してFunctionオブジェクトは参照渡しすることを推奨する。

終わりに

基本的にJSON.parse(JSON.stringify(object))が可能であればそれでコピーした方がよさそうだ。このclone関数の特徴は自作クラスのprototype継承をしているオブジェクトを継承含め真似る点、Functionオブジェクトまでコピーできる点と、script要素を含んだNodeもcloneできるという点と循環参照対策されている点。もし、バグがあれば教えてほしい。

GitHub