23
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

JavaScriptで完璧なディープコピーをしようと頑張った

Last updated at Posted at 2020-03-30

javascriptで完璧にオブジェクトを複製したい!

JavaScriptでオブジェクトをコピーしようとした時、シャローコピーならスプレッド構文でなんとかなるんですが、ディープコピーしようとすると面倒だったのでいい方法を探したのですが、あまりいいものが見つからなかったので関数を作りました。
頑張ったので褒めてください。
jQueryのextendsとやらでできるらしいですが、このためだけにjQueryを入れたくはないですしおすし。
Qiitaの記事を書くのは初めてなので、至らぬ点があるかもしれませんがどうかご容赦を。

ディープコピーって何?

Javascriptのオブジェクトは参照型です。
プロパティーが変更されると、そのオブジェクトを参照している変数がすべて影響を受けます。
そのため、元のオブジェクトのプロパティーを変更したくない場合、完全に別オブジェクトとして複製する必要があります。
その中で、一番浅い階層のみを複製するのがシャローコピー(Shallow Copy)、すべての階層を複製するのがディープコピー(Deep Copy)です。

const hoge = {
  foo: "吾輩は猫である",
  baz: {
    abc: "名前はまだ無い"
  }
};
const huga = hoge;
const piyo = { ...huga }; // <= スプレッド構文(シャローコピー)

huga.foo = "ねこです。よろしくおねがいします。";

hoge.foo // => "ねこです。よろしくおねがいします。" <= ただの代入だと参照元も影響を受ける
huga.foo // => "ねこです。よろしくおねがいします。"
piyo.foo // => "吾輩は猫である" <= きちんとコピーできている

hoge === huga // => true
hoge === piyo // => false

huga.baz.abc = "ねこはいます。";

hoge.baz.abc // => "ねこはいます。"
huga.baz.abc // => "ねこはいます。"
piyo.baz.abc // => "ねこはいます。" <= 上書きされてしまっている

hoge.baz === huga.baz // => true
hoge.baz === piyo.baz // => true

JSON.parse(JSON.stringify())

一旦JSON(文字列)に変換してからJSのオブジェクトに戻す方法です。
おそらく一番簡単なディープコピー方法ですが、undefined, Symbol, Getter/Setter等JSONで表現できないものはコピーできません。

const 複製したオブジェクト = JSON.parse(JSON.stringify(コピー元オブジェクト));

Object.create()

prototypeを指定してオブジェクトを作成できるやつです。
元オブジェクトをprototypeにしてオブジェクトを作るということですね。
DevToolなどで見るとプロパティーがありませんが、きちんとprototypeからアクセスできます。
コピー先を変更しても、コピー元オブジェクトはも変わりません。
が、コピー元オブジェクトが変更されるとコピー先も変更を受けます。
コピー元がイミュータブルである場合には使えるのかもしれません。

const 複製したオブジェクト = Object.create(コピー元オブジェクト);

作る

いい感じにコピーできる手段が見つからなかったので、作ります。
最初からごちゃごちゃさせても分かりづらいので、とりあえず単純に。(単純じゃない気がする)
プロパティーのキーを取得してreduceで値を追加していきます。
Object.keysだと一部プロパティーが取得できないのでObject.getOwnPropertyNamesObject.getOwnPropertySymbolsを使用します。

※追記: Object.getOwnPropertyDescriptorsは全てのkeyを取得できるみたいなのでこれを使ってもいいかも。

reduceの第二引数(初期値)は、prototypeだけ継承したオブジェクトを指定します。

const clone = (object) => {
  if(typeof object !== "object") return object;
  const propNames = Object.getOwnPropertyNames(object);
  const symbols = Object.getOwnPropertySymbols(object);
  const prototype = Object.getPrototypeOf(object);
  return [...propNames, ...symbols].reduce((propertiesObject, propName) => {
    // プロパティーの取得
    const prop = Object.getOwnPropertyDescriptor(object, propName);
    // getter/setterでなければ再起呼出し
    if(prop.hasOwnProperty("value"))
      prop.value = clone(prop.value);
    // プロパティーを追加
    Object.defineProperty(propertiesObject, propName, prop);
    return propertiesObject;
  }, Object.create(prototype));
};

