Edited at

v-modelにオブジェクトをバインディングする場合のコンポーネント実装を考える

More than 1 year has passed since last update.


まとめ


  • propsで受け取ったオブジェクトはイミュータブルなものとして扱う

  • v-modelを使うには、算出プロパティのgetterでpropsの参照を、setterでイベント通知をさせる

  • 配列をv-forでループしながらv-modelにバインドするのは無理そう


はじめに

いくつかの入力項目をドメインごとにまとめた小さな子コンポーネントに分け、親コンポーネントでそれらをひとまとめにするような構成を考えます。

親コンポーネントは、子コンポーネントの構成と同じ、もしくは近い構造のオブジェクトを状態として持ち、子コンポーネントにそのオブジェクトや配列をv-modelでバインドするような実装になっています。

例えば以下のような形です。


ContactInfo.vue

<template>

<div class="contact-info">
<input-contact-name v-model="model.name" />
<input-phone-numbers v-model="model.phoneNumbers" />
<input-mail-addresses v-model="model.mailAddresses" />
</div>
</template>

<script>
export default {
data () {
return {
model: {
name: { family: 'Hoge', first: 'Taro' },
phoneNumbers: [ ... ],
mailAddresses: [ ... ],
}
}
}
}
</script>


子コンポーネント側は、親から受け取ったオブジェクトを、input要素やさらに子のコンポーネントに対してv-modelでバインドしつつ、自身を利用する側に対してもv-modelディレクティブを提供する必要があります。

このような場合、子コンポーネントをどのように実装すべきかを考えてみます。


まずはVue.jsのガイドを読む

Vue.js公式ガイドの関連する項目へのリンクをまとめておきます。

重要なのはコンポーネント間のデータのやり取りにおいて、単方向データフローを守ることです。


Vue では、親子のコンポーネントの関係は、props down, events up というように要約することができます。親は、 プロパティを経由して、データを子に伝え、子はイベントを経由して、親にメッセージを送ります。


つまり、propsで受け取ったオブジェクトはイミュータブルなものとして扱う必要があります。

一方、v-modelによるプロパティのバインディングは、:value@inputをまとめた糖衣構文であり、バインドしたプロパティに対して参照と更新の両方が発生します。従って、propsで受け取ったオブジェクトや、そのプロパティを直接v-modelにバインドしてはいけない ということになります。


NGパターン

先に、実装して失敗したパターンを2つほど挙げます。


NGパターンその1: propsで受け取ったオブジェクトのプロパティを直接v-modelにバインドする

上でしてはいけないと書いた例です。


NgChildBindingDirectly.vue

<template>

<div class="child">
<input type="text" v-model="value.name.family">
<input type="text" v-model="value.name.first">
<input type="date" v-model="value.birthday">
</div>
</template>

<script>
export default {
props: ['value']
}
</script>



Parent.vue

<template>

<div class="parent">
<child v-model="person" />
</div>
</template>

<script>
import Child from './NgChildBindingDirectly'
export default {
components: { Child },
data() {
return {
person: {
name: { family: 'Hoge', first: 'Taro' },
birthday: '2008-03-05'
}
}
}
}
</script>


Edit v-model with object sample

コードとしてはかなり直感的で、一見正しく動作しますが、子コンポーネントから親コンポーネントのインスタンスを直接変更してしまっています。

親コンポーネント側からみると、プロパティの変更はv-modelによるものではありません。これは親コンポーネント内のtemplateのv-model="person":value="person"に変更しても同じように動作することからも確認できます。

これは単方向データフローを守れていません。子のpropsの値は常に親から変更されるべきです。


NGパターンその2: ディープコピーを保持してwatchで変更通知

ならばと、親からpropsで受け取ったオブジェクトのディープコピーを子コンポーネントのdataプロパティに状態として保持したうえで、その状態を監視し、変更があれば親側に通知すれば良いと考え、子コンポーネントの実装を下記のように変更してみました。


NgChildInfiniteLoop.vue

<template>

<div class="child">
<input type="text" v-model="model.name.family">
<input type="text" v-model="model.name.first">
<input type="date" v-model="model.birthday">
</div>
</template>

<script>
import _ from 'lodash'

export default {
props: ['value'],
data() {
return {
model: null
}
},
watch: {
value: {
handler(newValue) {
this.model = _.cloneDeep(newValue)
},
immediate: true,
deep: true
},
model: {
handler(newValue) {
this.$emit('input', newValue)
},
deep: true
}
}
}
</script>


Edit v-model with object sample

単方向データフローは実現できていますが、これは無限ループが発生します。

watchのdeepプロパティをtrueにセットすることにより、オブジェクト内部の変更まで監視してくれるようになりますが、オブジェクトの差分までは見てくれません。

全く同じ内容のオブジェクトで上書きされた場合もウォッチャが反応するため、親子間で通知がループしてしまいます。


:value@inputによる実装

まずはガイドの カスタムイベントを使用したフォーム入力コンポーネント にある方法を参考に実装してみます。


ChildBindValueInput.vue

<template>

<div class="child">
<input
type="text"
:value="value.name.family"
@input="updateFamilyName($event.target.value)">
<input
type="text"
:value="value.name.first"
@input="updateFirstName($event.target.value)">
<input
type="date"
:value="value.birthday"
@input="updateBirthday($event.target.value)">
</div>
</template>

<script>
export default {
props: ['value'],
methods: {
updateFamilyName(family) {
const newValue = {
...this.value,
name: { ...this.value.name, family }
}
this.$emit('input', newValue)
},

updateFirstName(first) {
const newValue = {
...this.value,
name: { ...this.value.name, first }
}
this.$emit('input', newValue)
},

updateBirthday(birthday) {
const newValue = { ...this.value, birthday }
this.$emit('input', newValue)
}
}
}
</script>


