Tips

JavaScriptで連想配列、入れ子オブジェクトのコピーの注意点【複製してもコピー元は変わらないようにしたい場合】

  • このエントリーをはてなブックマークに追加

JavaScriptにおいて連想配列や入れ子オブジェクトのコピーは、注意が必要な点があります。

一番簡単な方法は新しく変数を作ってコピーしたいものを代入するだけです。

しかしコピーを作成しても元のオブジェクトが変更されないようにする必要がある場合は、追加の手順が必要となります。

コピー元を変更したくない場合の方法としてスプレッド構文を使った方法が紹介されていますが、実はスプレッド構文を使った場合は連想配列(入れ子の状態)にはコピー元が変更されてしまいます。

今回はJavaScriptで連想配列や入れ子オブジェクトを安全にコピーする方法と、その注意点について解説します。

あくまでコピー元を変えたくない状況についてのお話になりますので、コピー元を変えても問題ない場合は何も気にせず好きな方法で大丈夫です。

また動画にもまとめていますので必要な方はご覧ください。

JavaScriptでオブジェクトをコピー(複製)する方法

まずはオブジェクトのコピーについてです。

冒頭でもお話しましたが一番簡単な方法はシンプルに代入することです。

let obj1 = {
  id: "0001",
  name: "aaa",
  age: 20,
};

// obj2にobj1を代入する
let obj2 = obj1;

console.log("obj1:", obj1);
console.log("obj2:", obj2);

多くの場合ではこれで大丈夫です、初学者の方は代入がまずできるようになりましょう。

しかしコピーしたobj2で値の変更を行った場合は以下のような形になります。

let obj1 = {
  id: "0001",
  name: "aaa",
  age: 20,
};

let obj2 = obj1;
// obj2の中身を変更する
obj2.id = "0002";

console.log("obj1:", obj1);
console.log("obj2:", obj2);

obj2しか変更していないつもりでもobj1まで変わっていることがわかります。

こちら初学者の方ならイメージしづらいと思いますが、オブジェクトや配列は値が代入されているわけではないのでコピー元とは一心同体のような動きになるからです。

過去の記事でも取り上げているくらい大事なポイントなので詳しい解説が必要な方は以下よりご確認ください。

繰り返しお話すると多くの場合はコピー元も一緒に変わってしまっても問題にならないです。

ただ本記事ではあえて「コピー元を変えたくないときはどうするか?」という話をしていきます。

ググっているとスプレッド構文を使うことで対策できることがわかります。

let obj1 = {
  id: "0001",
  name: "aaa",
  age: 20,
};
// ここを修正
let obj2 = { ...obj1 };
obj2.id = "0002";

console.log("obj1:", obj1);
console.log("obj2:", obj2);

先ほどと違ってコピー元は変更されていないことがわかります。

JavaScriptにはスプレッド構文という文法が用意されていて、配列やオブジェクトの中身をカスタマイズしたりコピーするときに使用します。

スプレッド構文についてはスクールや学習教材であまり習わないのですが実務ではよく見かけるはずなので知らなかった方はこの機会に覚えておきましょう。

以下の記事も参考にしてください。

これで一件落着のように見えて実はまだ落とし穴があります。

コピー元であるobj1を以下のように違う中身にしてみます。

// ここを修正
let obj1 = {
  id: "0001",
  name: "aaa",
  age: 20,
  sports: {
    sports1: "baseball",
    sports2: "soccer",
  },
};

let obj2 = { ...obj1 };
obj2.id = "0002";

console.log("obj1:", obj1);
console.log("obj2:", obj2);

オブジェクトの形を入れ子にしました。

入れ子の値をコピーした後のobj2で変更してみたいと思います。

let obj1 = {
  id: "0001",
  name: "aaa",
  age: 20,
  sports: {
    sports1: "baseball",
    sports2: "soccer",
  },
};

let obj2 = { ...obj1 };
obj2.id = "0002";
// ここを追加
obj2.sports.sports1 = "tennis";

console.log("obj1:", obj1);
console.log("obj2:", obj2);

idについては引き続きobj2のみの変更ですが、入れ子のsport1についてはコピー元のobj1まで変わってしまいました。

スプレッド構文では単純なオブジェクトについてはコピー元の変更を防げるのですが入れ子まで対応できない仕様になっているためです。

このような状況を専門用語で「シャローコピー(浅いコピー)」なんて言ったりします。

入れ子のオブジェクトについては以下のような書き方でコピー元の変更を回避することができます。

let obj1 = {
  id: "0001",
  name: "aaa",
  age: 20,
  sports: {
    sports1: "baseball",
    sports2: "soccer",
  },
};
// ここを修正
let obj2 = JSON.parse(JSON.stringify(obj1));
obj2.id = "0002";
obj2.sports.sports1 = "tennis";

