☺️

【JavaScript】オブジェクトのディープコピーが標準機能で使えるようになりました

2023/04/07に公開1

はじめに

個人的にずっと待ちわびてた機能がついに標準搭載されました。
オブジェクトのディープコピーを行ってくれる structuredClone です。

今回はこのメソッドがどんな恩恵をもたらしてくれたのか、またできることできないことをサンプルで確認しながら解説していきます。

シャローコピーとディープコピー

まずは、タイトルにも書いてるディープコピーについて理解が必要なので簡単に解説します。
対比であるシャローコピーも交えて比較しながら挙動を確認してみます。

シャローコピー

シャローコピーは名前通り浅い(shallow)コピーです。具体的に JavaScript では 1 階層までコピーするという意味で使われます。

シャローコピーのやり方は以下です。

const obj = {
  a: "a",
  b: {
    bb: "bb",
  },
};

// やり方1 スプレッド構文でのコピー
const shallowCopy1 = { ...obj };

// やり方2 Object.assignでのコピー
const shallowCopy2 = Object.assign({}, obj);

1 階層までのコピーなのでプロパティ a は別のオブジェクトとして識別されますが、b に関してはオブジェクトの中にオブジェクトがあり 2 階層目になるので参照渡しの影響を受けます。

以下はオブジェクトの 1 階層目は別のオブジェクトとして識別されるケースです。

shallowCopy1.a = "xxx";

console.log(obj);
/*
出力結果
{
  a: "a",
  b: {
    bb: "bb",
  },
}
*/
console.log(shallowCopy1);
/*
出力結果
{
  a: "xxx",
  b: {
    bb: "bb",
  },
}
*/
console.log(shallowCopy2);
/*
出力結果
{
  a: "a",
  b: {
    bb: "bb",
  },
}
*/

最初に変更した shallowCopy1 のプロパティ a のみが変更されたことが確認できます。

次に 2 階層目の b.bb を変更したときのケースは以下です。

shallowCopy1.b.bb = "yyy";

console.log(obj);
/*
出力結果
{
  a: "a",
  b: {
    bb: "yyy",
  },
}
*/
console.log(shallowCopy1);
/*
出力結果
{
  a: "xxx",
  b: {
    bb: "yyy",
  },
}
*/
console.log(shallowCopy2);
/*
出力結果
{
  a: "a",
  b: {
    bb: "yyy",
  },
}
*/

参照渡しなり、全変数のプロパティ b.bb の値が変更されてしまうことが確認できます。

全変数を別のオブジェクトとして扱いたい場合、不具合の原因になりますので、回避するためにディープコピーをしてあげる必要がありますが、これまでの JavaScript ではディープコピーが若干面倒でした。

ディープコピー

ディープコピーは内部に存在するすべての値をコピーし、全く別のオブジェクトとして扱う方法です。
本来ディープコピーをする場合はオブジェクトの階層を再起的にコピーしていく必要があります。

ただ、そんな関数を毎回書くのはしんどいので JavaScript ではしばしば以下のような方法でディープコピーをおこなっていました。

const obj = {
  a: "a",
  b: {
    bb: "bb",
  },
};

const deepCopy1 = JSON.parse(JSON.stringify(obj));

deepCopy1.a = "xxx";
deepCopy1.b.bb = "yyy";

console.log(obj);
/*
出力結果
{
  a: "a",
  b: {
    bb: "bb",
  },
}
*/
console.log(deepCopy1);
/*
出力結果
{
  a: "xxx",
  b: {
    bb: "yyy",
  },
}
*/

1, 2 階層ともにコピーされたことが確認できます。
ただ、JavaScript の経験が浅い人が見ると何のためにやってる処理なのかが分かりずらい、シンプルにタイプ量が増えてめんどくさい。などの懸念があります。

加えて、懸念でなく Date、関数、Undefined などのいくつかのプリミティブ/オブジェクト型がオブジェクトに含まれているとうまくいかない。 という問題点もあります。

以下で確認してみます。

const obj = {
  a: "a",
  b: {
    bb: "bb",
  },
  date: new Date(),
  func: () => {
    return "func";
  },
  _: undefined,
};

console.log(obj);
/*
出力結果
{
  a: 'a',
  b: { bb: 'bb' },
  date: 2023-03-24T02:23:54.794Z,
  func: [Function: func],
  _: undefined
}
*/

const deepCopy1 = JSON.parse(JSON.stringify(obj));
console.log(deepCopy1);
/*
出力結果
{
  a: 'a',
  b: { bb: 'bb' },
  date: '2023-03-24T02:23:54.794Z',
}
*/

// 問題点1 DateがStringになる
console.log(typeof obj.date);
/*
出力結果
object
*/
console.log(typeof deepCopy1.date);
/*
出力結果
string
*/

// そのため、Dateオブジェクトのメソッドを使うとエラーになる
console.log(obj.date.getTime());
/*
出力結果
1679624634794
*/
console.log(deepCopy1.date.getTime());
/*
出力結果
TypeError: deepCopy1.date.getTime is not a function
*/

// 問題点2 関数はコピーされない
console.log(obj.func());
/*
出力結果
func
*/
console.log(deepCopy1.func());
/*
出力結果
TypeError: deepCopy1.func is not a function
*/

// 問題点3 Undefinedはコピーされない
console.log(obj.hasOwnProperty("_"));
/*
出力結果
true
*/
console.log(deepCopy1.hasOwnProperty("_"));
/*
出力結果
false
*/

こちらを回避するために、しばしば lodash の cloneDeep が使われたりしてました。

const _ = require("lodash");

const obj = {
  a: "a",
  b: {
    bb: "bb",
  },
  date: new Date(),
  func: () => {
    return "func";
  },
  _: undefined,
};

