Code Explain

Geminiの鋭い視点と分かりやすい解説で、プログラミングスキルを向上させましょう!

JavaScript配列のディープコピーを完全攻略!初心者から上級者まで実践できる全手法

JavaScript開発に携わる皆さん、一度は「配列やオブジェクトをコピーしたはずなのに、元のデータも変わってしまった!」という経験はありませんか?それは、JavaScriptが「参照渡し」という特性を持つため、意図しないデータの変更が起こりがちな証拠です。特に複雑なネスト構造を持つ配列やオブジェクトを扱う際、この問題は頭を悩ませる要因となります。

この記事では、そんなJavaScriptにおける「配列のディープコピー」について、その基礎から応用、最新のテクニックまでを徹底的に解説します。単にデータをコピーするだけでなく、データの独立性を保ち、予期せぬバグを防ぐための、まさに「必須スキル」と言えるでしょう。

初心者の方にも分かりやすいように、シャローコピーとの違いから丁寧に説明し、各手法のメリット・デメリット、そして実際のコード例を豊富に盛り込みました。この記事を最後まで読めば、あなたはJavaScriptのディープコピーを完全にマスターし、より堅牢で安全なコードを書けるようになるはずです。

さあ、JavaScriptのディープコピーの世界へ深く潜り込んでいきましょう!


目次

  1. はじめに:なぜディープコピーが必要なのか?
  2. JavaScriptにおけるコピーの種類:シャローコピーとディープコピー
    • プリミティブ型とオブジェクト型
    • シャローコピーとは?
    • シャローコピーの落とし穴
    • ディープコピーの重要性
  3. ディープコピーを実現する主要な手法
    • 手法1: JSON.parse(JSON.stringify()) を使う
      • メリットとシンプルな使い方
      • 致命的なデメリットと注意点
    • 手法2: structuredClone() を使う(最新・推奨)
      • structuredClone() の登場とその威力
      • 使い方とJSON法の問題をどう解決するか
      • 対応データ型と限界
    • 手法3: 再帰関数を自作してディープコピーを実装する
      • 原理の理解と基本的な実装
      • 循環参照への対応(WeakMapの利用)
      • メリットと考慮点
    • 手法4: ライブラリ(Lodashなど)を活用する
      • Lodash _.cloneDeep() の紹介
      • メリットとデメリット
  4. 各手法の比較と使い分け:どの方法を選ぶべきか?
  5. ディープコピーに関する高度なトピックと注意点
    • パフォーマンスの考慮
    • コピー対象の限界と非シリアライズ可能なオブジェクト
    • クラスインスタンスのディープコピー
    • オブジェクトのプロパティディスクリプタの扱い
  6. まとめ:あなたのコードをより堅牢に

1. はじめに:なぜディープコピーが必要なのか?

JavaScriptで配列やオブジェクトを扱う際、多くの開発者が一度は経験するであろう問題があります。それは、「コピーしたはずなのに、元のデータまで変わってしまった!」という現象です。

例えば、以下のようなコードを見てください。

const originalArray = [1, 2, { a: 3 }];
const copiedArray = originalArray; // 単純な代入

copiedArray[0] = 99;
copiedArray[2].a = 88; // オブジェクトのプロパティを変更

console.log(originalArray); // 結果: [99, 2, { a: 88 }]
console.log(copiedArray);  // 結果: [99, 2, { a: 88 }]

驚いたことに、copiedArray を変更しただけなのに、originalArray も同じように変更されてしまいました。これは、copiedArray = originalArray という代入が、配列の中身を「複製」しているわけではないためです。実際には、copiedArrayoriginalArray と全く同じメモリ上のデータ(参照)を指しているに過ぎません。

このような振る舞いは、特に元のデータを保護しながら新しいデータを操作したい場合や、Immutable(不変)なデータ構造を扱いたい場合に、予期せぬバグの温床となります。

そこで登場するのが「ディープコピー」です。ディープコピーは、元のデータとは完全に独立した新しいデータを生成します。つまり、コピーされたデータをいくら変更しても、元のデータには一切影響を与えない、文字通りの「完全な複製」を作り出す技術なのです。

この記事では、このディープコピーを様々な角度から掘り下げ、あなたのJavaScriptスキルを一段階引き上げる手助けをします。

2. JavaScriptにおけるコピーの種類:シャローコピーとディープコピー

ディープコピーの理解を深める前に、JavaScriptにおける「コピー」の基本的な概念、特に「シャローコピー」との違いを明確にしておきましょう。

プリミティブ型とオブジェクト型

JavaScriptのデータ型は大きく二つに分けられます。

  • プリミティブ型 (Primitive Types):

    • string, number, boolean, null, undefined, symbol, BigInt
    • これらは「値」そのものが変数に格納されます。そのため、代入やコピーの際に常に「値渡し」が行われ、完全に独立したデータが生成されます。
    • 例: let a = 10; let b = a; b = 20; console.log(a); // 10
  • オブジェクト型 (Object Types):

    • Object (プレーンオブジェクト), Array, Function, Date, RegExp など
    • これらは「値」そのものではなく、データが格納されているメモリ上の「参照(アドレス)」が変数に格納されます。そのため、代入やコピーの際に「参照渡し」が行われます。
    • 例: let obj1 = { a: 1 }; let obj2 = obj1; obj2.a = 2; console.log(obj1.a); // 2

ディープコピーの問題が顕在化するのは、この「オブジェクト型」を扱う場合です。

シャローコピーとは?

シャローコピー(Shallow Copy、浅いコピー)とは、オブジェクトの「第一階層」のみを複製するコピー方法です。つまり、新しいオブジェクト(または配列)を作成し、その中に元のオブジェクトのプロパティ(または要素)をコピーします。

