structuredCloneを使ってオブジェクトをディープコピーする
JavaScript でのstructuredClone
関数が Chrome98 から使えるようになりました 🎉
structuredClone
を利用する事で、簡単にオブジェクトのディープコピーができるようになりました。
すごく便利そうだな...!!と思った関数なので実際に試してみました。
structuredClone
本題のstructuredClone
です。
structuredClone
は一言で言うと「構造化複製アルゴリズムに従って、指定された値のディープクローンを生成するメソッド」です。
つまり、構造化複製アルゴリズムに対応する値はstructuredClone
を使うとディープコピーした上で値を返してくれる、と言うことです。
IE には対応していませんが、Chrome、Edge、Firefox 等の主要なブラウザは対応してきているので、Object.assign
やライブラリの lodash 代わる候補の一つになりそうです。
ブラウザに限らず、Node.js では 17.0.0 から、Deno では 1.14 以降、対応予定との記載もあります。
構造化複製アルゴリズム
一番参考になるのは、MDN の構造化複製アルゴリズムのページです。
どの値がコピーできる・できないについては、上記の MDN で記載されているか否かを見るのが参考になります。
試してみる
**サポート済みの型(使いそうなもの抜粋しています)**
string
number
boolean
Date
Array
Object
Set
Map
structuredClone
でディープコピーし、コピー元のオブジェクトが変更されていないかを見てみます。
let user = {
name: "Aさん", // プロパティ(string)
age: 20, // プロパティ(number)
hasPhone: true, // プロパティ(boolean)
updateDay: new Date("2021-02-10T12:00:00+09:00"), // Dateオブジェクト
favoriteColor: ["red", "blue", "green"], // Array(配列)
area: {
pref: "北海道",
city: "旭川",
}, // ネストしたオブジェクト
};
const user_copy = structuredClone(user);
// コピー先user_copyのそれぞれのプロパティを書き換える
user_copy.name = "Bさん"; // プロパティ
user_copy.age = 40; // プロパティ(number)
user_copy.hasPhone = false; // プロパティ(boolean)
user_copy.updateDay.setMonth(user.updateDay.getMonth() + 1); // Dateオブジェクト
user_copy.favoriteColor.push("yellow"); // 配列
user_copy.area.pref = "東京都"; // ネストしたオブジェクト
// 出力
console.log(user);
// 結果
// name: "Aさん" // 変わってない
// age: 20 // 変わってない
// hasPhone: true // 変わってない
// updateDay: Wed Feb 10 2021 12:00:00 GMT+0900 (日本標準時) // 変わってない
// favoriteColor: (3) ['red', 'blue', 'green'] // 変わってない
// area: {pref: '北海道', city: '旭川'} // 変わってない
// 比較すると
console.log(user.name === user_copy.name);
// 結果
// false
ちゃんとディープコピーできてます。
- Set
Set は配列の値を一意にしてくれます。
const score = ["2", "5", "6", "2"];
let user = {
name: "Aさん",
hasScore: new Set(score),
};
const user_copy = structuredClone(user);
console.log(user_copy === user); // false
console.log(user_copy);
- Map
Map は配列の全てに処理をひとつづつ行い結果を返します。
const score = ["2", "5", "6", "2"];
let user = {
name: "Aさん",
doubleScore: score.map((value) => value * 2),
};
const user_copy = structuredClone(user);
console.log(user_copy === user); // false
console.log(user_copy);
- undefined の値があるもの
let user = {
friend: [
{ name: "Cさん", gender: "man" },
{ name: "Dさん", gender: undefined },
],
};
const user_copy = structuredClone(user);
console.log(user_copy);
// 結果
// 0: {name: 'Cさん', gender: 'man'}
// 1: {name: 'Dさん', gender: undefined}
JSON.stringify
を使った場合はundefined
のプロパティは消滅していましたが、structuredClone では消滅されずにコピーされていました。
structuredClone でコピーできないもの
- 関数(window オブジェクト)
let sayHello = function () {
console.log("hello");
}; // 関数
const user_copy = structuredClone(sayHello);
console.log(user);
structuredClone を使って関数をコピーしようとすると以下のエラーが表示されます。
Uncaught DOMException: Failed to execute 'structuredClone' on 'Window': function(){console.log('hello')} could not be cloned.
- オブジェクト内も同様にエラーとなってしまいます。
let user = {
sayHello: function () {
console.log("hello");
}, // 関数
};
const user_copy = structuredClone(user);
console.log(user);
// エラー
// Uncaught DOMException: Failed to execute 'structuredClone' on 'Window': function(){console.log('hello')} could not be cloned.
MDN にも関数は複製されない旨の記載があります。
Function オブジェクトは構造化複製アルゴリズムでは複製されません。複製しようとすると DATA_CLONE_ERR 例外が送出されます。
- Symbol
Symbol はその値が他にない事を表すプリミティブ値で、プロパティのキーとして使用します。
他にない事を示すものであるため、structuredClone
を使ってもコピーする事はできません。
実際コンソールで試してみてもエラーになり、symbol 型はサポートされてない事が分かります。
const obj = {
sybol: Symbol('hogeeee'),
};
let Duplicate = structuredClone(obj)
console.log(Duplicate)
// エラーになる
// Uncaught DOMException: Failed to execute 'structuredClone' on 'Window': Symbol(heart) could not be cloned.
structuredClone を利用することで、簡単にディープコピーができているのが分かります。
オブジェクトのコピーについて
ここからは、今までのオブジェクトのコピーについて整理しました。
通常
JavaScript のオブジェクトのコピーは参照渡しです。
const user = { name: "Aさん" };
const userAgent = user; // userAgentに代入
userAgent.name = "Bさん"; // userAgentのnameプロパティを書き換える
console.log(user.name); // Bさん
// コピー元のuserのプロパティもBさんに...
同じメモリ上の場所を参照しているため、コピー元のオブジェクトのプロパティの値も一緒に変わってしまいます。
オブジェクトをコピーしたい場面は沢山あるのに、これでは不都合となってしまいます。
そのため、元の値を変更せずにオブジェクトをコピーしたいときは、後述の工夫が必要になります。
Object.assign,スプレッド構文
先ほどのコードをObject.assign
を使用して書くと、元のオブジェクトの値を変更せずにコピーをすることができます。
Object.assign
では、第一引数にコピー後に入れるオブジェクトを用意してあげて、第二引数以降はコピーさせたいオブジェクトを指定します。
const user = { name: "Aさん" };
const user_copy = Object.assign({}, user);
user_copy.name = "Bさん"; // user_copyのnameプロパティを書き換える
console.log(user); // Aさん
// コピー元のuserのプロパティはAさんのまま!!
もう一つの方法であるスプレッド構文は、配列やオブジェクトの中身を展開してくれる構文です。
以下のように...
を3つ繋げて書くことでObject.assign
と同じ様にコピーをする事ができます。
const user = { name: "Aさん" };
const user_copy = { ...user };
user_copy.name = "Bさん"; // user_copyのnameプロパティを書き換える
console.log(user); // Aさん
// コピー元のuserのプロパティはAさんのまま!!
最近は記述量が少ないスプレッド構文で記載することが多いですが、どちらも同じです。
Object.assign
やスプレッド構文は、シャローコピー(浅いコピー)と言います。
これで元のオブジェクトを変更せずにコピーできてめでたし...かと言うと、そうではないです。
シャローコピーはオブジェクトを完璧にはコピーしてくれません。
コピーしてくれないオブジェクトのパターンもあります。
シャローコピーでコピーしないケース
試してみます。
let user = {
name: "Aさん",
area: {
pref: "北海道",
city: "旭川",
},
updateDay: new Date("2021-02-10T12:00:00+09:00"),
favoriteColor: ["red", "blue", "green"],
};
// スプレッド構文でシャローコピー!!
const user_copy = { ...user };
// user_copyのareaプロパティを書き換える
user_copy.name = "Bさん"; // プロパティ
user_copy.area.pref = "東京都"; // ネストしたオブジェクト
user_copy.updateDay.setMonth(user.updateDay.getMonth() + 1); // Dateオブジェクト
user_copy.favoriteColor.push("yellow"); // 配列
// 出力
console.log(user);
// 結果
// プロパティ
// name: "Aさん" // 変更されない
// ネストしたオブジェクト
// area: {pref: '東京都', city: '旭川'} // 変わってしまう!!
// Dateオブジェクト
// updateDay: Wed Mar 10 2021 12:00:00 GMT+0900 (日本標準時) // 変わってしまう!!
// 配列
// favoriteColor: (4) ['red', 'blue', 'green', 'yellow'] // 変わってしまう!!
- ネストしたオブジェクト
- Date オブジェクト
- Array(配列)
上記のもの(オブジェクト)は、コピー元の値にまで影響を及ぼしてしまいます。。
なので、Object.assign
やスプレッド構文で使おうとする時には注意が必要です。
ネストしたオブジェクトや配列が含まれるオブジェクトをコピーしたいときは以下の方法を使って対処します。
JSON.parse(JSON.stringify())
よくある方法としては、JSON.stringify
とJSON.parse
の 2 つを組み合わせて使う方法です。
const user_copy = JSON.parse(JSON.stringify(user));
-
JSON.stringify
オブジェクトを JSON 文字列に変換する -
JSON.parse
JSON 文字列をオブジェクトに変換する
JSON.parse(JSON.stringify(コピー元のオブジェクトを指定))
で一度文字列に変換したオブジェクトを再度元のオブジェクトに変換します。
これによりオブジェクトを値渡しにして、元のオブジェクトに影響を与えないようにしています。
試してみます。
let user = {
name: "Aさん",
area: {
pref: "北海道",
city: "旭川",
},
updateDay: new Date("2021-02-10T12:00:00+09:00"),
favoriteColor: ["red", "blue", "green"],
};
// JSON.parseを使ってディープコピー!!
const user_copy = JSON.parse(JSON.stringify(user));
// コピー先user_copyのそれぞれのプロパティを書き換える
user_copy.name = "Bさん"; // プロパティ
user_copy.area.pref = "東京都"; // ネストしたオブジェクト
user_copy.updateDay.setMonth(user.updateDay.getMonth() + 1); // Dateオブジェクト
user_copy.favoriteColor.push("yellow"); // 配列
// 出力
console.log(user);
// 結果
// プロパティ
// name: "Aさん" // 変更されない
// ネストしたオブジェクト
// area: {pref: '北海道', city: '旭川'} // 変更されない
// Dateオブジェクト
// エラーになる
// 配列
// favoriteColor: (4) ['red', 'blue', 'green'] 変更されない
ほとんどのオブジェクトのプロパティが変更されていません。
ただし、Date オブジェクトは少し特殊です。
Date オブジェクトはJSON.stringify
で変換した時に文字列扱いになってしまうため、setMonth 関数で日付を変えようとすると「Date オブジェクトじゃないよ」とエラーになってしまいます。
文字列にしてコピーする場合は問題ありませんが、Date オブジェクトの状態でコピーしたいときは少し厄介です。
また、レアケースですがJSON.parse
を利用してもundefined
がある場合や、関数が含まれる場合は、コピー先のオブジェクトのプロパティが消滅してしまうケースがあります。
- コピー先に影響を与える例
let user = {
friend: [
{ name: "Cさん", gender: "man" },
{ name: "Dさん", gender: undefined },
], // undefined
sayhello: function () {
console.log("hello");
}, // 関数
};
const user_copy = JSON.parse(JSON.stringify(user));
console.log(user_copy);
// 結果
// 0: {name: 'Cさん', gender: 'man'}
// 1: {name: 'Dさん'} // Dさんのgenderがなくなっている。
// 関数は消える
上記のように値にundefined
や関数が含むオブジェクトは、コピー先のオブジェクトでは消えてなくなってしまいます。
オブジェクトに関数やundefined
が含まれているケースはあまりないので、ほとんどの場合はJSON.stringify
を使いますが、注意が必要です。
このようなケースでも不安な場合は、loadash を使うことでコピー先のオブジェクトにも完璧にコピーすることができます。
loadash
もう一つのディープコピーの方法として、lodash を使う方法があります。
ライブラリなのでインストールしてモジュールとして組み込みます。yarn add --dev lodash
lodash は色々メソッドがありますが、その中でも cloneDeep()メソッドを利用します。
こんな感じで使います。
import * as _ from "lodash"; // lodashをインポート
let user = {
name: "Aさん", // プロパティ
area: {
pref: "北海道",
city: "旭川",
}, // ネストしたオブジェクト
updateDay: new Date("2021-02-10T12:00:00+09:00"), // Dateオブジェクト
favoriteColor: ["red", "blue", "green"], // 配列
friend: [
{ name: "Cさん", gender: "man" },
{ name: "Dさん", gender: undefined },
], // undefined
sayhello: function () {
console.log("hello");
}, // 関数
};
const user_copy = _.cloneDeep(user);
// コピー先user_copyのそれぞれのプロパティを書き換える
user_copy.name = "Bさん"; // プロパティ
user_copy.area.pref = "東京都"; // ネストしたオブジェクト
user_copy.updateDay.setMonth(user.updateDay.getMonth() + 1); // Dateオブジェクト
user_copy.favoriteColor.push("yellow"); // 配列
// 出力
console.log(user);
lodash ではJSON.stringify
ではコピーできていない Date オブジェクトも含めてコピーすることができます。
またJSON.parse(JSON.stringify())
で消えてしまっていたundefined
や関数が含むオブジェクトで消えていた値も消えずにきちんとコピーすることができます。
デメリットとしては、ライブラリモジュールなので chromeDev ツールのコンソールでは試せない、ライブラリなので使えるか確認する必要がある...でしょうか。
lodash 使える場合は lodash を使っていくのが無難かもしれません。
比較・まとめ
- 元のオブジェクトに影響を与えずコピーできるか
Object.assign スプレッド構文 |
JSON.parse | loadash | structuredClone | |
---|---|---|---|---|
コピーの種類 | シャローコピー | ディープコピー | ディープコピー | ディープコピー |
ネストしたオブジェクト | ❌ | 可能 | 可能 | 可能 |
Date オブジェクト | ❌ | 文字列になる | 可能 | 可能 |
配列 | ❌ | 可能 | 可能 | 可能 |
関数 | 可能 | コピー先のオブジェクトに影響 | 可能 | エラー表示 |
undefined | 可能 | コピー先のオブジェクトに影響 | 可能 | 可能 |
-
Object.assign
,スプレッド構文はネストされたオブジェクトがあるときは特に注意がいる。(ネスト内のオブジェクトにあるプロパティ等を変更すると、コピー元のオブジェクトに影響を与えてしまう) -
JSON.parse
は基本的に元のオブジェクトに影響を与えずコピーできるが、Date オブジェクトや関数、undefined
がある場合は、コピー先のオブジェクトに影響があるので注意。 -
loadash
はライブラリだが、元のオブジェクトに影響を与えずコピーできるのでライブラリが使えれば無難。 -
structuredClone
はオブジェクト内に関数がなければ普通に使いやすそう。出たばかりなので プロダクションで使うのはまだ躊躇するけど、モダンブラウザでは(MDN を見る限り)使えるようになってきているし、IE がサポートが終われば本格的に使ってもいい気がする。(polyfill 作っているのもありそうでした) - 間違いあったら教えてください。
参考
Discussion