【JavaScript】オブジェクト・配列をディープコピーする方法|structuredClone・JSON・再帰コピー・浅いコピーとの違い

【JavaScript】オブジェクト・配列をディープコピーする方法|structuredClone・JSON・再帰コピー・浅いコピーとの違い JavaScript

JavaScriptでオブジェクトや配列を別の変数へ代入しても、中身はコピーされません。代入されるのは「同じデータを指す参照」だけです。そのため、コピーしたつもりの変数を書き換えると、元のデータまで一緒に変わってしまいます。

この「参照の共有」は、状態管理、フォームの下書き保存、Undo・Redo、設定のリセットなど、元の値を保持したいあらゆる場面でバグの原因になります。元データを壊さずに複製するには、ディープコピーが必要です。

この記事では、浅いコピーとの違いから、標準APIのstructuredClone()、JSONを使う方法の落とし穴、自作の再帰コピー関数まで、実務で迷わないように順を追って解説します。

先に結論

  • 代入(=)はコピーではなく参照の共有です。プリミティブ値だけが値としてコピーされます。
  • スプレッド構文やObject.assign()は浅いコピーです。ネストしたオブジェクトは元と共有されたままです。
  • 最も簡単で安全な方法は標準APIのstructuredClone()です。循環参照やDate・Map・Setも複製できます。
  • ただしstructuredClone()は関数・DOMノード・クラスのプロトタイプを複製できません。
  • JSON.parse(JSON.stringify())は手軽ですが、Dateが文字列化し、undefinedや関数が消えるなど変換の副作用が多くあります。
  • 特殊な型を含み細かく制御したいときだけ、WeakMapで循環参照に対応した再帰コピーを自作します。

コピーの土台になるスプレッド構文や分割代入は配列の使い方まとめ、各種演算子の挙動は演算子の使い方まとめでも解説しています。なお、HTML要素そのものを複製したい場合はオブジェクトのコピーではなく要素を複製(クローン)する方法のcloneNodeを使います。

スポンサーリンク

なぜ代入ではコピーにならないのか

まず問題を正確に理解します。数値や文字列などのプリミティブ値は、代入すると値そのものがコピーされます。一方、オブジェクトや配列を代入すると、コピーされるのは中身ではなく「同じ実体を指す参照」です。

reference-vs-value.js
// プリミティブは値がコピーされる
let a = 10;
let b = a;
b = 20;
console.log(a); // 10(影響なし)

// オブジェクトは参照が共有される
const original = { count: 1 };
const copy = original;
copy.count = 99;
console.log(original.count); // 99(元まで変わる)

copyoriginalと同じオブジェクトを指しているため、どちらを書き換えても同じ実体が変わります。コピーしたいなら、新しいオブジェクトを作って中身を移し替える必要があります。

浅いコピーとディープコピーの違い

コピーには2種類あります。浅いコピー(シャローコピー)は一番外側だけを新しく作り、内側のネストしたオブジェクトは元と共有します。ディープコピーは内側まですべて新しく作り直すため、元データと完全に独立します。

shallow-vs-deep.js
const original = {
  name: "山田",
  profile: { age: 30 }
};

// 浅いコピー(スプレッド構文)
const shallow = { ...original };
shallow.name = "佐藤";        // 外側はOK
shallow.profile.age = 99;     // 内側は共有されている

console.log(original.name);        // 山田(独立している)
console.log(original.profile.age); // 99(一緒に変わってしまった)

外側のnameは独立しましたが、ネストしたprofileは元と同じ参照を共有しているため、ageの変更が元へ波及しています。これが浅いコピーの限界です。1段でもネストがあるデータでは、浅いコピーは安全ではありません。

スプレッド構文・Object.assignは浅いコピー

「ディープコピー」を検索してよく出てくるスプレッド構文({ ...obj }[ ...arr ])とObject.assign()は、どちらも浅いコピーです。配列のslice()concat()Array.from()も同じく一番外側だけをコピーします。

shallow-copy-methods.js
const source = { a: 1, nested: { b: 2 } };

// 以下はすべて浅いコピー
const s1 = { ...source };
const s2 = Object.assign({}, source);

const list = [{ id: 1 }, { id: 2 }];
const l1 = [...list];
const l2 = list.slice();
const l3 = Array.from(list);

// 外側は別物だが、nested や各要素は共有されたまま
console.log(s1.nested === source.nested); // true
console.log(l1[0] === list[0]);           // true
浅いコピーで十分なケース

扱うデータがネストを持たない一次元の配列やフラットなオブジェクト({ id: 1, name: "x" }のような構造)であれば、浅いコピーで問題ありません。プリミティブ値しか含まないなら、スプレッド構文が最も手軽で高速です。