ただし、コピーされるのがプリミティブ値であればその値がコピーされますが、オブジェクトへの「参照」であれば、その参照先がコピーされます。つまり、ネストされたオブジェクトや配列がある場合、それらは元のオブジェクトと「同じ参照」を共有することになります。

シャローコピーを実現する主な方法には、以下のようなものがあります。

  1. スプレッド構文 (...): 配列やオブジェクトのリテラル内で利用します。

    const original = { a: 1, b: { c: 2 } };
    const shallowCopy = { ...original }; // オブジェクトのスプレッド構文
    console.log(shallowCopy); // { a: 1, b: { c: 2 } }
    
    const originalArr = [1, [2, 3], 4];
    const shallowCopyArr = [...originalArr]; // 配列のスプレッド構文
    console.log(shallowCopyArr); // [1, [2, 3], 4]
    
  2. Object.assign(): オブジェクトのプロパティを別のオブジェクトにコピーします。

    const original = { a: 1, b: { c: 2 } };
    const shallowCopy = Object.assign({}, original);
    
  3. Array.prototype.slice() / Array.prototype.concat(): 配列をシャローコピーします。

    const originalArr = [1, [2, 3], 4];
    const shallowCopyArr1 = originalArr.slice();
    const shallowCopyArr2 = originalArr.concat();
    

シャローコピーの落とし穴

シャローコピーは、第一階層がプリミティブ値のみで構成されている場合には問題なく機能します。しかし、ネストされたオブジェクトや配列が含まれている場合、問題が発生します。

const original = {
  name: 'Alice',
  data: {
    age: 30,
    hobbies: ['reading', 'coding']
  }
};

// シャローコピー
const shallowCopy = { ...original };

console.log('--- コピー前 ---');
console.log('Original:', original);      // { name: 'Alice', data: { age: 30, hobbies: ['reading', 'coding'] } }
console.log('Shallow Copy:', shallowCopy); // { name: 'Alice', data: { age: 30, hobbies: ['reading', 'coding'] } }

// シャローコピーの第一階層にあるプリミティブ値を変更
shallowCopy.name = 'Bob';

// シャローコピーの第二階層にあるオブジェクトのプロパティを変更
shallowCopy.data.age = 31;
shallowCopy.data.hobbies.push('gaming');

console.log('\n--- コピー後変更 ---');
console.log('Original:', original);      // { name: 'Alice', data: { age: 31, hobbies: ['reading', 'coding', 'gaming'] } }
console.log('Shallow Copy:', shallowCopy); // { name: 'Bob', data: { age: 31, hobbies: ['reading', 'coding', 'gaming'] } }

// 結果の比較
console.log('\n--- 比較 ---');
console.log('Original.name === ShallowCopy.name:', original.name === shallowCopy.name); // false (別々の値)
console.log('Original.data === ShallowCopy.data:', original.data === shallowCopy.data); // true (同じ参照)
console.log('Original.data.hobbies === ShallowCopy.data.hobbies:', original.data.hobbies === shallowCopy.data.hobbies); // true (同じ参照)

この例から分かるように、

  • name のような第一階層のプリミティブ値は完全にコピーされ、独立しています。
  • しかし、data のような第一階層にあるオブジェクトへの参照は、元のオブジェクトと共有されています。そのため、shallowCopy.data.ageshallowCopy.data.hobbies を変更すると、original.data も同時に変更されてしまうのです。

これが「シャローコピーの落とし穴」であり、多くの開発者を悩ませる原因となります。

ディープコピーの重要性

ディープコピー(Deep Copy、深いコピー)は、オブジェクト(または配列)とその中にネストされている全てのオブジェクトを、完全に独立した新しいオブジェクトとして複製する方法です。これにより、元のデータとコピーされたデータの間に参照関係が一切なくなり、一方を変更してももう一方には全く影響を与えません。

ディープコピーが必要となる典型的なシナリオは以下の通りです。

  • 元のデータを不変に保ちたい (Immutable Data): データの不変性(イミュータビリティ)は、特にReact/Reduxのような状態管理を伴うアプリケーションで重要視されます。これにより、意図しない副作用を防ぎ、データの流れを予測しやすくします。
  • 複雑な設定オブジェクトの編集: アプリケーションの設定など、複数のレイヤーにわたるオブジェクト構造を編集する際に、元の設定を壊さずに新しい設定を生成したい場合。
  • undo/redo 機能の実装: ユーザー操作によってデータが変更された際、過去の状態を保持して元に戻せるようにする場合。
  • APIからのレスポンスデータの加工: サーバーから受け取った生データを加工する際に、元のデータを保持しておきたい場合。

ディープコピーは、特に大規模で複雑なアプリケーションを開発する上で、堅牢性と保守性を高めるために不可欠なテクニックです。

3. ディープコピーを実現する主要な手法

それでは、JavaScriptでディープコピーを実現するための具体的な手法を、それぞれの特徴とともに見ていきましょう。

手法1: JSON.parse(JSON.stringify()) を使う

この方法は、手軽さから古くから広く使われてきたディープコピーのテクニックです。

メリットとシンプルな使い方

原理は非常にシンプルです。

  1. まず、コピーしたいオブジェクトを JSON.stringify() を使ってJSON文字列に変換します。この時点で、オブジェクトの構造が全て文字列として表現されます。
  2. 次に、そのJSON文字列を JSON.parse() を使ってJavaScriptのオブジェクトに再変換します。この「文字列化して再解析する」過程で、元のオブジェクトとは全く異なるメモリ空間に新しいオブジェクトが生成されるため、ディープコピーが実現します。
