オブジェクトをコピーしたつもりが、コピー先をいじったら元のオブジェクトまで変わってしまった——JavaScript を書いていると必ず一度は踏む落とし穴です。スプレッド構文や Object.assign() では入れ子の中身までは複製されず、JSON.parse(JSON.stringify(obj)) は手軽な代わりに Date や Map が壊れます。この記事では、入れ子まで丸ごと複製できる structuredClone() の使い方を、コピーできる型・できない型、transfer オプション、実践的な使いどころ、DataCloneError が出るときの原因まで順に解説します。
目次
オブジェクトの代入は「コピー」ではない
まず前提の確認です。オブジェクトを別の変数に代入しても、複製は作られません。コピーされるのはオブジェクトそのものではなく、オブジェクトを指す参照だけです。つまり変数は2つでも、中身は同じ1つのオブジェクトを指しています。
const original = { name: '田中', age: 30 };
const copy = original; // 参照が渡されるだけ
copy.age = 31;
console.log(original.age); // 31 ← 元まで変わってしまう
console.log(original === copy); // true(同じオブジェクト)
数値や文字列などのプリミティブ値は代入時に値そのものが渡されるので、この問題は起きません。厄介なのはオブジェクト・配列・Map といった参照型で、「編集用にコピーを取ってから作業する」つもりの処理が、実際には元データを直接書き換えている、という事故につながります。
浅いコピーと JSON 経由のコピー、それぞれの限界
参照の共有を避けるための定番が2つあります。ただし、どちらも万能ではありません。
スプレッド構文と Object.assign() は1階層しかコピーしない
{ ...original } や Object.assign({}, original) は新しいオブジェクトを作りますが、コピーされるのは一番外側のプロパティだけです。プロパティの値がオブジェクトだった場合、そこに入るのは「そのオブジェクトへの参照」なので、入れ子の部分は元と共有されたままになります。これを浅いコピー(シャローコピー)と呼びます。
const original = {
name: '田中',
address: { city: '那覇市', zip: '900-0000' }, // 入れ子のオブジェクト
};
const copy = { ...original };
copy.name = '鈴木'; // 1階層目なのでコピー側だけ変わる
copy.address.city = '浦添市'; // 入れ子は共有されている
console.log(original.name); // '田中' ← 無事
console.log(original.address.city); // '浦添市' ← 元まで変わった
console.log(original.address === copy.address); // true
階層が浅いうちは気づきにくく、データ構造が育ってから表面化するのがこの問題のいやらしいところです。「1階層目だけ書き換える」と分かっているなら浅いコピーで十分ですが、入れ子を含むデータを安全に編集したいなら不十分です。
JSON を経由するコピーは値が壊れる
そこで長く使われてきたのが JSON.parse(JSON.stringify(obj)) です。一度 JSON 文字列に変換してから読み直すので、入れ子まで含めた別物が手に入ります。手軽で依存も不要ですが、JSON で表現できる値しか通らないという根本的な制約があります。
const original = {
savedAt: new Date('2026-01-01'),
tags: new Set(['js', 'web']),
scores: new Map([['math', 80]]),
memo: undefined,
render: () => 'hello',
};
const copy = JSON.parse(JSON.stringify(original));
console.log(copy.savedAt); // '2026-01-01T00:00:00.000Z' ← 文字列になった
console.log(copy.tags); // {} ← 中身が消えた
console.log(copy.scores); // {} ← 中身が消えた
console.log('memo' in copy); // false ← プロパティごと消えた
console.log('render' in copy); // false ← 関数も消えた
Date は文字列に変わり、undefined と関数を値に持つプロパティはまるごと省略され、Map や Set は中身を持たない空のオブジェクトになります。さらに、オブジェクトが自分自身を参照する循環参照を含んでいると、JSON.stringify() は TypeError を投げて処理そのものが止まります。
structuredClone() なら入れ子まで丸ごと複製できる
structuredClone() は、値を深く複製(ディープコピー)して返すグローバル関数です。使い方は、複製したい値を渡すだけです。
const original = {
name: '田中',
address: { city: '那覇市', zip: '900-0000' },
savedAt: new Date('2026-01-01'),
tags: new Set(['js', 'web']),
};
const copy = structuredClone( original );
copy.address.city = '浦添市';
copy.tags.add('css');
console.log(original.address.city); // '那覇市' ← 元は無傷
console.log(original.tags.size); // 2 ← 元は無傷
console.log(copy.savedAt instanceof Date); // true ← Date のまま
console.log(original.address === copy.address); // false(別オブジェクト)
入れ子のオブジェクトも Date も Set も、型を保ったまま別のオブジェクトとして作り直されています。内部では 構造化複製アルゴリズム(structured clone algorithm) という、ブラウザーが postMessage() でデータをやり取りするときにも使っている仕組みが働いています。もともと存在していた複製の仕組みを、そのまま同期的に呼び出せるようにしたのが structuredClone() だと考えると分かりやすいでしょう。
3つのコピー方法で結果がどう変わるかを、実際に動かして比べてみてください。次のデモはボタンを押すとその方法でコピーを作り、コピー側の style.color だけを 'blue' に書き換えた結果を表示します。
<p class="lead">コピーの方法を選ぶと、コピー側の <code>style.color</code> だけを書き換えます。元のオブジェクトがどうなるか見てみてください。</p>
<div class="buttons">
<button data-method="spread">スプレッド構文</button>
<button data-method="json">JSON 経由</button>
<button data-method="structured">structuredClone</button>
</div>
<pre id="output">上のボタンを押してください。</pre>
body {
font-family: sans-serif;
margin: 16px;
}
.lead {
font-size: 14px;
color: #444;
}
.buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
padding: 8px 14px;
border: 1px solid #007bff;
border-radius: 6px;
background: #fff;
color: #007bff;
cursor: pointer;
}
button:hover {
background: #eaf3ff;
}
#output {
margin-top: 12px;
padding: 12px;
background: #f5f5f5;
border-radius: 6px;
white-space: pre-wrap;
font-size: 13px;
line-height: 1.8;
}
// 毎回まっさらな「元のオブジェクト」を作る
function createOriginal() {
return {
style: { color: 'red' }, // 入れ子のオブジェクト
savedAt: new Date('2026-01-01'), // Date
tags: new Set(['js']), // Set
memo: undefined, // undefined のプロパティ
};
}
const copiers = {
spread: (obj) => ({ ...obj }),
json: (obj) => JSON.parse(JSON.stringify(obj)),
structured: (obj) => structuredClone(obj),
};
const output = document.getElementById('output');
function describe(obj) {
const savedAt = obj.savedAt instanceof Date
? 'Date オブジェクト'
: typeof obj.savedAt + '(' + obj.savedAt + ')';
const tags = obj.tags instanceof Set
? 'Set(' + obj.tags.size + '件)'
: JSON.stringify(obj.tags);
return ' style.color : ' + obj.style.color + '\n'
+ ' savedAt : ' + savedAt + '\n'
+ ' tags : ' + tags + '\n'
+ ' memo : ' + ('memo' in obj ? String(obj.memo) : 'プロパティごと消えた');
}
document.querySelectorAll('button').forEach((button) => {
button.addEventListener('click', () => {
const method = button.dataset.method;
const original = createOriginal();
const copy = copiers[method](original);
// コピー側の入れ子だけを書き換えたつもり
copy.style.color = 'blue';
output.textContent = '【' + button.textContent + '】\n\n'
+ '-- 元のオブジェクト --\n' + describe(original) + '\n\n'
+ '-- コピー --\n' + describe(copy);
});
});
スプレッド構文では元のオブジェクトの style.color まで 'blue' に変わり、JSON 経由では Date が文字列に、Set が空のオブジェクトに化けます。structuredClone だけが、元を無傷に保ったまま型も維持できているはずです。
コピーできるもの・できないもの
構造化複製アルゴリズムが扱える型は決まっています。JSON より圧倒的に広い一方、JavaScript の値なら何でも通るわけではありません。まずは複製できるものです。
| 複製できるもの | 補足 |
|---|---|
| プリミティブ値 | 数値・文字列・真偽値・null・undefined・BigInt。Symbol は除く |
Array / プレーンオブジェクト | 入れ子になっていても、下まですべて複製される |
Date | Date オブジェクトのまま複製される |
RegExp | 複製されるが、lastIndex プロパティは保持されない |
Map / Set | キーと値も再帰的に複製される |
ArrayBuffer / TypedArray / DataView | バイト列がコピーされる(transfer を使えば移動もできる) |
Blob / File / FileList | ファイル系のオブジェクトも扱える |
ImageData / ImageBitmap | Canvas 関連の画像データ |
Error 系 | Error・TypeError・RangeError など一部の型。保持されるのは name / message / cause で、stack は保証されない |
| 循環参照 | 自分自身を参照する構造も、参照関係を保ったまま正しく複製される |
とくに Map / Set と循環参照に対応している点が、JSON 経由との大きな差です。循環参照は、次のように親子を相互に参照するデータ構造で自然に発生します。
const parent = { name: '親', children: [] };
const child = { name: '子', parent: parent }; // 親を参照
parent.children.push(child); // 親も子を参照(循環)
// JSON.stringify(parent); // TypeError: Converting circular structure to JSON
const copy = structuredClone(parent);
console.log(copy.children[0].name); // '子'
console.log(copy.children[0].parent === copy); // true ← 循環関係も再現される
console.log(copy.children[0].parent === parent); // false(元とは別物)
一方、複製できない値を渡すと DataCloneError という名前の DOMException が投げられます。エラーにはならないものの「思っていたものと違うかたちで複製される」ケースもあり、こちらのほうが気づきにくいので注意が必要です。
| 複製できないもの | どうなるか |
|---|---|
| 関数(メソッドを含む) | DataCloneError が発生する |
Symbol | DataCloneError が発生する |
DOM ノード(Element など) | DataCloneError が発生する。ImageData のように複製できる例外的なオブジェクトもある |
| プロパティディスクリプタ | 失われる。読み取り専用(writable: false)などの設定は複製先に引き継がれない |
| ゲッター・セッター | 失われる。ゲッターは呼び出された結果の値として複製される |
| プロトタイプチェーン | たどられない。クラスのインスタンスはプレーンオブジェクトになる |
3つのコピー方法を比べる
ここまでの内容を、代表的な3つの方法で並べて整理します。用途に応じて選べるよう、どの値がどう扱われるかを押さえておきましょう。
| 対象 | スプレッド構文 / Object.assign() | JSON.parse(JSON.stringify()) | structuredClone() |
|---|---|---|---|
| 入れ子のオブジェクト | 元と共有される(浅いコピー) | 複製される | 複製される |
Date | 同じ Date を共有 | 文字列になる | Date のまま複製 |
Map / Set | 同じオブジェクトを共有 | 空のオブジェクトになる | 中身ごと複製 |
undefined のプロパティ | 保持される | プロパティごと消える | 保持される |
| 関数 | 参照がコピーされる | プロパティごと消える | DataCloneError |
| 循環参照 | 共有されるだけなので問題なし | TypeError で失敗 | 関係を保って複製 |
| クラスのインスタンス | プロトタイプは引き継がれない | プレーンオブジェクトになる | プレーンオブジェクトになる |
| ゲッター | 呼び出された結果の値になる | 呼び出された結果の値になる | 呼び出された結果の値になる |
1階層だけの単純なオブジェクトなら、スプレッド構文が最も短く速い選択です。入れ子を含むデータを安全に複製したいなら structuredClone()、というのが基本的な使い分けになります。JSON 経由のコピーは、いまとなっては「対応環境の都合でほかに手がないとき」の手段と考えてよいでしょう。
transfer オプションで所有権を移す
structuredClone() は第2引数にオプションオブジェクトを取り、transfer プロパティに配列を渡せます。ここに指定したオブジェクトはコピーではなく移動され、元の側は使えなくなります。
const buffer = new ArrayBuffer(1024);
const data = { label: '音声データ', buffer: buffer };
// buffer だけは中身をコピーせず、所有権ごと複製先へ移す
const clone = structuredClone( data, { transfer: [buffer] } );
console.log(clone.buffer.byteLength); // 1024 ← 移動先では使える
console.log(buffer.byteLength); // 0 ← 元は切り離された(detached)
console.log(clone.label); // '音声データ'(transfer 以外は通常のコピー)
大きなバイナリデータをコピーすると、その分だけメモリと時間を消費します。transfer を使えば中身の複製が起こらないため、数 MB の ArrayBuffer でも一瞬で受け渡せます。移動できるのは ArrayBuffer のほか、MessagePort・ReadableStream・ImageBitmap・OffscreenCanvas といった転送可能オブジェクト(Transferable objects)に限られます。
注意したいのは、移動したあと元の変数は空になるという点です。上の例のように byteLength が 0 になり、TypedArray 越しに読み書きしようとすると TypeError になります。「渡したらもう触らない」と決めて使う機能だと理解しておいてください。
実践例1:設定の初期値を壊さずに編集する
既定の設定オブジェクトを1か所に置き、ユーザーごとの設定はそこから作る——という形はよくある設計です。ここで浅いコピーを使うと、入れ子の設定を書き換えたときに既定値まで汚染され、次に作った設定にも影響が残ります。
// アプリ全体で共有する既定の設定(絶対に書き換えたくない)
const defaultSettings = {
theme: 'light',
notify: { email: true, push: false },
hiddenIds: new Set(),
updatedAt: new Date('2026-01-01'),
};
// 既定値から独立した設定オブジェクトを作って返す
function createSettings(overrides = {}) {
const settings = structuredClone( defaultSettings );
return Object.assign(settings, overrides);
}
const userA = createSettings({ theme: 'dark' });
userA.notify.push = true; // 入れ子を書き換える
userA.hiddenIds.add(12); // Set も書き換える
const userB = createSettings();
console.log(defaultSettings.notify.push); // false ← 既定値は無傷
console.log(userB.notify.push); // false ← 影響を受けていない
console.log(userB.hiddenIds.size); // 0
structuredClone() を使ったことで、notify も hiddenIds も呼び出しごとに新しく作り直されます。設定の受け渡しに Object.freeze() で凍結する方法もありますが、凍結は「書き換えを禁じる」だけで、編集したい場面には向きません。編集したいなら複製する、と覚えておくとよいでしょう。
実践例2:スナップショットで「元に戻す」を実装する
エディターやフォームの「元に戻す(Undo)」は、操作前の状態を保存しておき、必要になったら書き戻すことで実現できます。ここで保存するのが参照だと、あとから状態が変わるたびに履歴の中身まで一緒に変わってしまい、機能として成立しません。状態を深く複製して積んでおくのが要点です。
let state = {
title: '無題のドキュメント',
tags: new Set(['下書き']),
blocks: [{ type: 'paragraph', text: '' }],
};
const history = [];
// 変更を加える前に、いまの状態のスナップショットを積む
function snapshot() {
history.push( structuredClone( state ) );
}
function undo() {
if (history.length === 0) return;
state = history.pop();
}
snapshot();
state.blocks[0].text = '本文を書き始めました';
state.tags.add('編集中');
undo();
console.log(state.blocks[0].text); // '' ← 元に戻った
console.log(state.tags.has('編集中')); // false
history.push(state) と書いてしまうと、履歴に入るのは現在の状態への参照なので、そのあと state.blocks[0].text を変えた時点で履歴の中身も同じ値になります。複製してから積むことで、そのときの姿がそのまま保存されます。Set や Date をそのまま扱えるので、状態に含まれる型を JSON 用に変換し直す必要もありません。
ひとつだけ意識しておきたいのが処理コストです。structuredClone() はデータ全体を作り直すため、巨大な状態をキー入力のたびに複製すれば当然重くなります。スナップショットを取るのは「操作の区切り」に限る、履歴の上限を決めて古いものから捨てる、といった設計を添えておくと安心です。
使える環境
structuredClone() はブラウザーのウィンドウ(window)でも Web Worker でも、グローバル関数としてそのまま呼び出せます。ブラウザーへの搭載は 2021 年末から 2022 年前半にかけて進み、Chrome と Edge は 98、Firefox は 94、Safari は 15.4 から利用できます。現在の主要ブラウザーの最新版であれば、いずれも対応済みです。
Node.js では 17.0.0 以降でグローバルに用意されています。require や import は不要で、ブラウザーと同じように書けます。Deno でも同様にグローバル関数として使えます。
エラーや想定外の結果が出るとき
使い方自体は単純なので、つまずくポイントもほぼ3つに集約されます。
DataCloneError が出る
もっとも多いのが、複製しようとしたオブジェクトのどこかに関数が混ざっているケースです。構造化複製アルゴリズムは関数を扱えないため、途中の階層にひとつでもあれば DataCloneError という名前の DOMException が投げられます。
const state = {
count: 0,
onUpdate: () => console.log('updated'), // これが原因
};
try {
structuredClone(state);
} catch (error) {
console.log(error.name); // 'DataCloneError'(メッセージはブラウザーごとに異なる)
}
// 対処1: 複製したいデータと、ふるまい(関数)を分けて持つ
const data = { count: 0 };
const copy = structuredClone(data);
// 対処2: 関数を含むプロパティを除いてから複製する
const { onUpdate, ...cloneable } = state;
const copy2 = structuredClone(cloneable);
エラーメッセージには複製できなかった値が含まれることが多いので、まずはそこを手がかりに原因の値を特定してください。関数以外では、DOM 要素(document.querySelector() の戻り値など)を状態オブジェクトに保持しているケース、Symbol を値として持っているケースが該当します。イベントハンドラーや DOM への参照は、複製したいデータとは別の場所に持つよう整理するのが根本的な解決になります。
クラスのインスタンスがただのオブジェクトになる
構造化複製アルゴリズムはプロトタイプチェーンをたどりません。そのため、クラスから作ったインスタンスを複製すると、プロパティの値だけを持つプレーンオブジェクトが返ってきます。エラーにはならず、メソッドを呼んだ瞬間に初めて気づくパターンです。
class User {
constructor(name) {
this.name = name;
}
greet() {
return this.name + ' さん、こんにちは';
}
}
const user = new User('田中');
const copy = structuredClone(user);
console.log(copy.name); // '田中' ← プロパティは複製される
console.log(copy instanceof User); // false ← プレーンオブジェクトになった
// copy.greet(); // TypeError: copy.greet is not a function
メソッドがプロトタイプ側にあるうちは DataCloneError になりませんが、コンストラクター内で this.greet = () => {} のようにインスタンス自身へ関数を代入している場合は、それ自体が複製できない値なのでエラーになります。どちらにせよ、クラスのインスタンスを複製したいときは new User(copy.name) のように複製した値から作り直すか、クラス側に clone() メソッドを用意するのが確実です。
structuredClone is not defined と出る
関数そのものが見つからないというエラーなので、実行環境が対応していないことを意味します。真っ先に疑うべきは Node.js のバージョンで、16 以前では存在しません。node -v で確認し、17 以降に上げるのがいちばん簡単な解決です。
ブラウザー側で出る場合は、前述の対応バージョンより古い環境が対象に含まれていることになります。どうしても古い環境を支えなければならないなら、@ungap/structured-clone のようなポリフィルを読み込むか、その部分だけ JSON 経由のコピーに落とす判断が必要です。また、jsdom を使ったテスト環境のようにブラウザーを模した実行環境では、バージョンによってグローバルに用意されていないことがあります。ブラウザーでは動くのにテストだけ落ちる、というときはこちらを疑ってください。
まとめ
structuredClone() は、値を渡すだけで入れ子まで含めた複製を返してくれる関数です。スプレッド構文や Object.assign() が1階層しかコピーしないのに対して下まで作り直し、JSON 経由のコピーでは壊れてしまう Date・Map・Set・undefined を型のまま保ち、循環参照も正しく扱えます。
一方で、関数・Symbol・DOM ノードは複製できず DataCloneError になり、クラスのインスタンスはプレーンオブジェクトになります。つまり、データを複製するための関数であって、ふるまいまで複製するものではないと捉えるのが正解です。transfer オプションを使えば ArrayBuffer を複製せず移動させることもできます。Node.js 17 以降と主要ブラウザーで使えるので、ディープコピーが必要になったらまず選択肢の先頭に置いてよい機能です。