こんにちは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?」と声を出してしまいました。開発ツールで見ていても同じ値になっていることが確認できました。値が参照渡しされているとすぐに気づきましたが、「スプレッド構文を使用したのに...」と対処方法ずっと考えていました。
原因は内包されたオブジェクトが参照渡しされていたからなのです。スプレッド構文は使用しましたが、これはあくまで別の配列を作っただけであり、中身のオブジェクトの参照は維持されて(コピー元を参照して)いたのです。
以下のような実験をコンソールでしてみます。
> 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 === copy
はfalse
となり、値渡しのorigin === passbyval
はtrue
となります。ただし同じindexのオブジェクトを比較すると、なんと同じになっています。値を変えてみるとspred
も変更されているのが分かります。
> origin[0].content = "changed!";
> spred[0];
// {id: 1, content: 'changed!'}
// contentが変更されている
これが今回起きた原因です。つまり一応スプレッド構文を使用するのは配列の値をコピーするのでは確かに正しいのですが、今回のようなオブジェクトが内包されている場合は別の手法が必要です。
さて上記のように配列にオブジェクトが内包されている場合は、オブジェクトの参照が維持されたままになるのは確認できました。ではスプレッド構文以外にどうやって中身を値渡しできるでしょうか?
この時とられる方法としては「ディープコピー」があります。今回のように配列内のオブジェクトも値渡ししたいとき、つまり配列の中の深いとこまでマルッと別物としてコピーする処理をディープコピーと言います。
Array.prototype.deepcopy()
みたいなディープコピーを一発で行う関数はありません。以下の3つテクニックを使用します。
手取り早いのはJSON.stringify()
を使用してコピー元をJSONにして、すぐにJSON.parse()
を使用して元に戻します。この際、デコードしたJSONは全く新しい値となるので参照を断ち切ることができます。
> 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エンコードしてデコードする必要はありますが、中身がなんであろうが関係なく展開できるのは良いところです。
スプレッド構文はオブジェクトにも使用できます。しかし今回は配列内にいるのが厄介です。そんな時は map()
を使用して各々のオブジェクトを展開して、新しい配列を生成します。
> 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である保証がない場合はエラーが起きる可能性があります。あと以下の様にオブジェクトの中にオブジェクトがあるネストした場合はディープコピーできません!!
> 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とスプレッドは使うのは結構微妙です。
テクニックではないのですがlodashなどのライブラリを用いると、cloneDeep()
といったディープコピー を行うユーティリティー関数があります。面倒な場合はそれを使ってしまってもいいと思います。なんか方法1のJSONよりもlodashのcloneDeep()
の方が早いみたいです。
参考:JavaScriptのディープコピー速さ比較 〜7つの手法/ライブラリを比べてみた〜
> 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もネストしたオブジェクトの参照は断ち切れます。
以上が配列内のオブジェクトの値が連動してしまう原因と対処法です。正確には配列内にオブジェクトを含む場合や、オブジェクト内にオブジェクトを含む値コピーする時は「ディープコピー 」を使いましょうというお話です。ネストしたオブジェクトは今回解説した様に、参照が根深く残っています。アプリによっては以下のような単純な構造でなく、
[{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を用いるか大人しくライブラリを使用する方が賢明そうです。値が連動してしまう時はまず値渡し・参照渡しを疑い、その次はディープコピーがされているか(ネストしたオブジェクトがないか)を確かめてみましょう。