const original = {
  name: 'Alice',
  data: {
    age: 30,
    hobbies: ['reading', 'coding'],
    address: {
      city: 'Tokyo',
      zip: '100-0001'
    }
  }
};

// JSONメソッドを使ったディープコピー
const deepCopyJSON = JSON.parse(JSON.stringify(original));

console.log('--- コピー前 ---');
console.log('Original:', original);
console.log('Deep Copy (JSON):', deepCopyJSON);

// ディープコピーしたオブジェクトを変更
deepCopyJSON.name = 'Bob';
deepCopyJSON.data.age = 31;
deepCopyJSON.data.hobbies.push('gaming');
deepCopyJSON.data.address.city = 'Osaka';

console.log('\n--- コピー後変更 ---');
console.log('Original:', original);
console.log('Deep Copy (JSON):', deepCopyJSON);

// 結果の比較
console.log('\n--- 比較 ---');
console.log('Original.name === DeepCopyJSON.name:', original.name === deepCopyJSON.name); // false
console.log('Original.data === DeepCopyJSON.data:', original.data === deepCopyJSON.data); // false
console.log('Original.data.hobbies === DeepCopyJSON.data.hobbies:', original.data.hobbies === deepCopyJSON.data.hobbies); // false
console.log('Original.data.address === DeepCopyJSON.data.address:', original.data.address === deepCopyJSON.data.address); // false

ご覧の通り、deepCopyJSON をいくら変更しても、original には全く影響がありません。これは、JSON.parse(JSON.stringify()) が完全に独立した新しいオブジェクト構造を生成したためです。

致命的なデメリットと注意点

この方法は非常に手軽ですが、いくつかの致命的なデメリットが存在します。これらは、特定のデータ型を扱う際に予期せぬ挙動を引き起こす可能性があります。

  1. Dateオブジェクトが文字列に変換される: Date オブジェクトはJSON文字列に変換される際、ISO 8601形式の文字列(例: "2023-10-27T10:00:00.000Z")になります。JSON.parse() で再変換されても、元の Date オブジェクトには戻らず、単なる文字列として扱われます。

    const objWithDate = {
      date: new Date(),
      name: 'Test'
    };
    const deepCopy = JSON.parse(JSON.stringify(objWithDate));
    console.log(deepCopy.date); // "2023-10-27T10:00:00.000Z" (文字列)
    console.log(typeof deepCopy.date); // string
    
  2. 関数 (function), undefined, Symbol, BigInt が失われる: JSON.stringify() は、これらの値を無視するか、不正な値として処理します。

    • オブジェクトのプロパティにこれらが含まれている場合、そのプロパティはコピーされません。
    • 配列の要素にある場合、null に置き換えられるか、単に消滅します。
    const objWithUnsupported = {
      func: () => console.log('hello'),
      undef: undefined,
      sym: Symbol('foo'),
      big: 123n, // BigInt
      data: {
        val: 'some value',
        func: () => console.log('nested func')
      }
    };
    const deepCopy = JSON.parse(JSON.stringify(objWithUnsupported));
    console.log(deepCopy);
    // 結果: { data: { val: 'some value' } }
    // func, undef, sym, big がすべて失われている
    
  3. 循環参照に対応できない: オブジェクトが自分自身を(直接的または間接的に)参照している「循環参照」構造を持つ場合、JSON.stringify() はエラー (TypeError: Converting circular structure to JSON) をスローします。

    const obj1 = {};
    const obj2 = { a: obj1 };
    obj1.b = obj2; // obj1 -> obj2 -> obj1 (循環参照)
    
    try {
      JSON.parse(JSON.stringify(obj1));
    } catch (e) {
      console.error(e.message); // Converting circular structure to JSON
    }
    
  4. プロパティディスクリプタが失われる: プロパティの writable, enumerable, configurable などの情報が失われ、すべてデフォルト値になります。 例えば、writable: false で定義されたプロパティも、コピー後は true になります。

  5. パフォーマンス: 大規模なオブジェクトや配列に対しては、JSON文字列への変換と解析に時間がかかり、パフォーマンスが低下する可能性があります。

これらのデメリットから、JSON.parse(JSON.stringify()) は、「複雑なデータ型を含まず、循環参照もない、比較的単純なプレーンオブジェクトや配列のディープコピー」 に限定して使用すべきです。

手法2: structuredClone() を使う(最新・推奨)

近年、Web標準として新しいディープコピーの仕組みである structuredClone() が登場しました。これは、JSONメソッドの多くの問題を解決する、現在最も推奨されるディープコピー手法です。

structuredClone() の登場とその威力

structuredClone() は、HTML StandardとWhatWGのStructured Clone Algorithmに基づいて実装されており、Web Workers間でメッセージをやり取りする際に内部的に使われているアルゴリズムを直接利用できるようにしたものです。

特徴:

  • 多くの組み込み型に対応: Date, RegExp, Map, Set, ArrayBuffer, TypedArray, Blob, File, FileList, ImageData, DOMException, Error など、JSONでは扱えなかった多くのデータ型を適切にコピーできます。
  • 循環参照に対応: 循環参照を持つオブジェクトも正しくコピーできます。
  • パフォーマンス: JavaScriptエンジン内部で最適化されているため、多くのケースで自作の再帰関数よりも高速です。

使い方とJSON法の問題をどう解決するか

使い方は非常にシンプルです。コピーしたいオブジェクトを引数に渡すだけです。

const original = {
  name: 'Alice',
  birthday: new Date(),
  pattern: /xyz/g,
  data: {
    age: 30,
    hobbies: ['reading', 'coding']
  },
  // func: () => console.log('hello'), // 関数はコピーされない (後述)
  // sym: Symbol('foo') // Symbolもコピーされない (後述)
};

