🖇️

structuredCloneを使ってオブジェクトをディープコピーする

2022/02/25に公開

JavaScript でのstructuredClone関数が Chrome98 から使えるようになりました 🎉

https://caniuse.com/?search=structuredClone

structuredCloneを利用する事で、簡単にオブジェクトのディープコピーができるようになりました。

すごく便利そうだな...!!と思った関数なので実際に試してみました。

structuredClone

本題のstructuredCloneです。
https://developer.mozilla.org/en-US/docs/Web/API/structuredClone
structuredCloneは一言で言うと「構造化複製アルゴリズムに従って、指定された値のディープクローンを生成するメソッド」です。

つまり、構造化複製アルゴリズムに対応する値はstructuredCloneを使うとディープコピーした上で値を返してくれる、と言うことです。

IE には対応していませんが、Chrome、Edge、Firefox 等の主要なブラウザは対応してきているので、Object.assignやライブラリの lodash 代わる候補の一つになりそうです。

ブラウザに限らず、Node.js では 17.0.0 から、Deno では 1.14 以降、対応予定との記載もあります。

構造化複製アルゴリズム

一番参考になるのは、MDN の構造化複製アルゴリズムのページです。

https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#サポート済みの型

どの値がコピーできる・できないについては、上記の 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.stringifyJSON.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 を使う方法があります。
https://www.npmjs.com/package/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 作っているのもありそうでした)
  • 間違いあったら教えてください。

参考

https://web.dev/structured-clone/

https://nansystem.com/shallow-copy-vs-deep-copy/

Discussion