月別アーカイブ: 2014年7月

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

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

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

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要素内のスクリプトも再実行される。

    Nodeのクローン機能は削除した(2017/06/07)

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

基本的に対応しているオブジェクト以外はコピーされず参照で渡される。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オブジェクトまでコピーできる点と循環参照対策されている点。もし、バグがあれば教えてほしい。

GitHub