// 循環参照の例
const objA = {};
const objB = { refA: objA };
objA.refB = objB;
original.circular = objA;

const deepCopyStructured = structuredClone(original);

console.log('--- structuredCloneの結果 ---');
console.log(deepCopyStructured);

console.log('deepCopyStructured.birthday instanceof Date:', deepCopyStructured.birthday instanceof Date); // true
console.log('deepCopyStructured.pattern instanceof RegExp:', deepCopyStructured.pattern instanceof RegExp); // true
console.log('deepCopyStructured.circular === original.circular:', deepCopyStructured.circular === original.circular); // false (参照が異なる)
console.log('deepCopyStructured.circular.refB === deepCopyStructured.circular:', deepCopyStructured.circular.refB === deepCopyStructured.circular); // true (コピー内で循環参照が保持されている)

// コピー後の変更
deepCopyStructured.name = 'Bob';
deepCopyStructured.data.hobbies.push('gaming');
deepCopyStructured.birthday.setFullYear(2000); // Dateオブジェクトの変更

console.log('\n--- 変更後比較 ---');
console.log('Original name:', original.name); // Alice
console.log('Deep Copy name:', deepCopyStructured.name); // Bob
console.log('Original hobbies:', original.data.hobbies); // ['reading', 'coding']
console.log('Deep Copy hobbies:', deepCopyStructured.data.hobbies); // ['reading', 'coding', 'gaming']
console.log('Original birthday year:', original.birthday.getFullYear()); // 現在の年
console.log('Deep Copy birthday year:', deepCopyStructured.birthday.getFullYear()); // 2000

structuredClone() は、DateRegExp オブジェクトを適切にコピーし、循環参照も問題なく処理していることがわかります。

対応データ型と限界

structuredClone() は非常に強力ですが、万能ではありません。Structured Clone Algorithmが扱えない一部のデータ型やオブジェクトはコピーできません。

コピーできないもの(エラーをスローする):

  • 関数 (Function)
  • DOMノード
  • プロパティディスクリプタ(getter/setterなど)
  • 内部スロットを持つオブジェクト(例: Errorstack プロパティなど、一部のネイティブオブジェクトの特定のプロパティ)

コピーされるが特別な扱いをするもの:

  • Symbol: プロパティキーとしてSymbolが使われている場合、そのプロパティはコピーされません。Symbol値そのものをオブジェクトのプロパティとして持つ場合も同様です。
  • BigInt: JSONでは失われましたが、structuredClone() では正しくコピーされます。

対応状況:

  • ブラウザ: ほぼ全てのモダンブラウザ(Chrome 98+, Firefox 94+, Edge 98+, Safari 15.4+)でサポートされています。
  • Node.js: Node.js v17.0.0+ および v16.11.0+ でグローバルに利用可能です。

ほとんどのWebアプリケーション開発において、structuredClone() はディープコピーの第一選択肢となるでしょう。ただし、関数やDOMノードなど、特定のオブジェクトをコピーしたい場合は、後述の自作関数やライブラリを検討する必要があります。

手法3: 再帰関数を自作してディープコピーを実装する

JSON.parse(JSON.stringify()) の限界や、structuredClone() が使えない古い環境、あるいは特定のオブジェクト(関数など)もコピーしたいといった高度な要件がある場合、独自の再帰関数を使ってディープコピーを実装することがあります。これは、ディープコピーの原理を深く理解する上でも非常に有効な方法です。

原理の理解と基本的な実装

ディープコピーの再帰関数は、以下のロジックで動作します。

  1. 引数として渡された値がプリミティブ型であれば、そのまま返す(値渡し)。
  2. 引数として渡された値がオブジェクト型であれば、新しい空のオブジェクト(または配列)を作成する。
  3. 元のオブジェクトの各プロパティ(または配列の各要素)を一つずつ見ていく。
  4. そのプロパティの値が再びオブジェクト型であれば、その値に対して再帰的にディープコピー関数を呼び出す。
  5. プリミティブ値であれば、そのまま新しいオブジェクトにコピーする。

基本的な実装例は以下のようになります。

function deepCopy(obj) {
  // 1. プリミティブ型、null、undefined、関数、Symbol、BigInt はそのまま返す
  if (obj === null || typeof obj !== 'object' || typeof obj === 'function' || typeof obj === 'symbol' || typeof obj === 'bigint') {
    return obj;
  }

  // 2. Dateオブジェクトのコピー
  if (obj instanceof Date) {
    return new Date(obj.getTime());
  }

  // 3. RegExpオブジェクトのコピー
  if (obj instanceof RegExp) {
    return new RegExp(obj.source, obj.flags);
  }

  // 4. 配列かオブジェクトかを判別し、新しい空のコンテナを作成
  const copy = Array.isArray(obj) ? [] : {};

  // 5. プロパティを再帰的にコピー
  for (let key in obj) {
    // 自身のプロパティのみをコピー (プロトタイプチェーン上のプロパティは含めない)
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      copy[key] = deepCopy(obj[key]);
    }
  }

  return copy;
}

// テスト
const original = {
  a: 1,
  b: {
    c: 2,
    d: [3, { e: 4 }],
    f: new Date(),
    g: /pattern/
  },
  h: () => console.log('hello'), // 関数はコピーされない (意図しない場合)
  i: undefined, // undefinedもコピーされない
  j: Symbol('test'), // Symbolもコピーされない
  k: 123n // BigIntもコピーされない
};

const customDeepCopy = deepCopy(original);

