⚠️

【Vue3】オブジェクト/配列のpropsは直接参照を避けよう

2023/06/27に公開

はじめに

Vue3におけるオブジェクト/配列のpropsに関する記事です。

与件

  • Vue3, Nuxt3, TypeScript
  • propsでオブジェクト/配列を親コンポーネントから受け取る

propsの仕様

Vue3において、オブジェクトや配列をpropsとして渡した場合、子コンポーネントがそのオブジェクトや配列自体を変更することはできませんが、オブジェクトや配列のネストされたプロパティを変更することはできてしまいます。

これは、JavaScriptでオブジェクトや配列が参照渡しであることに起因しているようです。
https://ja.vuejs.org/guide/components/props.html#one-way-data-flow

単一の値を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>

起こりうる問題

こうした仕様は、子コンポーネントが親の状態に影響を与えることを許してしまいます...
以下の公式ドキュメントでも、後からデータの流れを見極めるのが難しくなるため、親と子を密に結合させる設計でない限り、このような変更を避けることがベストプラクティスであると記載されてあります。

https://ja.vuejs.org/guide/components/props.html#mutating-object-array-props

意図しないデータフローを避けてオブジェクト/配列を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>

このコードではarrHogeobjHogeを新しい配列と新しいオブジェクトとして計算された値として定義しています。こうすることで、props.arrHogeprops.objHogeの値が変更されても、arrHogeobjHogeは常に新しい配列と新しいオブジェクトを参照するようになります。

ちなみにめちゃくちゃ省略して

<script setup lang="ts">
ref<Array<number | string>>([...props.arrHoge])
</script>

とかでも良いんですが、これだとpropsの値が変更された場合に手動で valueプロパティを設定する必要があるので場合によりますね😅

まとめ

  • オブジェクトや配列をpropsとして渡した場合、子コンポーネントがpropsのバインディングを変更することはできないが、オブジェクトや配列のネストされたプロパティを変更することはできてしまう。
  • 意図しないデータフローを防ぐため、オブジェクト/配列のpropsは直接参照しないようにする
  • オブジェクト/配列のpropsは新しいオブジェクト/配列として別々のメモリ位置を参照するようにする

参考資料

https://ja.vuejs.org/guide/components/props.html#one-way-data-flow
https://ja.vuejs.org/guide/components/props.html#mutating-object-array-props

Discussion