const deepCopy1 = _.cloneDeep(obj);
deepCopy1.a = "xxx";
deepCopy1.b.bb = "yyy";
deepCopy1.date.setMonth(deepCopy1.date.getMonth() + 1);
deepCopy1.func = () => {
  return "changeFunc";
};
deepCopy1._ = null;

console.log(obj);
/*
出力結果
{
  a: 'a',
  b: { bb: 'bb' },
  date: 2023-03-24T02:50:38.011Z,
  func: [Function: func],
  _: undefined
}
*/
console.log(deepCopy1);
/*
出力結果
{
  a: 'xxx',
  b: { bb: 'yyy' },
  date: 2023-04-24T02:50:38.011Z,
  func: [Function (anonymous)],
  _: null
}
*/

console.log(obj.func());
/*
出力結果
func
*/
console.log(deepCopy1.func());
/*
出力結果
changeFunc
*/

JSON.parse()JSON.stringify() の組み合わせではできなかったコピーが実現できました。

信用のあるライブラリだし、全然使えばいいじゃんって気もしますが、ライブラリはできる限り入れたくないという意見もあるでしょうし、mjs として使いたいとなるとバンドルしてあげる必要があったりとやっぱり何かと不便な一面があります。

structuredClone でのディープコピー

そんな中、満を持して登場しました structuredClone です。

流れ的に先の lodash で試したものをそのまま structuredClone に置き換えられます。とテンションアゲアゲの流れでいきたかったのですが、ちゃんとデメリットもありますので最初に記載します w

残念ながら structuredClone ではコピーできないデータも存在します。
先の lodash で試した中で言うと、関数が該当します。

そのまま structuredClone(obj) とすると DOMException が throw されることが確認できます。

エラー文は以下です。

DOMException [DataCloneError]: () => {
    return "func";
  } could not be cloned.

structuredClone でコピーできるデータは こちら で確認できます。

確認する限り関数の他に独自クラスのインスタンスや Symbol あたりも対応はしていないようですね。

では、こちらを考慮して関数を抜いた上でのオブジェクトのコピーを試してみます。

const deepCopy1 = structuredClone(obj);
deepCopy1.a = "xxx";
deepCopy1.b.bb = "yyy";
deepCopy1.date.setMonth(deepCopy1.date.getMonth() + 1);
deepCopy1._ = null;

console.log(obj);
/*
出力結果
{
  a: 'a',
  b: { bb: 'bb' },
  date: 2023-03-24T03:58:31.220Z,
  _: undefined
}
*/
console.log(deepCopy1);
/*
出力結果
{ a: 'xxx',
  b: { bb: 'yyy' },
  date: 2023-04-24T03:58:31.220Z,
  _: null }
*/

ちゃんとディープコピーされてますね。

それぞれのメリット・デメリットをちゃんと理解した上で使い分ける必要性はありますが、使い勝手が一番良い選択肢が追加されたので個人的にはだいぶ歓喜してます。少なくとも API レスポンスをそのままコピーするみたいな使い方ならこれがベストな選択肢になったと思います。

第 2 引数でオブジェクトの移譲も可能

parameterstransfer プロパティにオブジェクトを渡すことで移譲することができるようです。

サンプルコードは ArrayBuffer の例で こちら に記載があります。

移譲するとはどう言うこと言うと、オブジェクトをそのままコピーするのではなく、コピー元を利用不可にしてコピー先のみでデータを扱えるようにすると言うことです。

サンプルコード内のコピー元とコピー先の変数を以下で出力するとイメージがつきやすいかと思います。

const buffer1 = new ArrayBuffer(16);
const object1 = {
  buffer: buffer1,
};
console.log(object1);
/*
出力結果
{
  buffer: ArrayBuffer {
    [Uint8Contents]: <00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00>,
    byteLength: 16
  }
}
*/

const object2 = structuredClone(object1, { transfer: [buffer1] });

console.log(object1);
/*
出力結果
{ buffer: ArrayBuffer { (detached), byteLength: 0 } }
*/
console.log(object2);
/*
出力結果
{
  buffer: ArrayBuffer {
    [Uint8Contents]: <00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00>,
    byteLength: 16
  }
}
*/

コピー元の変数 object1 の宣言時に出力するとバッファを確保してますが、structuredClone によってコピーされた後は object1detached で使用不可になり、object2 にバッファが移譲されていることが確認できます。

ArrayBuffer 以外に移譲に対応しているオブジェクトは こちら から確認できます。

対応ブラウザ

origin は こちら

大体のブラウザでサポートされているので今後どんどん使って行っても問題なさそうですね。

さいごに

さいごまで読んでいただきありがとうございます。
今日のまとめです。

  • オブジェクトにはシャローコピーとディープコピーがある
  • シャローコピーしたい場合に使うメソッド
    • スプレッド構文
    • Object.assign
  • ディープコピーしたい場合に使うメソッド
    • JSON.parse × JSON.stringify
      • ※ Date, 関数, undefined など一部のデータはうまくコピーできない
    • cloneDeep
      • ※ npm ライブラリとして lodash をインストールする必要がある
    • structuredClone
      • ※ 関数、独自クラスのインスタンス、Symbol はコピーできない

間違いの指摘やリクエストなどありましたら加筆していきたので是非、ご意見をいただけたらと思います。

それではまた次の記事でお会いしましょう。

GitHubで編集を提案

Discussion

TaqqnTaqqn

lodash のバンドルサイズは大きいので、just-clone というものも存在します!
こちらも関数のコピーは可能です。独自classのインスタンスのコピーは不可能です。
ESModuleを使っていてlodash-esを使える状況なら、ツリーシェイキングできるので、lodash-esのほうがいいかもしれません。