console.log('--- 自作関数の結果 ---');
console.log(customDeepCopy);
console.log(customDeepCopy.b.f instanceof Date); // true
console.log(customDeepCopy.b.g instanceof RegExp); // true
console.log(customDeepCopy.h); // オリジナルと同じ関数参照
console.log(customDeepCopy.i); // undefined はコピーされない
console.log(customDeepCopy.j); // Symbol はコピーされない
console.log(customDeepCopy.k); // BigInt はコピーされない

// 変更テスト
customDeepCopy.a = 99;
customDeepCopy.b.c = 88;
customDeepCopy.b.d[1].e = 77;
customDeepCopy.b.f.setFullYear(2000);

console.log('\n--- 変更後比較 ---');
console.log('Original a:', original.a); // 1
console.log('Deep Copy a:', customDeepCopy.a); // 99
console.log('Original b.c:', original.b.c); // 2
console.log('Deep Copy b.c:', customDeepCopy.b.c); // 88
console.log('Original b.d[1].e:', original.b.d[1].e); // 4
console.log('Deep Copy b.d[1].e:', customDeepCopy.b.d[1].e); // 77
console.log('Original b.f year:', original.b.f.getFullYear()); // 現在の年
console.log('Deep Copy b.f year:', customDeepCopy.b.f.getFullYear()); // 2000

この基本的な実装では、関数、SymbolBigIntundefined は意図的にコピーせず、元の参照をそのまま返しています(または無視しています)。もしこれらもディープコピーの対象としたい場合は、追加のロジックが必要になります。

循環参照への対応(WeakMapの利用)

上記の基本的な deepCopy 関数は、循環参照を持つオブジェクトが渡されると無限ループに陥り、スタックオーバーフローエラーが発生します。

これを解決するには、既にコピー中のオブジェクトを追跡する仕組みが必要です。WeakMap を利用するのが一般的です。WeakMap はキーがオブジェクトでなければならず、キーへの参照がなくなるとガベージコレクションされるため、メモリリークのリスクを減らせます。

function deepCopyAdvanced(obj, cache = new WeakMap()) {
  // 1. プリミティブ型、null、undefined、Symbol、BigInt はそのまま返す
  if (obj === null || typeof obj !== 'object' || typeof obj === 'symbol' || typeof obj === 'bigint') {
    return obj;
  }

  // 2. 関数はそのまま返す (ディープコピー対象としない場合)
  if (typeof obj === 'function') {
    return obj;
  }

  // 3. 循環参照の検出と処理
  if (cache.has(obj)) {
    return cache.get(obj); // 既にコピー済みであればキャッシュから返す
  }

  // 4. Dateオブジェクトのコピー
  if (obj instanceof Date) {
    return new Date(obj.getTime());
  }

  // 5. RegExpオブジェクトのコピー
  if (obj instanceof RegExp) {
    return new RegExp(obj.source, obj.flags);
  }

  // 6. 配列かオブジェクトかを判別し、新しい空のコンテナを作成
  const copy = Array.isArray(obj) ? [] : {};

  // 7. コピーしたオブジェクトをキャッシュに保存 (循環参照対策)
  cache.set(obj, copy);

  // 8. プロパティを再帰的にコピー
  for (let key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      // 再帰的に deepCopyAdvanced を呼び出す際に、同じキャッシュインスタンスを渡す
      copy[key] = deepCopyAdvanced(obj[key], cache);
    }
  }

  return copy;
}

// 循環参照のテスト
const obj1 = {};
const obj2 = { a: obj1 };
obj1.b = obj2; // obj1 -> obj2 -> obj1

const circularDeepCopy = deepCopyAdvanced(obj1);
console.log('\n--- 循環参照の自作関数結果 ---');
console.log(circularDeepCopy);
console.log(circularDeepCopy === obj1); // false
console.log(circularDeepCopy.b === obj1.b); // false
console.log(circularDeepCopy.b.a === circularDeepCopy); // true (コピー内で循環参照が保持されている)

// 関数もコピーしたい場合(これはディープコピーではないが、参照を維持)
const objWithFunc = {
    myFunc: () => 'hello',
    data: {
        nestedFunc: function() { return 'world'; }
    }
};
const copiedWithFunc = deepCopyAdvanced(objWithFunc);
console.log('\n--- 関数を含む自作関数結果 ---');
console.log(copiedWithFunc.myFunc()); // hello
console.log(copiedWithFunc.data.nestedFunc()); // world
console.log(copiedWithFunc.myFunc === objWithFunc.myFunc); // true (参照が維持される)

このdeepCopyAdvanced関数は、DateRegExp、そして循環参照にも対応できるようになりました。関数は「参照渡し」という形で維持されています。

メリットと考慮点

メリット:

  • 完全な制御: どのような型のオブジェクトをどのようにコピーするか、開発者が完全に制御できます。特定のビジネスロジックに合わせたコピー処理を実装できます。
  • 学習: ディープコピーの動作原理を深く理解できます。
  • 環境依存なし: 特定のブラウザ機能やNode.jsバージョンに依存せず、どこでも動作します(ただし、WeakMapはES6以降)。

考慮点(デメリット):

  • 実装の手間と複雑さ: 対応すべきデータ型が増えたり、特殊なオブジェクト(例えばMapSetArrayBuffer、カスタムクラスインスタンスなど)を考慮すると、実装が非常に複雑になります。
  • バグのリスク: 自作するため、テストが不十分だとバグが混入しやすいです。
  • パフォーマンス: JavaScriptエンジンが提供するネイティブ機能(structuredClone()など)と比較して、一般的にパフォーマンスは劣ります。