console.log("obj1:", obj1);
console.log("obj2:", obj2);

こちらでようやく入れ子までobj1とobj2の値を別々にすることができました。

修正後のコードはJavaScriptのメソッドでJSON.parse( )というものです。

文字列を引数に受け取ることでオブジェクトを新規作成してくれるものです。

つまりobj1の中身を参考にしながら新しいオブジェクトとしてobj2を作ったわけです。

新規作成ですからobj1とobj2は正々堂々と別人格になることができたので、obj2を変更してもobj1には何も反映されなくなります。

また上記のコードでJSON.parseの引数に別のメソッドがあります。

こちらはJSON.stringify( )と言ってオブジェクトを文字列に変換するためのメソッドです。

obj1はオブジェクトなので文字列を引数にとるJSON.parseにはそのまま使用できません。

一度JSON.stringifyを使ってオブジェクトのobj1を文字列に変換する必要がありました。

JavaScriptで配列をコピー(複製)する方法

続いて配列についてです。

結論から言うと仕組みとしては先ほどのオブジェクトとほとんど同じです。

まずはシンプルな代入ができればOKです。

let arr1 = [1, 2, 3];
// arr2にarr1を代入する
let arr2 = arr1;

console.log(arr1);
console.log(arr2);

こちらも代入しただけではarr1とarr2は同じ動きをすることになります。

let arr1 = [1, 2, 3];

let arr2 = arr1;
// ここを追加
arr2[0] = 4;
console.log(arr1);
console.log(arr2);

先ほど紹介したスプレッド構文は配列にも使用できます。

配列も単純なコピーであればスプレッド構文によってコピー元の変更を回避できます。

let arr1 = [1, 2, 3];
// ここを修正
let arr2 = [...arr1];

arr2[0] = 4;
console.log(arr1);
console.log(arr2);

続いて配列についても入れ子になっている状況があります。

配列も入れ子になるとスプレッド構文ではコピー元の反映は回避できません。

// ここを修正
let arr1 = [1, 2, 3, [4, 5, 6]];

let arr2 = [...arr1];

arr2[0] = 4;
// ここを追加
arr2[3][0] = 5;

console.log(arr1);
console.log(arr2);

ここからが本題です。

配列についてはオブジェクトと違って少しややこしい作業になりますが、考え方は同じで「新規作成としてarr2を作る」ことです。

配列はインデックス番号で要素を見分けるので、インデックス番号ごとに値を突き合わせてarr1の中身と同じ値をarr2の中で再現するわけです。

let arr1 = [1, 2, 3, [4, 5, 6]];
// ここを修正
let arr2 = copyArray(arr1);

arr2[0] = 4;
arr2[3][0] = 5;

// ここを追加
function copyArray(arr) {
  let copy = [];
  for (let i = 0; i < arr.length; i++) {
    if (Array.isArray(arr[i])) {
      copy[i] = copyArray(arr[i]);
    } else {
      copy[i] = arr[i];
    }
  }
  return copy;
}

console.log(arr1);
console.log(arr2);

配列についても入れ子の部分まで含めてコピー元には変更を反映させないようにすることができました。

関数copyArrayの基本的な動きはelse文の部分になります。

arr2のために空の配列を用意して、インデックス番号ごとにarr1の値を順番に追加していくことでarr1と同じ配列がもう一個出来上がることになります。

ただ今回のように値自体が配列になっている場合は、配列になっている場所の中で同じ処理をもう一度行います。

入れ子の中に入ってしまうと関数copyArrayを再度実行したときに今度はelse文まで辿りつけるわけです。

また値自体が配列かどうかはArray.isArrayというメソッドで判定できます。

let arr1 = [1, 2, 3, [4, 5, 6]];
let arr2 = copyArray(arr1);

arr2[0] = 4;
arr2[3][0] = 5;

function copyArray(arr) {
  let copy = [];
  for (let i = 0; i < arr.length; i++) {
    // 基本的にはインデックス番号ごとに代入する
    // copy[i] = arr[i];
    // [] = [1, 2, 3, [4, 5, 6]]
    // [1] = [1, 2, 3, [4, 5, 6]]
    // [1,2] = [1, 2, 3, [4, 5, 6]]

    // 値が配列の時
    if (Array.isArray(arr[i])) {
      copy[i] = copyArray(arr[i]);
    } else {
    // 値が配列じゃない時
      copy[i] = arr[i];
    }
  }
  return copy;
}

console.log(arr1);
console.log(arr2);

JavaScriptで連想配列をコピー(複製)する方法

配列に関しては入れ子が配列ではなくオブジェクトになっている場合もあります。

主にはAPIなどで使われる形式ですね。

