【Vue3】オブジェクト/配列のpropsは直接参照を避けよう
はじめに
Vue3におけるオブジェクト/配列のprops
に関する記事です。
与件
- Vue3, Nuxt3, TypeScript
-
props
でオブジェクト/配列を親コンポーネントから受け取る
propsの仕様
Vue3において、オブジェクトや配列をprops
として渡した場合、子コンポーネントがそのオブジェクトや配列自体を変更することはできませんが、オブジェクトや配列のネストされたプロパティを変更することはできてしまいます。
これは、JavaScriptでオブジェクトや配列が参照渡しであることに起因しているようです。
単一の値をpropsとして渡す場合
単一の値をprops
として渡す場合、子コンポーネントでprops
の値を変更すると、その変更は親コンポーネントに反映されません。これは、props
が単一の値であるため、子コンポーネントでの変更が単なるローカルな変数の変更と同じ扱いになるためです。
<script setup lang="ts">
const props = defineProps(['hoge'])
// 警告:propsは読み取り専用です!になる
props.hoge = 'bar'
</script>
オブジェクトや配列をpropsとして渡す場合
オブジェクトや配列をprops
として渡した場合、子コンポーネントが親コンポーネントから渡されたオブジェクトや配列のプロパティを直接変更することはできません。ただし、オブジェクトや配列のプロパティが変更された場合、Vue3はその変更を検知して、親コンポーネントのデータも更新されます。
<script setup lang="ts">
interface MyObject {
name: string;
age: number;
hobbies: string[];
}
const props = defineProps({
myObject: {
type: Object as () => MyObject,
required: true,
},
});
const changeHobbies = () => {
// 親からpropsで受け取ったmyObjectのプロパティを変更できてしまう
props.myObject.hobbies.push('baking');
};
</script>
<template>
<div>
<p>{{ myObject.name }}</p>
<p>{{ myObject.age }}</p>
<ul>
<li v-for="hobby in myObject.hobbies" :key="hobby">{{ hobby }}</li>
</ul>
<button @click="changeHobbies">Change Hobbies</button>
</div>
</template>
起こりうる問題
こうした仕様は、子コンポーネントが親の状態に影響を与えることを許してしまいます...
以下の公式ドキュメントでも、後からデータの流れを見極めるのが難しくなるため、親と子を密に結合させる設計でない限り、このような変更を避けることがベストプラクティスであると記載されてあります。
意図しないデータフローを避けてオブジェクト/配列をpropsとして渡す方法
ダメな例
何も考えずにオブジェクトや配列を渡すとこんな感じでしょうか。
<script setup lang="ts">
interface HogeObject {
name: string;
age: number;
}
const props = defineProps({
arrHoge: {
type: Array as () => Array<number | string>,
required: true,
},
objHoge: {
type: Object as () => HogeObject,
required: true,
},
});
const arrHoge = ref<Array<number | string>>(props.arrHoge);
const objHoge = ref<HogeObject>(props.objHoge);
</script>
<template>
<div>
<p>Array: {{ arrHoge }}</p>
<p>Object: {{ objHoge }}</p>
</div>
</template>
このコードでは、props.arrHoge
を直接参照しています。props.arrHoge
と新しい配列の両方が同じメモリ位置を参照しているため、props.arrHoge
内の変更が新しい配列に反映され、親コンポーネントの値に影響を与える可能性があります。
ベストプラクティス
<script setup lang="ts">
import { defineProps, ref, Ref } from 'vue';
interface HogeObject {
name: string;
age: number;
}
const props = defineProps({
arrHoge: {
type: Array as () => Array<number | string>,
required: true,
},
objHoge: {
type: Object as () => HogeObject,
required: true,
},
});
// スプレッド構文で新しい配列として展開
const arrHoge: Ref<Array<number | string>> = computed(() => [...props.arrHoge]);
const objHoge: Ref<HogeObject> = computed(() => ({ ...props.objHoge }));
</script>
<template>
<div>
<p>Array: {{ arrHoge }}</p>
<p>Object: {{ objHoge }}</p>
</div>
</template>
このコードではarrHoge
とobjHoge
を新しい配列と新しいオブジェクトとして計算された値として定義しています。こうすることで、props.arrHoge
とprops.objHoge
の値が変更されても、arrHoge
とobjHoge
は常に新しい配列と新しいオブジェクトを参照するようになります。
ちなみにめちゃくちゃ省略して
<script setup lang="ts">
ref<Array<number | string>>([...props.arrHoge])
</script>
とかでも良いんですが、これだとpropsの値が変更された場合に手動で value
プロパティを設定する必要があるので場合によりますね😅
まとめ
- オブジェクトや配列を
props
として渡した場合、子コンポーネントがprops
のバインディングを変更することはできないが、オブジェクトや配列のネストされたプロパティを変更することはできてしまう。 - 意図しないデータフローを防ぐため、オブジェクト/配列の
props
は直接参照しないようにする - オブジェクト/配列の
props
は新しいオブジェクト/配列として別々のメモリ位置を参照するようにする
参考資料
Discussion
参照渡しではなくて、インスタンスが共有されるからではないでしょうか?(javascript では 参照渡しでいうエイリアスの様な挙動にはならない為。
@juner
なるほど!勉強になります!🙇♂️
訂正
「参照渡し」ではなく「インスタンスが共有される」という表現のほうが、Vue 3のpropsの挙動を説明するうえで正確。
1. 参照渡しとインスタンス共有の違い
参照渡し(Pass by Reference)とは?
「参照渡し」は、引数や値が完全に同じメモリ参照を指している場合に使われる用語。もし参照渡しが行われている場合、どちらのスコープで操作しても同じインスタンスが影響を受ける。
しかし、JavaScriptでは、すべての値が値渡し(Pass by Value)として渡される。ただし、オブジェクトや配列のような参照型(Reference Type)は、その参照そのものが値として渡されるため、見た目上は「参照渡し」のように動作する。
インスタンス共有とは?
「インスタンス共有」という表現は、参照がコピーされて、複数の変数やスコープが同じインスタンスを指している状態を指す。JavaScriptでは、propsとして渡されたオブジェクトや配列が参照型であるため、子コンポーネントと親コンポーネントで同じインスタンスを共有する。
重要な違い:
2. Vue 3での挙動の解釈
オブジェクト自体の変更が禁止される理由
Vueでは、親から子へのデータフローを守るために、propsは読み取り専用(Read-Only)として扱われる。具体的には、Vueのデバッグモードでは次のようにエラーが発生する:
しかし、この「読み取り専用」はpropsそのものに対する制約であり、オブジェクトや配列の中身の変更(ネストされたプロパティの操作)までは防がない。
ネストされたプロパティの変更が可能な理由
propsが渡される際、トップレベルの参照(インスタンス)自体は変更できないようになっていますが、その参照が指すオブジェクトや配列の中身は直接操作可能。
実際の挙動:
この挙動の理由:
結果として、親コンポーネントと子コンポーネントが同じインスタンスを共有しているため、このような動作になる。
同じインスタンスというよりも同じ変数(※参照渡しでいうところの参照はインスタンスの参照ではなくて変数の参照な為) ですね。
要点としては 仮引数が実引数のエイリアスであるかですね。
そのため、javascriptでも糖衣構文を挟むことで構文上の参照渡しの実現性自体はあります。
vue3 でいうと、廃止された Reactivity Transform がそれですね。
つまり、 ref() の .value が隠蔽できるなら参照渡しは実現されます。
厳密には ref では既存の変数の参照としての動作はできないので proposal-refs にあるようにクロージャで既存の変数への反映とかまでしないとだめではありますが。