自作関数は、特定の要件を満たす必要がある場合や、学習目的以外では、structuredClone() や後述のライブラリの利用を検討すべきでしょう。

手法4: ライブラリ(Lodashなど)を活用する

より堅牢で広範なディープコピー機能が必要な場合、成熟したJavaScriptライブラリを利用するのが最も現実的で安全な選択肢です。特に「Lodash」の _.cloneDeep() メソッドは、ディープコピーのデファクトスタンダードとして広く使われています。

Lodash _.cloneDeep() の紹介

Lodashは、JavaScriptのユーティリティ関数を提供するライブラリであり、その中でも _.cloneDeep() は非常に強力なディープコピー機能を提供します。

特徴:

  • 広範なデータ型に対応: プレーンオブジェクト、配列はもちろん、Date, RegExp, Map, Set, ArrayBuffer, TypedArray, Error, Function, Symbol (プロパティキーとしてのSymbolも)、Promiseなど、非常に多くの型を適切に処理します。
  • 循環参照に対応: 複雑な循環参照も問題なく扱えます。
  • プロパティディスクリプタの一部対応: configurableenumerable など、一部のプロパティディスクリプタを考慮してコピーします(ただし、writableは常にtrueになるなど、完全に再現するわけではありません)。
  • 高い信頼性: 多くの開発者によって利用され、徹底的にテストされているため、バグが少なく信頼性が高いです。
  • パフォーマンス: 大規模なオブジェクトに対しても、独自の最適化により比較的高いパフォーマンスを発揮します。

使い方

Lodashはnpmでインストールできます。 npm install lodash

import _ from 'lodash'; // ES Modulesの場合
// または const _ = require('lodash'); // CommonJSの場合

const original = {
  name: 'Alice',
  birthday: new Date(),
  pattern: /xyz/g,
  data: {
    age: 30,
    hobbies: ['reading', 'coding'],
    func: () => console.log('hello'), // 関数もコピーされる (参照渡し)
    sym: Symbol('foo'), // Symbol もコピーされる (参照渡し)
    big: 123n, // BigInt もコピーされる
  },
  nestedArr: [[1, 2], {x: 10, y: 20}],
  map: new Map([['a', 1], ['b', {c: 2}]])
};

// 循環参照の例
const objA = {};
const objB = { refA: objA };
objA.refB = objB;
original.circular = objA;

const deepCopyLodash = _.cloneDeep(original);

console.log('--- Lodash _.cloneDeep() の結果 ---');
console.log(deepCopyLodash);

console.log('deepCopyLodash.birthday instanceof Date:', deepCopyLodash.birthday instanceof Date); // true
console.log('deepCopyLodash.pattern instanceof RegExp:', deepCopyLodash.pattern instanceof RegExp); // true
console.log('deepCopyLodash.data.func === original.data.func:', deepCopyLodash.data.func === original.data.func); // true (関数は参照渡し)
console.log('deepCopyLodash.data.sym === original.data.sym:', deepCopyLodash.data.sym === original.data.sym); // true (Symbolは参照渡し)
console.log('deepCopyLodash.data.big === original.data.big:', deepCopyLodash.data.big === original.data.big); // true (BigIntは値渡し)
console.log('deepCopyLodash.map instanceof Map:', deepCopyLodash.map instanceof Map); // true
console.log('deepCopyLodash.map.get("b") === original.map.get("b"):', deepCopyLodash.map.get("b") === original.map.get("b")); // false (Map内のオブジェクトもディープコピー)
console.log('deepCopyLodash.circular === original.circular:', deepCopyLodash.circular === original.circular); // false

// コピー後の変更
deepCopyLodash.name = 'Bob';
deepCopyLodash.data.hobbies.push('gaming');
deepCopyLodash.map.set('a', 99);
deepCopyLodash.map.get('b').c = 88;

console.log('\n--- 変更後比較 ---');
console.log('Original name:', original.name); // Alice
console.log('Deep Copy name:', deepCopyLodash.name); // Bob
console.log('Original hobbies:', original.data.hobbies); // ['reading', 'coding']
console.log('Deep Copy hobbies:', deepCopyLodash.data.hobbies); // ['reading', 'coding', 'gaming']
console.log('Original Map "a" value:', original.map.get('a')); // 1
console.log('Deep Copy Map "a" value:', deepCopyLodash.map.get('a')); // 99
console.log('Original Map "b".c value:', original.map.get('b').c); // 2
console.log('Deep Copy Map "b".c value:', deepCopyLodash.map.get('b').c); // 88

_.cloneDeep() は、関数やSymbolのような参照型はディープコピーせず、元の参照を保持する挙動がデフォルトです。これは「ディープコピー」の定義として妥当な挙動と言えます(関数は実行可能なコードであり、それを複製すること自体はほとんど意味がないため)。

メリットとデメリット

メリット:

  • 堅牢性と信頼性: あらゆるエッジケースに対応するよう設計されており、安定して動作します。
  • 広範な型サポート: structuredClone() ではコピーできない関数やSymbolも、参照を維持する形でサポートします。MapSetなどのコレクションも適切に処理します。
  • コードの簡潔さ: 複雑なディープコピーロジックを自分で書く必要がなく、コードが簡潔になります。
  • 機能の豊富さ: Lodashには他にも多数の便利なユーティリティ関数があり、総合的な開発効率向上に貢献します。

デメリット:

  • ライブラリの依存: プロジェクトにLodashを追加する必要があり、バンドルサイズが増加する可能性があります。モジュール単位でインポートすれば軽減できます。
  • 学習コスト: LodashのAPIに慣れる必要があります(_.cloneDeep はシンプルですが)。
  • 実行環境: クライアントサイドでの利用が前提ですが、Node.jsでも問題なく利用できます。