その場合には先ほどの方法に手を加える必要があるので別途紹介しておきます。

とはいえ考え方は同じです。

まずは普通に代入する方法だとコピー元と中身を分けることは引き続きできません。

let arr1 = [
  1,
  2,
  3,
  {
    id: "0001",
    name: "aaa",
    age: 20,
  },
];

let arr2 = arr1;

arr2[0] = 4;
arr2[3].age = 24;

console.log(arr1);
console.log(arr2);

またスプレッド構文を使うと浅い階層については対策できますが、入れ子の中までになると同じ結果になってしまいます。

let arr1 = [
  1,
  2,
  3,
  {
    id: "0001",
    name: "aaa",
    age: 20,
  },
];
// ここを修正
let arr2 = [...arr1];

arr2[0] = 4;
arr2[3].age = 24;

console.log(arr1);
console.log(arr2);

ここで先ほど使った関数をオブジェクトにも対応できるようにカスタマイズしていきます。

コードが長くなりますが中身はほとんど同じです。

関数copyArrayをそのままコピペして関数copyObjとします。

引数や変数の名前だけは書き換えておきましょう。

let arr1 = [
  1,
  2,
  3,
  {
    id: "0001",
    name: "aaa",
    age: 20,
  },
];
// ここを修正
let arr2 = copyArray(arr1);

// ここを追加
function copyArray(arr) {
  let copy = [];
  for (let i = 0; i < arr.length; i++) {
    if (Array.isArray(arr[i])) {
      copy[i] = copyArray(arr[i]);
   } else {
      copy[i] = arr[i];
    }
  }
  return copy;
}
// ここを追加(copyArrayのコピペ)
function copyObj(obj) {
  let copy = {};
  for (let key in obj) {
    if (Array.isArray(obj[key])) {
      copy[key] = copyArray(obj[key]);
    } else {
      copy[key] = obj[key];
    }
  }
  return copy;
}

arr2[0] = 4;
arr2[3].age = 24;

console.log(arr1);
console.log(arr2);

条件分岐の部分について現状だとif(値が配列)else(値が配列じゃない)の2択なので、オブジェクトの場合も含めてelseifをそれぞれの関数に追加します。

値がオブジェクトかどうかの判定はtypeofを使った型判定を使います。

また関数copyArrayを実行して、繰り返し処理の中で値がオブジェクトになったときに関数copyObjに移動するようにしています。

let arr1 = [
  1,
  2,
  3,
  {
    id: "0001",
    name: "aaa",
    age: 20,
  },
];
// 関数copyArrayのみを実行する
let arr2 = copyArray(arr1);

function copyArray(arr) {
  let copy = [];
  for (let i = 0; i < arr.length; i++) {
    // 値が配列か
    if (Array.isArray(arr[i])) {
      console.count(`インデックス番号${i}番目はcopyArrayのif通ったよ`);
      // 自分自身をもう一度呼び出してelse文に辿り着かせる
      copy[i] = copyArray(arr[i]);
    // 値がオブジェクトか
    } else if (typeof arr[i] === "object" && arr[i] !== null) {
      // copyObjに移動する
      copy[i] = copyObj(arr[i]);
      // 通常の処理
    } else {
      copy[i] = arr[i];
    }
  }
  return copy;
}

function copyObj(obj) {
  let copy = {};
  for (let key in obj) {
    // 値が配列か
    if (Array.isArray(obj[key])) {
      // copyArrayに移動する
      copy[key] = copyArray(obj[key]);
    // 値がオブジェクトか
    } else if (typeof obj[key] === "object" && obj[key] !== null) {
      // 自分自身をもう一度呼び出してelse文に辿り着かせる
      copy[key] = copyObj(obj[key]);
      // 通常の処理
    } else {
      copy[key] = obj[key];
    }
  }
  return copy;
}

arr2[0] = 4;
arr2[3].age = 24;

console.log(arr1);
console.log(arr2);

これで入れ子のオブジェクトの中までコピー元が上書きされることを回避できました。

コードが複雑なので関数の中でコンソールログを出力するようにしてみましょう。

let arr1 = [
  1,
  2,
  3,
  {
    id: "0001",
    name: "aaa",
    age: 20,
  },
];

let arr2 = copyArray(arr1);

function copyArray(arr) {
  let copy = [];
  for (let i = 0; i < arr.length; i++) {
    if (Array.isArray(arr[i])) {
    // ここを追加
      console.count(`インデックス番号${i}番目はcopyArrayのif通ったよ`);
      copy[i] = copyArray(arr[i]);
    } else if (typeof arr[i] === "object" && arr[i] !== null) {
      // ここを追加
      console.count(`インデックス番号${i}番目はcopyArrayのelseif通ったよ`);
      copy[i] = copyObj(arr[i]);
    } else {
      // ここを追加
      console.count(`インデックス番号${i}番目はcopyArrayのelse通ったよ`);
      copy[i] = arr[i];
    }
    // ここを追加
    console.log("現在の中身は\n", copy);
  }
  return copy;
}