ネストが浅く構造が分かっているなら、必要な階層だけ手動で展開する方法もあります。ただし階層が深い、または構造が動的に変わるデータには向きません。

manual-nested-spread.js
const state = {
  user: { name: "山田", tags: ["a", "b"] }
};

// 1段ネストを手動で展開して独立させる
const next = {
  ...state,
  user: {
    ...state.user,
    tags: [...state.user.tags]
  }
};

next.user.tags.push("c");
console.log(state.user.tags); // ["a", "b"](独立)

structuredCloneでディープコピーする(最も簡単な標準API)

現在のJavaScriptには、ディープコピー専用の標準関数structuredClone()があります。グローバル関数なので読み込み不要で、ネストの深さを気にせず一発で複製できます。まずこれを第一候補に考えてください。

structured-clone-basic.js
const original = {
  name: "山田",
  profile: { age: 30, tags: ["js", "css"] },
  createdAt: new Date("2026-01-01"),
  scores: new Map([["math", 90]])
};

const copy = structuredClone(original);

copy.profile.age = 99;
copy.profile.tags.push("html");

console.log(original.profile.age);   // 30(独立している)
console.log(original.profile.tags);  // ["js", "css"]
console.log(copy.createdAt instanceof Date); // true(Dateのまま)
console.log(copy.scores instanceof Map);     // true(Mapのまま)

JSONを使う方法と違い、structuredClone()DateDateのまま、MapSetも型を保ったまま複製します。循環参照(自分自身を参照するデータ)もエラーにならず正しく複製できる点が大きな利点です。

structured-clone-circular.js
const node = { name: "root" };
node.self = node; // 循環参照

const copy = structuredClone(node);

console.log(copy.self === copy);       // true(循環構造を維持)
console.log(copy.self === node);       // false(元とは独立)
structuredCloneが複製できる主な型

  • プレーンオブジェクト、配列、プリミティブ値
  • DateRegExpMapSet
  • ArrayBuffer、各種TypedArrayDataView
  • BlobFileImageData
  • 循環参照や、複数の場所から同じオブジェクトを参照する共有参照
structuredCloneが複製できない・失われるもの

  • 関数とDOMノードは複製できず、DataCloneErrorという例外が発生します。
  • Symbolも複製できず、例外になります。
  • クラスのインスタンスはプロトタイプ情報が失われ、ただのプレーンオブジェクトになります(instanceofが成立しなくなります)。
  • getter / setterは呼び出した結果の値だけが普通のプロパティとしてコピーされ、アクセサーとしての性質は失われます。
structured-clone-limit.js
// 関数を含むと例外になる
try {
  structuredClone({ run: () => 1 });
} catch (error) {
  console.log(error.name); // DataCloneError
}

// クラスインスタンスはプレーンオブジェクトになる
class User {
  constructor(name) {
    this.name = name;
  }
  greet() {
    return `Hi, ${this.name}`;
  }
}

const copy = structuredClone(new User("山田"));
console.log(copy.name);            // 山田(データは残る)
console.log(copy instanceof User); // false(型は失われる)
console.log(typeof copy.greet);    // undefined(メソッドは消える)

対応環境について補足します。structuredClone()は主要ブラウザの2022年前後のバージョンとNode.js 17以降で利用できます。よほど古い環境を相手にしない限り、そのまま使って問題ありません。古い環境も想定する場合は、後述のJSON方式や自作関数をフォールバックにします。

JSON.parse(JSON.stringify())の方法と落とし穴

古くから使われてきた手法が、一度JSON文字列に変換してから戻すやり方です。structuredClone()が使えない環境でも動き、コードが短いのが利点です。ただし変換による副作用が多いため、データの中身を理解したうえで使う必要があります。

json-deep-copy.js
const original = {
  name: "山田",
  profile: { age: 30 }
};

const copy = JSON.parse(JSON.stringify(original));

copy.profile.age = 99;
console.log(original.profile.age); // 30(独立している)

プリミティブ値だけで構成された素直なデータなら、この方法でも独立した複製が得られます。問題は、JSONで表現できない値が混ざったときです。

json-deep-copy-pitfalls.js
const data = {
  date: new Date("2026-01-01"),
  fn: () => 1,
  undef: undefined,
  num: NaN,
  set: new Set([1, 2]),
  map: new Map([["a", 1]])
};

const copy = JSON.parse(JSON.stringify(data));