プロジェクトにライブラリの追加が許容される場合、特に複雑なデータ構造や多岐にわたるデータ型を扱う場合は、Lodash _.cloneDeep() は非常に強力な選択肢です。

4. 各手法の比較と使い分け:どの方法を選ぶべきか?

これまで見てきた各ディープコピー手法の特徴をまとめ、どのような状況でどの方法を選ぶべきかを明確にしましょう。

手法 メリット デメリット 考慮すべき点 推奨されるユースケース
JSON.parse(JSON.stringify()) - 実装が最もシンプルで手軽
- ライブラリ不要
- Dateが文字列化
- function, undefined, Symbol, BigIntが失われる
- 循環参照でエラー
- プロパティディスクリプタが失われる
- コピー対象が純粋なデータ(プリミティブ、オブジェクト、配列)のみの場合
- 循環参照がないことを確信できる場合
- 比較的小さく、シンプルなオブジェクト/配列のディープコピー
- JSONシリアライズ可能なデータ構造のみを扱う場合
structuredClone() - Date, RegExp, Map, Setなど多くの型に対応
- 循環参照に対応
- パフォーマンスが良い (ネイティブ)
- ライブラリ不要
- 関数 (Function), DOMノードはコピーできない
- プロパティディスクリプタは失われる
- ブラウザ/Node.jsのバージョン制限
- モダンな環境で開発している場合
- 関数やDOMノードをコピーする必要がない場合
- ほとんどのWebアプリケーション開発におけるディープコピーの第一選択肢
- 複雑なデータ型や循環参照を含む場合
自作の再帰関数 (deepCopy) - コピーロジックを完全に制御できる
- 環境依存が少ない (ES6以降)
- 実装が複雑で手間がかかる
- バグの温床になりやすい
- ネイティブ機能よりパフォーマンスが劣る
- structuredClone()でコピーできない特定の型を独自に処理したい
- 古い環境で structuredClone()が使えない場合
- 学習目的
- 非常に特殊な要件がある場合
- structuredClone()が利用できないレガシー環境
- ディープコピーの動作原理の理解
ライブラリ (Lodash.cloneDeep()) - 非常に堅牢で多機能
- 広範なデータ型に対応
- 循環参照に対応
- 高い信頼性と実績
- ライブラリの依存関係が増える
- バンドルサイズが増加する可能性
- プロジェクトにライブラリ導入が許容される場合
- 多くのデータ型や複雑な構造を扱う大規模プロジェクト
- 大規模なアプリケーション開発
- structuredClone()で不足する要件があるが、自作は避けたい場合

結論として、多くの場合で推奨される選択肢は以下の通りです。

  1. モダンな環境(ほとんどのWeb開発)で、関数やDOMノードをコピーする必要がなければ: structuredClone()
  2. 非常にシンプルで、JSONにシリアライズ可能なデータのみを扱う場合: JSON.parse(JSON.stringify())
  3. より複雑な要件(特定の型への対応、古い環境、極度の堅牢性)があり、ライブラリ導入が許容される場合: Lodash.cloneDeep()
  4. 上記いずれも適さず、かつ独自の制御が必要な場合、または学習目的: 自作の再帰関数

ご自身のプロジェクトの要件、対象環境、そして扱うデータの特性を考慮して、最適なディープコピー手法を選択してください。

5. ディープコピーに関する高度なトピックと注意点

ディープコピーは一見シンプルに見えますが、奥が深く、いくつかの高度な側面や注意点があります。

パフォーマンスの考慮

ディープコピーは、元のオブジェクトの全てのプロパティを再帰的に走査し、新しいオブジェクトにコピーするという性質上、コストのかかる処理です。

  • オブジェクトの規模: プロパティが非常に多い、またはネストが深いオブジェクトをディープコピーする場合、その処理時間は顕著に増加します。
  • 手法による違い:
    • structuredClone() はネイティブ実装のため、多くの場合で最も高速です。
    • JSON.parse(JSON.stringify()) は、比較的シンプルですが、文字列化と解析のオーバーヘッドがあります。
    • 自作の再帰関数は、実装の効率性によってパフォーマンスが大きく変動します。
    • ライブラリ(Lodashなど)は、内部で最適化されていますが、ライブラリ自体のオーバーヘッドもあります。

対策:

  • 本当にディープコピーが必要か検討する: シャローコピーで十分な場合や、一部のプロパティだけをディープコピーすれば良い場合もあります。
  • 変更されない部分の特定: オブジェクトの一部が常に不変である場合、その部分だけはシャローコピーを使い、変更される可能性がある部分だけをディープコピーする「部分ディープコピー」を検討します。
  • Immutable.jsなどの利用: 大規模なデータ構造を頻繁に変更するアプリケーションでは、Immutable.jsのようなライブラリを使って不変性を強制し、構造共有によって効率的な更新を行うアプローチも有効です。これは厳密にはディープコピーとは異なりますが、似た目的をより効率的に達成できます。

コピー対象の限界と非シリアライズ可能なオブジェクト

ディープコピーの手法によってコピーできるオブジェクトの範囲が異なります。

  • JSON.parse(JSON.stringify()): 純粋なJSONデータ型(プリミティブ、プレーンオブジェクト、配列)以外は基本的に失われるか、型が変換されます。
  • structuredClone(): Date, RegExp, Map, Set など多くの組み込みオブジェクトに対応しますが、Function, DOM ノード, Promise, WeakMap, WeakSet などはコピーできません。
  • 自作関数/ライブラリ: 実装次第で柔軟に対応できますが、FunctionPromiseのような「実行可能なコード」や「状態を持つ非シリアライズオブジェクト」を完全にディープコピーすることは、その性質上、非常に困難であるか、意味がない場合がほとんどです。これらは通常、参照渡しとして扱われます。