Edit v-model with object sample

v-modelを分解して、:valueにpropsで親から受け取ったオブジェクトのプロパティを、@inputに親コンポーネントへの更新通知処理をそれぞれバインドしています。

今回の実装では、コンポーネントは状態を持っていません。入力項目をまとめただけのコンポーネントなので、コンポーネント自身に状態を持つ必要はありません。

親から受け取った値を使って描画し、入力内容に変更があれば親に通知するだけです。入力項目にバインドされたプロパティは、常に親側で変更されます。


算出プロパティのgetter/setterを利用する

上記の例だとinput要素ごとに:value@inputのバインディングが必要になり、templateの記述が少し煩雑になります。

v-modelによる双方向バインディングを使うには、算出プロパティのgetter/setterを使います。


ChildComputedVmodel.vue

<template>

<div class="child">
<input type="text" v-model="familyName">
<input type="text" v-model="firstName">
<input type="date" v-model="birthday">
</div>
</template>

<script>
export default {
props: ['value'],
computed: {
name: {
get() {
return this.value.name
},
set(name) {
this.updateValue({ name })
}
},
birthday: {
get() {
return this.value.birthday
},
set(birthday) {
this.updateValue({ birthday })
}
},
familyName: {
get() {
return this.name.family
},
set(family) {
this.name = { ...this.name, family }
}
},
firstName: {
get() {
return this.name.first
},
set(first) {
this.name = { ...this.name, first }
}
}
},
methods: {
updateValue(diff) {
this.$emit('input', { ...this.value, ...diff })
}
}
}
</script>


Edit v-model with object sample

computed内の記述はボイラープレート気味ですが、v-modelを利用したことでtemplateの記述はシンプルになりました。

また、算出プロパティのsetterで親コンポーネントへの通知をしているため、例えばmethodsに用意した処理から値の変更が必要になるような場合でも、単なるプロパティへの代入として記述できるメリットがあります。


配列をバインドする場合のv-forとの組み合わせ

今度は、親からpropsで配列を受け取り、v-forでループしながら配列の要素を子コンポーネントにバインドする場合を考えてみます。


ChildrenArray.vue

<template>

<div class="children">
<child
v-for="(item, index) in value"
:key="index"
:value="value[index]"
@input="value => updateItem(value, index)" />
</div>
</template>

<script>
import Child from './ChildComputedVmodel'

export default {
components: { Child },
props: ['value'],
methods: {
updateItem(item, index) {
const newValue = [
...this.value.slice(0, index),
item,
...this.value.slice(index + 1)
]
this.$emit('input', newValue)
}
}
}
</script>


Edit v-model with object sample

配列のインデックスアクセスに対してgetter/setterは利用できないため、v-modelを使うのは諦めて:value@inputを分けて記述しています。inputイベントを拾って変更後の配列を新規に作成し、親コンポーネントへ通知する形で実装しています。


親への変更通知を任意のタイミングで行う例

編集状態を持ち、確定するまで値を変更したくないような場合を考えてみます。


ChildCancelable.vue

<template>

<div class="child">
<template v-if="model">
<input type="text" v-model="model.name.family">
<input type="text" v-model="model.name.first">
<input type="date" v-model="model.birthday">
<button @click="submit">OK</button>
<button @click="cancel">Cancel</button>
</template>
<template v-else>
<div class="preview">{{ value.name.family }}</div>
<div class="preview">{{ value.name.first }}</div>
<div class="preview">{{ value.birthday }}</div>
<button @click="edit">Edit</button>
</template>
</div>
</template>

<script>
import _ from 'lodash'

export default {
props: ['value'],
data() {
return {
model: null
}
},
methods: {
edit() {
this.model = _.cloneDeep(this.value)
},
cancel() {
this.model = null
},
submit() {
this.$emit('input', this.model)
this.model = null
}
}
}
</script>


Edit v-model with object sample

コンポーネントは状態を持つことになります。

編集モードに遷移する際に親から受け取ったオブジェクトのディープコピーを生成し、編集完了時に親へのイベント通知を行う形で実装しています。

はじめのNGパターンに近い実装ですが、コンポーネント内で保持しているオブジェクトは親のインスタンスと別物なので、オブジェクトの内部を直接v-modelにバインドしても問題ありません。


実践

上記の実装例をもとに、サンプルとして連絡先情報を更新するアプリ(の一部のようなもの)を作ってみました。

Edit v-model with object contact list


おわりに

入力項目の多い画面を作っていると、今回のようにコンポーネントを分けてv-modelにオブジェクトをまるごとバインドする作りにしたくなることが割とあるのですが、ググってもあまり良い実装例が見つからず、今回このような記事を書くに至りました。

もしかしたらUI設計やコンポーネント分割方針自体が間違っているのかもしれません。あまり再利用性のないコンポーネント分割をしているような気もしています。

単一ファイルコンポーネントで作っていてtemplateやstyleが膨らんでくると見通しが悪くなってきて分割したくなる、すると今度は小さいコンポーネントにも関わらず今回挙げた例のようにscript部分が冗長になってしまう、という感じで正直なところジレンマを抱えています。

Vuexを導入する場合はpropsをstateやgetterに、親コンポーネントへのイベント通知をcommitやdispatchに置き換えて考えてもらえれば良いかと思います。

computedのgetter/setterの形式をとるかどうかは好みで良くて、無理にv-modelに拘る必要はないと思っています。

今のところ配列のv-forとの組み合わせではv-modelを使う方法がない以上、v-modelに統一もできない……と思います。何か良い方法があれば教えてください。

この記事が似たような実装で悩んでいる方の参考になれば幸いです。