配列や他のオブジェクトにも対応させましょう。
typeof演算子ではオブジェクトは全て"object"になってしまうので、Object.prototype.toStringを使った関数を定義しています。
プリミティブ型のオブジェクトはオブジェクトのまま返していますが、単純なプリミティブ型を帰す場合はreturn new...を消してください。
DateやMap等は同じように複製できないので個別に処理をしています。

const typeOf = (obj) => Object.prototype.toString.call(obj).slice(8, -1).toLowerCase();
const clone = (object) => {
    if(Array.isArray(object)) // [], new Array
        return object.map(clone);
    if(typeof object === "object") switch(typeOf(object)) {
    default: // new Foo etc...
    case "object": { // {}, new Object
        // 上と同じなので省略
    }
    case "number": // new Number
        return new Number(object);
    case "string": // new String
        return new String(object);
    case "boolean": // new Boolean
        return new Boolean(object);
    case "bigint": // Object(BigInt())
        return object.valueOf();
    case "regexp": // /regexp/, new RegExp
        return new RegExp(object);
    case "null": // null
        return null;
    case "date": return new Date(object);
    case "map": {
        const map = new Map();
        for(const [key, value] of object)
            map.set(key, clone(value));
        return map;
    }
    case "set": return new Set(object);
    }
    // primitive type, function
    return object;
};

完成?

こんな感じになりました。
existingObjectsにこれまでcloneしたオブジェクトを入れて再帰参照があったらエラー吐いてます。
ただ、これでも一部のオブジェクトは完璧にはコピーできないのですがね。
クロージャーで作成したような隠しパラメータを持っているようなのがだめっぽい気がします。
完璧にはテストできてないので不具合とかあるかもしれない。

const typeOf = (obj) => Object.prototype.toString.call(obj).slice(8, -1).toLowerCase();
const clone = (object, existingObjects) => {
    if(!existingObjects) existingObjects = [];
    else if(existingObjects.indexOf(object) !== -1)
        throw new Error("Recursive reference exists.");
    else existingObjects = [...existingObjects, object];
    if(Array.isArray(object)) // [], new Array
        return object.map(value => clone(value, existingObjects));
    if(typeof object === "object") switch(typeOf(object)) {
    default: // new Foo etc...
    case "object": { // {}, new Object
        const symbols = Object.getOwnPropertySymbols(object);
        const propNames = Object.getOwnPropertyNames(object);
        const prototype = Object.getPrototypeOf(object);
        return [...propNames, ...symbols].reduce((propertiesObject, propName) => {
            const prop = Object.getOwnPropertyDescriptor(object, propName);
            if(prop.hasOwnProperty("value"))
                prop.value = clone(prop.value, existingObjects);
            Object.defineProperty(propertiesObject, propName, prop);
            return propertiesObject;
        }, Object.create(prototype));
    }
    case "number": // new Number
        return new Number(object);
    case "string": // new String
        return new String(object);
    case "boolean": // new Boolean
        return new Boolean(object);
    case "bigint": // Object(BigInt())
        return object.valueOf();
    case "regexp": // /regexp/, new RegExp
        return new RegExp(object);
    case "null": // null
        return null;
    case "date": return new Date(object);
    case "map": {
        const map = new Map();
        for(const [key, value] of object)
            map.set(key, clone(value, existingObjects));
        return map;
    }
    case "set": return new Set(object);
    }
    // primitive type, function
    return object;
}

ヲマケ(シャローコピー)

簡単なシャローコピーならスプレッド構文やObject.assign()で実現できます。
ただしこの方法は、Getter/Setterがコピーできません。

const hoge = {
  foo: "いろはにほへと ちりぬるを",
  baz: "わかよたれそ つねならむ",
  bar: "うゐのおくやま けふこえて",
  qux: "あさきゆめみし ゑひもせす"
};
const huga = { ...hoge };
const piyo = Object.assign({}, hoge);
hoge === huga // => false
hoge === piyo // => false

Getter/Setterもコピーしたければディープコピーの以下の部分とcloneの再帰呼び出しを消しましょう。

if(!existingObjects) existingObjects = [];
else if(existingObjects.indexOf(object) !== -1)
  throw new Error("Recursive call exists.");
else existingObjects = [...existingObjects, object];

参考

23
15
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?