背景
親コンポーネントと子コンポーネント2つ、という構成にて以下の要件を満たしたい、という場面に遭遇したので備忘録としてまとめます。
- 親コンポーネントはオブジェクト(多階層)を状態に持つ
- 子コンポーネントから親のオブジェクトを更新できる
- 片方の子コンポーネントが親のオブジェクトを更新したら、もう片方の子コンポーネントも最新の状態に更新される
つまり、親が持つオブジェクトに対して、複数の子コンポーネント間で双方向バインディングを実現したかったわけです。
前提:オブジェクトのpropsの仕様
通常Vueで親から子コンポーネントへ渡したpropsは子コンポーネントでは、直接変更はできません。変更したい場合は、emit
を利用して親コンポーネントでの関数を発火させる必要があります。
しかし親から子コンポーネントへpropsでオブジェクトまたは、配列を渡した場合は直接変更できてしまいます。
<template>
<p>User</p>
{{ user }}
<Child :user="user"></Child>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Child from './Child.vue';
const user = ref({
name: 'taro',
score: {
english: 80,
math: 90,
}
})
</script>
<template></template>
<script setup lang="ts">
type Props = {
user: object;
};
const props = defineProps<Props>();
//propsのオブジェクトを直接変更
props.user.score.english = 50;
</script>
User
{ "name": "taro", "score": { "english": 50, "math": 90 } }
Child.vue
に渡したオブジェクトをChild内で変更できてしまっています。これはVueの公式ドキュメントでも言及されていますが、データの流れを追いにくくなるため、あまり推奨されていないようです。
オブジェクトや配列をプロパティとして渡した場合、子コンポーネントがプロパティのバインディングを変更することはできませんが、オブジェクトや配列のネストされたプロパティを変更することは可能です。これは、JavaScript ではオブジェクトや配列が参照渡しであり、Vue がそのような変更を防ぐのにかかるコストが現実的でないためです。
このような変更の主な欠点は、親コンポーネントにとって明瞭でない方法で子コンポーネントが親の状態に影響を与えることを許してしまい、後からデータの流れを見極めるのが難しくなる可能性があることです。親と子を密に結合させる設計でない限り、ベストプラクティスとしてはそのような変更を避けるべきです。ほとんどの場合、子コンポーネントはイベントを発行して、変更を親コンポーネントに実行してもらう必要があります。
そこで今回はあくまでも、この仕様は使わず変更は親コンポーネントで行うようにして上記の要件を実現します。
方針
というわけでこれを実現するために以下の方針で進めます。
- 子コンポーネントではpropsのcopyを作り、それを更新して
emit
で親に更新後のコピーオブジェクトを渡す - 親コンポーネントで
emit
を検知、コピーオブジェクトを受け取り、元のオブジェクトを更新
ここまでは普通なのですが今回は子コンポーネントが2つあるため、片方の子が変更されたらもう片方の子も親の変更を検知する必要があります。というわけでそれぞれの子コンポーネントはwatch
を使って親から渡されるpropsの変更を検知し、常にcopyオブジェクトを更新するようにします。
実装
1. propsのコピーをemit
propsで渡されたオブジェクトをコピーし、コピーが変更されたらemit
で親に値を渡します。
注意:propsのオブジェクトのコピーは参照が渡ったまま
propsで渡したオブジェクトのコピーを作る際に、スプレッド演算子で分割してもネストされたオブジェクトの参照は渡ったままなので、これだと結局直接変更できてしまいます。
<script setup lang="ts">
import { ref } from 'vue';
type Props = {
user: object;
};
const props = defineProps<Props>();
const copyUser = ref({ ...props.user })
copyUser.value.score.english = 50; // これで変更できてしまう
console.log(copyUser.value.score === props.user.score); // true
</script>
ディープコピーで対応
JSON.parse(JSON.stringify())
などでもいいですが、今回はlodash
のcloneDeep
を使用してディープコピーすることで参照を切ります。
<script setup lang="ts">
import { ref } from 'vue';
+ import _ from 'lodash';
type Props = {
user: object;
};
const props = defineProps<Props>();
- const copyUser = ref({ ...props.user })
+ const copyUser = ref(_.cloneDeep(props.user)) // deepコピーを行う
copyUser.value.score.english = 50; // コピーだけが更新される
console.log(copyUser.value.score === props.user.score); // false
</script>
というわけでemit
の処理も追加したのがこちら
<template>
<p>User</p>
{{ user }}
<Child1 :user="user" @update="handleUpdate"></Child1>
<Child2 :user="user" @update="handleUpdate"></Child2>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import Child1 from './Child1.vue';
import Child2 from './Child2.vue';
const user = ref({
name: 'taro',
score: {
english: 80,
math: 90,
}
})
const handleUpdate = (object) => {
user.value = object;
}
</script>
<template>
<div>
<p>Child1</p>
{{ copyUser }}
<button @click="handleUpdate">英語を50点にする</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import _ from 'lodash';
type Props = {
user: object;
};
const props = defineProps<Props>();
const copyUser = ref(_.cloneDeep(props.user));
type Emits = { (e: 'update', value: object): void }
const emit = defineEmits<Emits>();
const handleUpdate = () => {
copyUser.value.score.english = 50; // コピーを更新
emit('update', copyUser.value); // コピーをemit
}
</script>
Child2.vue
もほぼ同じです。(Child2.vue
では数学を50点にしています。)今回は簡略化のために、ボタンで変更にしましたが、フォーム等で更新する場合もコピーオブジェクトを変更してemit
します。
途中経過
2つの子コンポーネントで変更を加えると、親のオブジェクトも変更されていることがわかります。ただし、それぞれのコンポーネントの変更がもう片方のコンポーネントには伝わってないことがわかります。
(Child1.vue
で英語を50点にしても、Child2.vue
では80点のまま。逆も然り。)
2. propsの変更をwatch
今回はpropsをそのままwatch
してもよかったのですが、他にpropsが存在する場合もあるので、toRefs
でオブジェクトをリアクティブに取り出してwatch
しました。props.user
をwatchしても検知されません。詳しくは以下の記事がわかりやすかったです。
変更後のChild1.vue
<template>
<div>
<p>Child1</p>
{{ copyUser }}
<button @click="handleUpdate">英語を50点にする</button>
</div>
</template>
<script setup lang="ts">
import { ref, watch, toRefs } from 'vue';
import _ from 'lodash';
type Props = {
user: object;
};
const props = defineProps<Props>();
const copyUser = ref(_.cloneDeep(props.user));
+ const { user } = toRefs(props); // propsをリアクティブに取り出す
+ watch(user, () => copyUser.value = _.cloneDeep(user.value), { deep: true }); // オブジェクトをdeepに検知
type Emits = { (e: 'update', value: object): void }
const emit = defineEmits<Emits>();
const handleUpdate = () => {
copyUser.value.score.english = 50;
emit('update', copyUser.value);
}
</script>
深くネストされているオブジェクトの変更を検知するには、watch
の第三引数に{ deep: true }
オプションをつける必要があります。
結果
これで子コンポーネントを変更したら、親コンポーネントともうひとつの子コンポーネントも更新することができました。
まとめ
結構単純な処理ではあるのですが、前提に書いたオブジェクトのプロパティを直接変更できてしまう件など参考になるかなと思いまとめてみました。
もし、間違い等ありましたらコメントいただけますと幸いです。