console.log(typeof copy.date); // string(Dateが文字列になる)
console.log("fn" in copy);     // false(関数は消える)
console.log("undef" in copy);  // false(undefinedは消える)
console.log(copy.num);         // null(NaNはnullになる)
console.log(copy.set);         // {}(Setは空オブジェクトに)
console.log(copy.map);         // {}(Mapは空オブジェクトに)
JSON方式で起きる変換と例外

  • DateはISO形式の文字列になります(Dateに戻りません)。
  • undefined関数Symbolはオブジェクトのプロパティから消えます(配列内ではnullになります)。
  • NaNInfinitynullに変換されます。
  • MapSetRegExpは中身を失い、空オブジェクト{}になります。
  • 循環参照を含むとTypeErrorで失敗します。
  • BigIntを含むとTypeErrorで失敗します。
  • クラスのインスタンスはプロトタイプを失い、プレーンオブジェクトになります。

これらの仕様は、配列の比較でJSON.stringifyを使うときにも同じ落とし穴になります。値の同一判定での注意点は配列を比較する方法でも触れています。フォームの下書きを保存するような用途では、保存先のlocalStorageの使い方側でもJSON化の制約を意識しておくと安全です。

自作の再帰ディープコピー関数

特殊な型を含みつつ、変換の挙動を自分で制御したい場合は、再帰的にコピーする関数を用意します。ポイントは、DateMapSet・配列・オブジェクトを型ごとに分けて扱うことと、WeakMapで「すでにコピー済みのオブジェクト」を記録して循環参照と共有参照に対応することです。

custom-deep-clone.js
function deepClone(value, seen = new WeakMap()) {
  // プリミティブと関数はそのまま返す
  if (value === null || typeof value !== "object") {
    return value;
  }

  // すでにコピー済みなら同じ複製を返す(循環参照・共有参照対策)
  if (seen.has(value)) {
    return seen.get(value);
  }

  // Date は新しい Date を作る
  if (value instanceof Date) {
    return new Date(value.getTime());
  }

  // RegExp はパターンとフラグから作り直す
  if (value instanceof RegExp) {
    return new RegExp(value.source, value.flags);
  }

  // 配列
  if (Array.isArray(value)) {
    const result = [];
    seen.set(value, result);
    for (const item of value) {
      result.push(deepClone(item, seen));
    }
    return result;
  }

  // Map
  if (value instanceof Map) {
    const result = new Map();
    seen.set(value, result);
    for (const [key, val] of value) {
      result.set(deepClone(key, seen), deepClone(val, seen));
    }
    return result;
  }

  // Set
  if (value instanceof Set) {
    const result = new Set();
    seen.set(value, result);
    for (const item of value) {
      result.add(deepClone(item, seen));
    }
    return result;
  }

  // プレーンオブジェクト
  const result = {};
  seen.set(value, result);
  for (const key of Reflect.ownKeys(value)) {
    result[key] = deepClone(value[key], seen);
  }
  return result;
}

使い方はstructuredClone()と同じく、値を渡すだけです。seen引数は再帰の内部でのみ使うため、呼び出し側では指定しません。

custom-deep-clone-usage.js
const original = {
  name: "山田",
  profile: { age: 30, tags: ["js"] },
  createdAt: new Date("2026-01-01")
};
original.self = original; // 循環参照

const copy = deepClone(original);

copy.profile.tags.push("css");
console.log(original.profile.tags); // ["js"](独立)
console.log(copy.createdAt instanceof Date); // true
console.log(copy.self === copy);    // true(循環構造を維持)
自作関数を選ぶ基準

関数やクラスのメソッドまで含めて複製したい、特定の型だけ独自のルールで変換したい、といった細かい要件があるときに自作が活きます。逆に、扱うデータがstructuredClone()の対応範囲に収まるなら、自作よりも標準APIのほうが安全で確実です。

なお、Reflect.ownKeys()を使うとSymbolキーや列挙不可のキーも対象になります。プレーンオブジェクトだけを想定するならObject.keys()でも構いません。クラスのインスタンスを正確に複製したい場合は、プロトタイプの引き継ぎやgetPrototypeOfの考慮が追加で必要になり、難易度が一段上がります。

ライブラリを使う選択肢

すでにユーティリティライブラリを導入しているプロジェクトでは、lodashのcloneDeep()のような実装済みの関数を使うのも堅実です。多くの型を網羅し、エッジケースのテストも積み重ねられています。

lodash-clonedeep.js
import cloneDeep from "lodash/cloneDeep";

const copy = cloneDeep(original);

ただし、ディープコピーひとつのためだけに新しくライブラリを追加するのは慎重に判断してください。標準のstructuredClone()で要件を満たせるなら、依存を増やさないほうがバンドルサイズの面でも有利です。

