0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Vue3】親子コンポーネント間でオブジェクトの双方向バインディングを実現する

Posted at

背景

親コンポーネントと子コンポーネント2つ、という構成にて以下の要件を満たしたい、という場面に遭遇したので備忘録としてまとめます。

  • 親コンポーネントはオブジェクト(多階層)を状態に持つ
  • 子コンポーネントから親のオブジェクトを更新できる
  • 片方の子コンポーネントが親のオブジェクトを更新したら、もう片方の子コンポーネントも最新の状態に更新される

つまり、親が持つオブジェクトに対して、複数の子コンポーネント間で双方向バインディングを実現したかったわけです。

image.png

前提:オブジェクトのpropsの仕様

通常Vueで親から子コンポーネントへ渡したpropsは子コンポーネントでは、直接変更はできません。変更したい場合は、emitを利用して親コンポーネントでの関数を発火させる必要があります。

しかし親から子コンポーネントへpropsでオブジェクトまたは、配列を渡した場合は直接変更できてしまいます。

Parent.vue
<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>
Child.vue
<template></template>

<script setup lang="ts">
type Props = {
  user: object;
};
const props = defineProps<Props>();

//propsのオブジェクトを直接変更
props.user.score.english = 50;

</script>
Parent.vueの出力
User
{ "name": "taro", "score": { "english": 50, "math": 90 } }

Child.vueに渡したオブジェクトをChild内で変更できてしまっています。これはVueの公式ドキュメントでも言及されていますが、データの流れを追いにくくなるため、あまり推奨されていないようです。

オブジェクトや配列をプロパティとして渡した場合、子コンポーネントがプロパティのバインディングを変更することはできませんが、オブジェクトや配列のネストされたプロパティを変更することは可能です。これは、JavaScript ではオブジェクトや配列が参照渡しであり、Vue がそのような変更を防ぐのにかかるコストが現実的でないためです。
このような変更の主な欠点は、親コンポーネントにとって明瞭でない方法で子コンポーネントが親の状態に影響を与えることを許してしまい、後からデータの流れを見極めるのが難しくなる可能性があることです。親と子を密に結合させる設計でない限り、ベストプラクティスとしてはそのような変更を避けるべきです。ほとんどの場合、子コンポーネントはイベントを発行して、変更を親コンポーネントに実行してもらう必要があります。

そこで今回はあくまでも、この仕様は使わず変更は親コンポーネントで行うようにして上記の要件を実現します。

方針

というわけでこれを実現するために以下の方針で進めます。

  • 子コンポーネントではpropsのcopyを作り、それを更新してemitで親に更新後のコピーオブジェクトを渡す
  • 親コンポーネントでemitを検知、コピーオブジェクトを受け取り、元のオブジェクトを更新

ここまでは普通なのですが今回は子コンポーネントが2つあるため、片方の子が変更されたらもう片方の子も親の変更を検知する必要があります。というわけでそれぞれの子コンポーネントはwatchを使って親から渡されるpropsの変更を検知し、常にcopyオブジェクトを更新するようにします。

image.png

実装

1. propsのコピーをemit

propsで渡されたオブジェクトをコピーし、コピーが変更されたらemitで親に値を渡します。

注意:propsのオブジェクトのコピーは参照が渡ったまま

propsで渡したオブジェクトのコピーを作る際に、スプレッド演算子で分割してもネストされたオブジェクトの参照は渡ったままなので、これだと結局直接変更できてしまいます。

Child.vue
<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())などでもいいですが、今回はlodashcloneDeepを使用してディープコピーすることで参照を切ります。

<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の処理も追加したのがこちら

Parent.vue
<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>
Child1.vue
<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点のまま。逆も然り。)

0207.gif

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 }オプションをつける必要があります。

結果

これで子コンポーネントを変更したら、親コンポーネントともうひとつの子コンポーネントも更新することができました。

0207-2.gif

まとめ

結構単純な処理ではあるのですが、前提に書いたオブジェクトのプロパティを直接変更できてしまう件など参考になるかなと思いまとめてみました。
もし、間違い等ありましたらコメントいただけますと幸いです。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?