⚠️

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

に公開4

はじめに

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

junerjuner

これは、JavaScriptでオブジェクトや配列が参照渡しであることに起因しているようです。

参照渡しではなくて、インスタンスが共有されるからではないでしょうか?(javascript では 参照渡しでいうエイリアスの様な挙動にはならない為。

https://ja.wikipedia.org/wiki/評価戦略#参照呼び

k1b3k1b3

@juner
なるほど!勉強になります!🙇‍♂️

k1b3k1b3

訂正

「参照渡し」ではなく「インスタンスが共有される」という表現のほうが、Vue 3のpropsの挙動を説明するうえで正確。

1. 参照渡しとインスタンス共有の違い

参照渡し(Pass by Reference)とは?

「参照渡し」は、引数や値が完全に同じメモリ参照を指している場合に使われる用語。もし参照渡しが行われている場合、どちらのスコープで操作しても同じインスタンスが影響を受ける。

しかし、JavaScriptでは、すべての値が値渡し(Pass by Value)として渡される。ただし、オブジェクトや配列のような参照型(Reference Type)は、その参照そのものが値として渡されるため、見た目上は「参照渡し」のように動作する。

インスタンス共有とは?

「インスタンス共有」という表現は、参照がコピーされて、複数の変数やスコープが同じインスタンスを指している状態を指す。JavaScriptでは、propsとして渡されたオブジェクトや配列が参照型であるため、子コンポーネントと親コンポーネントで同じインスタンスを共有する。

重要な違い:

  • 「参照渡し」では、元の変数そのものを置き換えられる(JavaScriptでは起きない)。
  • 「インスタンス共有」では、インスタンスの中身が変更可能だが、元の変数を完全に置き換えることはできない(JavaScriptの挙動に合致)。

2. Vue 3での挙動の解釈

オブジェクト自体の変更が禁止される理由

Vueでは、親から子へのデータフローを守るために、propsは読み取り専用(Read-Only)として扱われる。具体的には、Vueのデバッグモードでは次のようにエラーが発生する:

props.obj = { key: 'newValue' }; // 変更しようとすると警告

しかし、この「読み取り専用」はpropsそのものに対する制約であり、オブジェクトや配列の中身の変更(ネストされたプロパティの操作)までは防がない。

ネストされたプロパティの変更が可能な理由

propsが渡される際、トップレベルの参照(インスタンス)自体は変更できないようになっていますが、その参照が指すオブジェクトや配列の中身は直接操作可能。

実際の挙動:

props.obj.nestedKey = 'modifiedValue'; // 問題なく動作する

この挙動の理由:

  • JavaScriptでは、props.objは親コンポーネントのオブジェクトの参照をコピーしたもの。
  • Vueは、propsの参照先が変更されることは防ぐが、参照先の中身の操作を禁止する仕組みは提供していない。

結果として、親コンポーネントと子コンポーネントが同じインスタンスを共有しているため、このような動作になる。

junerjuner

「参照渡し」は、引数や値が完全に同じメモリ参照を指している場合に使われる用語。もし参照渡しが行われている場合、どちらのスコープで操作しても同じインスタンスが影響を受ける。

同じインスタンスというよりも同じ変数(※参照渡しでいうところの参照はインスタンスの参照ではなくて変数の参照な為) ですね。
要点としては 仮引数が実引数のエイリアスであるかですね。
そのため、javascriptでも糖衣構文を挟むことで構文上の参照渡しの実現性自体はあります。

https://qiita.com/juner/items/584ccbbc5576fb9854e3

vue3 でいうと、廃止された Reactivity Transform がそれですね。
https://ja.vuejs.org/guide/extras/reactivity-transform

つまり、 ref() の .value が隠蔽できるなら参照渡しは実現されます。

厳密には ref では既存の変数の参照としての動作はできないので proposal-refs にあるようにクロージャで既存の変数への反映とかまでしないとだめではありますが。

https://github.com/rbuckton/proposal-refs