メモ JavascriptVue.js

jsで配列内のオブジェクトの値が連動してしまう原因と対処法

2021.11.07

こんにちはjunです。vue.jsとかでアプリを作るととき、一覧ページや複数個のデータを出力する時はオブジェクトが内包された配列データを使用することが多いと思います。例としては以下のような「記事一覧」用のpostsデータです。

const posts = [
    {id:1,title:"記事タイトル1",content:"..."},
    {id:2,title:"記事タイトル2",content:"..."},
    {id:3,title:"記事タイトル3",content:"..."},
    // ...
]

今回の記事で解説する内容は上記のような配列を別の変数に格納した時、スプレッド構文を使用して配列を生成したにもかかわらず、オブジェクト内の値が連動してしまったときの対処と原因について解説します。「どんなデータの状況だったか」という実装背景から話すので、ささっと原因からは知りたい人は「原因」へ、解決方法だけ知りたい人は「対処法」へ移動してください。

実装背景

vue.jsで編集画面を実装していた時です。編集画面は「入力された新しい値」と「登録済みの値(DBにある値)」を持たせておき、一部でその差分を確認できるようにするという面倒な実装がありました。内容としては「1日の予定表」みたいなもので、以下のようなデータ構造です。

const DATA_FROM_DB = response.data;

console.log(DATA_FROM_DB);
/**
{
    date:"2021-11-01", 
    memo:"...",
    todo:   // この日の予定を入れる。
    [
        {id:1,content:"AAAA"},
        {id:2,content:"BBBB"},
        {id:3,content:"CCCC"},
        ...
    ]
}
**/

上記データの DATA_FROM_DB.todo の変更前のデータを確認できるようにする必要があります。実装のためには別の定数なりに格納しておく必要があります。私はもちろん「参照渡し」・「値渡し」の概念は知っていたので、

const OLD_TODO_DATA = DATA_FROM_DB.todo;

なんてことせず、

const OLD_TODO_DATA = [...DATA_FROM_DB.todo];

とスプレッド構文を使用して新しく配列を生成しました。これで大丈夫だろと思っていましたが。。

事件は起きた。

新しいTODOと元のTODOを画面上に表示させて、TODO編集をテストしていた時です。新しくTODOを変更しているはずなに、古い OLD_TODO_DATAから表示している内容も同じ変更した値に切り替わっていました。つまり値が連動していたのです。「WHY?」と声を出してしまいました。開発ツールで見ていても同じ値になっていることが確認できました。値が参照渡しされているとすぐに気づきましたが、「スプレッド構文を使用したのに...」と対処方法ずっと考えていました。

ここでは値渡しと参照渡しの概念は解説しません。知らない方は「js 値渡し 参照渡し」でググってください。

原因

原因は内包されたオブジェクトが参照渡しされていたからなのです。スプレッド構文は使用しましたが、これはあくまで別の配列を作っただけであり、中身のオブジェクトの参照は維持されて(コピー元を参照して)いたのです。

以下のような実験をコンソールでしてみます。

console
> const origin =[{id:1,content:"1111"},{id:2,content:"2222"}];

> const passbyval = origin;

> const spred = [...origin];

> origin === passbyval;
// true

> origin === spred;
// false

> origin[0] === spred[0];
// true
// WHY!?

上記が示すとおり、スプレッド構文を使用することで配列としては別物のorigin === copyfalseとなり、値渡しのorigin === passbyval trueとなります。ただし同じindexのオブジェクトを比較すると、なんと同じになっています。値を変えてみるとspredも変更されているのが分かります。

console
> origin[0].content = "changed!";

> spred[0];
// {id: 1, content: 'changed!'}
// contentが変更されている

これが今回起きた原因です。つまり一応スプレッド構文を使用するのは配列の値をコピーするのでは確かに正しいのですが、今回のようなオブジェクトが内包されている場合は別の手法が必要です。

対処法

さて上記のように配列にオブジェクトが内包されている場合は、オブジェクトの参照が維持されたままになるのは確認できました。ではスプレッド構文以外にどうやって中身を値渡しできるでしょうか?

この時とられる方法としては「ディープコピー」があります。今回のように配列内のオブジェクトも値渡ししたいとき、つまり配列の中の深いとこまでマルッと別物としてコピーする処理をディープコピーと言います。