function copyObj(obj) {
  let copy = {};
  for (let key in obj) {
    if (Array.isArray(obj[key])) {
      // ここを追加
      console.count(`インデックス番号${key}番目はcopyObjのif通ったよ`);
      copy[key] = copyArray(obj[key]);
    } else if (typeof obj[key] === "object" && obj[key] !== null) {
      // ここを追加
      console.count(`インデックス番号${key}番目はcopyObjのelseif通ったよ`);
      copy[key] = copyObj(obj[key]);
    } else {
      // ここを追加
      console.count(`オブジェクトの${key}はcopyObjのelse通ったよ`);
      copy[key] = obj[key];
    }
    // ここを追加
    console.log("現在の中身は\n", copy);
  }
  return copy;
}

arr2[0] = 4;
arr2[3].age = 24;

console.log(arr1);
console.log(arr2);

複雑なコードを理解するには細かくコンソールログに出力することが大事です。

今回の件に限らず初学者の方はエラーに遭遇したときはコンソールで順に追っていく癖をつけておきましょう。

コンソールを確認すると関数copyArrayのelse文をはじめとして、どの順番で値が追加されていっているかがわかります。

値がオブジェクトになったときに関数copyObjに移動しているのもわかりますね。

またコピー元のarr1がオブジェクトが最後の要素の値でしたが、オブジェクトから配列に当たる可能性もあります。

そのため関数copyObjの中では配列の判定も残しておき、もしオブジェクトの次に配列に遭遇したら関数copyArrayに再び戻るような動きになります。

let arr1 = [
  1,
  2,
  3,
  {
    id: "0001",
    name: "aaa",
    age: 20,
  },
];

let arr2 = copyArray(arr1);

// 再帰的な呼び出し
function copyArray(arr) {
  let copy = [];
  for (let i = 0; i < arr.length; i++) {
    // 値が配列か
    if (Array.isArray(arr[i])) {
      console.count(`インデックス番号${i}番目はcopyArrayのif通ったよ`);
      // 自分自身をもう一度呼び出してelse文に辿り着かせる
      copy[i] = copyArray(arr[i]);
    // 値がオブジェクトか
    } else if (typeof arr[i] === "object" && arr[i] !== null) {
      console.count(`インデックス番号${i}番目はcopyArrayのelseif通ったよ`);
      // copyObjに移動する
      copy[i] = copyObj(arr[i]);
    // 通常の処理
    } else {
      console.count(`インデックス番号${i}番目はcopyArrayのelse通ったよ`);
      copy[i] = arr[i];
    }
    console.log("現在の中身は\n", copy);
  }
  return copy;
}

function copyObj(obj) {
  let copy = {};
  for (let key in obj) {
    // 値が配列か
    if (Array.isArray(obj[key])) {
      console.count(`インデックス番号${key}番目はcopyObjのif通ったよ`);
      // copyArrayに移動する
      copy[key] = copyArray(obj[key]);
    // 値がオブジェクトか
    } else if (typeof obj[key] === "object" && obj[key] !== null) {
      console.count(`インデックス番号${key}番目はcopyObjのelseif通ったよ`);
      // 自分自身をもう一度呼び出してelse文に辿り着かせる
      copy[key] = copyObj(obj[key]);
    // 通常の処理
    } else {
      console.count(`オブジェクトの${key}はcopyObjのelse通ったよ`);
      copy[key] = obj[key];
    }
    console.log("現在の中身は\n", copy);
  }
  return copy;
}

arr2[0] = 4;
arr2[3].age = 24;

console.log(arr1);
console.log(arr2);

今回作ったような「関数が自分をもう一度呼ぶ」「2つの関数を行ったり来たりする」ような動きを再帰と言ったりします。

再帰については初学者の方は覚えなくて良いくらい難しい概念です。

例えばモダンJSのReactやVueの中身は再帰をベースにした設計になっています。

私たちの意外と身近なところに再帰はあるのですが、経験の少ないエンジニアが自分で再帰を作ることはほとんど無いので今回の内容が一度で理解できなくても大丈夫です。

あくまで配列、オブジェクトのコピーの方法がメインテーマでした。

また特殊なケースとして「コピー元はいじりたくない」と言った場合には通常の代入方式だけではなく違うパターンを想定しないといけないことも知ってもらえれば上出来でしょう。

また今回参考にした本は以下になりますのでよければどうぞ。

今回参考にした本はこちら

  • このエントリーをはてなブックマークに追加