クラスインスタンスのディープコピー

JavaScriptのクラスインスタンスをディープコピーする場合、注意が必要です。

例えば、以下のようなクラスがあるとします。

class User {
  constructor(name, email) {
    this.name = name;
    this.email = email;
    this.createdAt = new Date();
    this.preferences = { theme: 'dark' };
  }

  getDetails() {
    return `${this.name} (${this.email})`;
  }
}

const user1 = new User('Alice', 'alice@example.com');
  1. JSON.parse(JSON.stringify(user1)): user1{ name: 'Alice', email: 'alice@example.com', createdAt: '...', preferences: { theme: 'dark' } } のようなプレーンオブジェクトになります。User クラスのインスタンスではなくなり、getDetails() メソッドも失われます。createdAt は文字列になります。

  2. structuredClone(user1): user1{ name: 'Alice', email: 'alice@example.com', createdAt: Dateオブジェクト, preferences: { theme: 'dark' } } のようなプレーンオブジェクトになります。User クラスのインスタンスではなくなりますが、createdAtDateオブジェクトとして正しくコピーされます。メソッドは失われます。

  3. 自作関数/ライブラリ (_.cloneDeep()): これらも通常、クラスインスタンスを「プレーンオブジェクト」としてディープコピーし、プロパティだけを複製します。プロトタイプチェーン上のメソッドやgetter/setterは失われます。

クラスインスタンスを「クラスインスタンスのまま」ディープコピーするには? これは一般的なディープコピーの範疇を超え、独自のロジックが必要です。

  • constructor を再呼び出しする:
    function deepCopyClassInstance(instance, CopyClass) {
      if (!(instance instanceof CopyClass)) {
        throw new Error('Provided instance is not an instance of the specified class.');
      }
      const newInstance = new CopyClass(); // 新しいインスタンスを作成
      for (const key in instance) {
        if (Object.prototype.hasOwnProperty.call(instance, key)) {
          // ここで、各プロパティをディープコピーするロジックを適用
          // 例えば、structuredClone() を使う
          newInstance[key] = structuredClone(instance[key]);
        }
      }
      return newInstance;
    }
    
    const user2 = deepCopyClassInstance(user1, User);
    console.log(user2 instanceof User); // true
    console.log(user2.getDetails()); // Alice (alice@example.com)
    
    この方法は、コンストラクタが引数なしでインスタンスを作成できること、そして全ての必要なプロパティが列挙可能であることを前提とします。より複雑なクラスの場合、Symbol.for('deepCopy') のような特殊なメソッドをクラス内に実装して、クラス自身がディープコピー方法を定義するようにすることもあります。

オブジェクトのプロパティディスクリプタの扱い

オブジェクトのプロパティは、value だけでなく、writable, enumerable, configurable といった「プロパティディスクリプタ」を持っています。ディープコピーは通常、これらのディスクリプタを考慮せず、単純に value をコピーします。

  • JSON.parse(JSON.stringify()): 全てのディスクリプタがデフォルト値(writable: true, enumerable: true, configurable: true)になります。
  • structuredClone(): ディスクリプタはコピーされません。configurable: false で定義されたプロパティも、コピー後は変更可能になります。
  • 自作関数/ライブラリ: デフォルトではディスクリプタはコピーされません。もし必要であれば、Object.getOwnPropertyDescriptor()Object.defineProperty() を組み合わせて、コピー先のオブジェクトにもディスクリプタを適用するような複雑な実装が必要になります。

ほとんどのアプリケーションでは、このレベルのコピーの再現性は求められませんが、特定のユースケース(例えば、内部的にプロパティの書き込みを禁止しているオブジェクトを扱う場合)では注意が必要です。


6. まとめ:あなたのコードをより堅牢に

この記事では、JavaScriptにおける配列(およびオブジェクト)のディープコピーについて、その必要性からシャローコピーとの違い、そして主要な4つの手法までを徹底的に解説しました。

重要なポイントをまとめると:

  • シャローコピーは第一階層のみを複製し、ネストされたオブジェクトは元の参照を共有します。
  • ディープコピーは完全に独立した新しいデータ構造を作成し、元のデータへの影響を排除します。
  • JSON.parse(JSON.stringify()) は手軽ですが、扱えるデータ型が限られ、多くのデメリットがあります。シンプルなデータ向け。
  • structuredClone() は最新かつ最も推奨される方法で、多くのデータ型と循環参照に対応します。ほとんどのモダンなWeb開発で第一選択肢となります。
  • 自作の再帰関数は、ディープコピーの原理を理解し、特定の要件に合わせてカスタマイズしたい場合に有用ですが、実装の手間とリスクを伴います。
  • Lodash _.cloneDeep() は、非常に堅牢で広範なデータ型に対応するライブラリ機能であり、複雑なデータ構造や大規模プロジェクトに適しています。

JavaScript開発において、データの不変性を保ち、予期せぬ副作用を防ぐことは、バグの少ない、堅牢で保守しやすいコードを書く上で非常に重要です。ディープコピーは、この目標を達成するための強力なツールの一つです。

今日からあなたのコードにディープコピーの概念を取り入れ、状況に応じて最適な手法を選択してください。これにより、あなたのJavaScriptアプリケーションはより安全に、そして予測可能になるでしょう。

Happy Coding!

\ この記事をシェア/
この記事を書いた人
pekemalu
I love codes. I also love prompts (spells). But I get a lot of complaints (errors). I want to be loved by both of you as soon as possible.
Image