どの方法を選ぶか

ここまでの方法を、対応範囲で整理します。迷ったら上から順に検討すると判断しやすくなります。

方法 ネストも複製 Date・Map・Set 循環参照 関数を含む
スプレッド / Object.assign ×(浅い) 参照を共有 ○(参照のまま)
structuredClone() ×(例外)
JSON.parse(JSON.stringify()) ×(消失・文字列化) ×(例外) ×(消失)
自作の再帰コピー ○(実装次第) ○(WeakMap) ○(実装次第)

基本方針はシンプルです。フラットなデータならスプレッド構文、ネストがあり標準型に収まるならstructuredClone()、特殊な要件があるときだけ自作関数やライブラリを選びます。JSON方式は手軽さが魅力ですが、副作用を理解したうえで限定的に使うのが安全です。

よくある失敗

浅いコピーで満足してネストを共有してしまう

スプレッド構文やObject.assign()を「ディープコピー」と思い込み、ネストしたオブジェクトを書き換えて元データを壊すのが最も多い失敗です。データに1段でもネストがあるなら、浅いコピーでは不十分だと考えてください。

JSONコピーでDateが文字列に変わる

JSON.parse(JSON.stringify())を通すとDateはISO文字列になります。コピー後にgetFullYear()などを呼ぶと「関数ではない」というエラーになります。日付を含むデータにはstructuredClone()を使うか、文字列からDateへ戻す処理を加えてください。

structuredCloneに関数やDOMを渡して例外になる

イベントハンドラーやメソッド、DOMノードを含むオブジェクトをstructuredClone()へ渡すとDataCloneErrorで失敗します。複製したいのは純粋なデータ部分だけのはずなので、関数やDOMはコピー対象から分離してください。

クラスのインスタンスをコピーして型が消える

structuredClone()やJSON方式でクラスのインスタンスをコピーすると、プロトタイプが失われてプレーンオブジェクトになります。instanceofやメソッド呼び出しが必要なら、コピー後にnewで作り直すか、専用の復元処理を用意します。

自作の再帰コピーが循環参照で無限ループする

再帰コピーを自作する際、WeakMapなどで訪問済みオブジェクトを記録しないと、循環参照を含むデータで無限ループになり、最終的に「Maximum call stack size exceeded」で停止します。共有参照も別々に複製され、構造が崩れます。訪問済み管理は必須です。

よくある質問

Q結局どの方法を使えばいいですか?
Aネストのあるデータをコピーするなら、まずstructuredClone()を検討してください。関数やクラスのメソッドを含めたい、特殊な型を独自ルールで変換したいといった要件があるときだけ、自作関数やライブラリを使います。フラットなデータなら浅いコピーで十分です。
Qスプレッド構文はディープコピーではないのですか?
Aはい、浅いコピーです。一番外側のオブジェクトや配列は新しく作られますが、ネストした中身は元と同じ参照を共有します。ネストを持つデータでは元を壊す可能性があります。
QstructuredCloneはどの環境で使えますか?
A主要ブラウザの2022年前後以降のバージョンと、Node.js 17以降で利用できます。グローバル関数なので追加の読み込みは不要です。非常に古い環境を相手にする場合のみ、JSON方式や自作関数をフォールバックにします。
QJSON方式は使ってはいけませんか?
A禁止ではありません。プリミティブ値だけで構成された素直なデータなら問題なく動きます。ただし、Dateの文字列化、undefinedや関数の消失、循環参照での例外といった副作用があるため、データの中身を把握したうえで使ってください。
QDOM要素を複製したいときもディープコピーですか?
Aいいえ。HTML要素そのものを複製する場合は、オブジェクトのコピーではなくcloneNode(true)を使います。詳しくは要素を複製(クローン)する方法を参照してください。

まとめ

  • 代入は参照の共有であり、オブジェクトや配列の中身はコピーされません。
  • スプレッド構文・Object.assign()slice()は浅いコピーで、ネストは元と共有されます。
  • ネストのあるデータはstructuredClone()が第一候補です。Date・Map・Set・循環参照も複製できます。
  • structuredClone()は関数・DOM・Symbolを複製できず、クラスのプロトタイプは失われます。
  • JSON.parse(JSON.stringify())は手軽ですが、型の変換や値の消失といった副作用に注意します。
  • 特殊な要件があるときだけ、WeakMapで循環参照に対応した再帰コピーを自作します。

コピーの失敗は「動いているように見えて、別の場所のデータを静かに壊す」やっかいなバグです。扱うデータの構造と含まれる型を把握し、それに合った方法を選ぶことが、安全な状態管理の第一歩になります。