vanilla ES6にArray.prototype.deepcopy()みたいなディープコピーを一発で行う関数はありません。以下の3つテクニックを使用します。

1:JSON.stringifyを使う

手取り早いのはJSON.stringify()を使用してコピー元をJSONにして、すぐにJSON.parse()を使用して元に戻します。この際、デコードしたJSONは全く新しい値となるので参照を断ち切ることができます。

console
> const origin =[{id:1,content:"1111"},{id:2,content:"2222"}];

> const copyjson = JSON.prase(JSON.stringify(origin));

> copyjson;
// [{id:1,content:"1111"},{id:2,content:"2222"}]

> origin === copyjson;
// false

> origin[0] === copyjson[0]
// false
// よし!

JSONエンコードしてデコードする必要はありますが、中身がなんであろうが関係なく展開できるのは良いところです。

2:スプレッドとmap()を組み合わせる(使用箇所が限定的なので微妙)

スプレッド構文はオブジェクトにも使用できます。しかし今回は配列内にいるのが厄介です。そんな時は map()を使用して各々のオブジェクトを展開して、新しい配列を生成します。

console
> const origin =[{id:1,content:"1111"},{id:2,content:"2222"}];

> const copymap = origin.map(obj => {return {...obj}});

> copymap;
// [{id:1,content:"1111"},{id:2,content:"2222"}]

> origin === copymap;
// false

> origin[0] === copymap[0]
// false
// よし!

JSONを使うよりスマートですが、配列内がオブジェクト・配列といったiterableなものでないと使えません。今使用したoriginのような簡単な構成であればいいですが、途中で文字列があったりなど、中身が全てiterableである保証がない場合はエラーが起きる可能性があります。あと以下の様にオブジェクトの中にオブジェクトがあるネストした場合はディープコピーできません!!

console
> const origin =[{id:1,content:{summery:"detail1",detail:"detail1"}},{id:2,content:{summery:"detail2",detail:"detail2"}},];

> const copymap = origin.map(obj => {return {...obj}});

> origin[0] === copymap[0]
// false

> origin[0].content === copymap[0].content;
// true
// んっっ!?

なのでこのmapとスプレッドは使うのは結構微妙です。

3:lodashのcloneDeep()を使用する

テクニックではないのですがlodashなどのライブラリを用いると、cloneDeep()といったディープコピー を行うユーティリティー関数があります。面倒な場合はそれを使ってしまってもいいと思います。なんか方法1のJSONよりもlodashのcloneDeep()の方が早いみたいです。

参考:JavaScriptのディープコピー速さ比較 〜7つの手法/ライブラリを比べてみた〜

console
> const origin =[{id:1,content:{summery:"detail1",detail:"detail1"}},{id:2,content:{summery:"detail2",detail:"detail2"}},];

> const copy_ =  _.cloneDeep(origin);

> origin[0] === copy_[0]
// false

> origin[0].content === copy_[0].content;
// false
// よし!

ちなみに方法1のJSONもネストしたオブジェクトの参照は断ち切れます。

ライブラリかJSON変換を使うといい

以上が配列内のオブジェクトの値が連動してしまう原因と対処法です。正確には配列内にオブジェクトを含む場合や、オブジェクト内にオブジェクトを含む値コピーする時は「ディープコピー 」を使いましょうというお話です。ネストしたオブジェクトは今回解説した様に、参照が根深く残っています。アプリによっては以下のような単純な構造でなく、

[{id:1,content:"1111"},{id:2,content:"2222"}];

以下のように内包する値も複雑で、さらにオブジェクトがネストしていることもあります。

const origin = {
    title: "...", // 途中にiterableでないものがある。
    todo:[
            {
                id:1,
                // オブジェクトがネストしている
                content:{
                    summery:"detail1",detail:"detail1"
                }
            },
            {
                id:2,
                content:{
                    summery:"detail2",detail:"detail2"
                }
            }
        ];
    // More unpredictable properties..
}

上記のような状況を踏まえると、ディープコピー はJSONを用いるか大人しくライブラリを使用する方が賢明そうです。値が連動してしまう時はまず値渡し・参照渡しを疑い、その次はディープコピーがされているか(ネストしたオブジェクトがないか)を確かめてみましょう。

Copyright © 2021 jun. All rights reserved.