はじめに
Vue.js を触り始めて半年経ちましたが、未だに v-model で迷ってしまうことがあります。
何度 v-model 完全に理解した!! と思ったことでしょうか。
もう二度と v-model に惑わされないように、基本的な内容と躓きやすいポイントをまとめていきます。
※ この話を社内勉強会でしたところ、議論があり、もう一度惑わされてしまいました。[追記](# (追記)イミュータブル VS 可読性)してあります。
前提
本記事に出てくるコードは下記の構成のプロジェクトであることが前提になっています。
- Vue 2.x
- TypeScript
- vue-property-decorator
v-model とは
公式ドキュメント では下記のように紹介されています。
form の input 要素 や textarea 要素、 select 要素に双方向 (two-way) データバインディングを作成するには、v-model ディレクティブを使用することができます。それは、自動的に入力要素のタイプに基づいて要素を更新するための正しい方法を選択します。ちょっと魔法のようですが、v-model はユーザーの入力イベントにおいてデータを更新するための基本的な糖衣構文 (syntax sugar) で、それに加えて、いくつかのエッジケースに対しては特別な配慮をしてくれます。
v-model は糖衣構文
v-model
は v-bind
と v-on
をまとめて書くための糖衣構文です。
最も基本的な例として、下記のような input 要素は
<input
:value="searchWord"
@input="searchWord = $event.target.value"
>
このように書き換えることができます。
<input v-model="searchWord">
双方向データバインディング?
双方向とは 親 → 子、子 → 親 というデータの流れのことを指します。
今回の例で説明するとこんなイメージです。
v-model が使えるカスタムコンポーネント
現実的には html 要素を直接扱う機会は少ない
コンポーネント指向で開発をしていると、input 要素は直接使わず、ラップして使うことが多いと思います。
スタイルをプロジェクトに合わせて設定したり、バリデーション機能を付けたりします。
つまり上記の例のように input に v-model を設定することはそんなにありません。それよりも自分で作ったコンポーネントに対して v-model を設定したくなります。
v-model が使えるカスタムコンポーネントの作り方
v-model を使わない双方向バインディングの仕方をもう一度見てみましょう。
<input
:value="searchWord"
@input="searchWord = $event.target.value"
>
カスタムコンポーネントで v-model を使うためには、この例と同じインターフェースを持つコンポーネントを作ればOKです。
つまり、
- value プロパティを持つ
- input イベントを発火して入力された値を渡してくれる
という2つがそろっていればいいのです。
これらを満たすコンポーネントが以下です。
<template>
<div>
<p>BaseInput</p>
<input
:value="value"
@input="$emit('input', $event.target.value)"
>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"
@Component
export default class BaseInput extends Vue {
@Prop({ required: true })
value!: string
}
</script>
使う側はこんな感じです。
<template>
<div>
<p>SampleForm</p>
<p>Name</p>
<BaseInput v-model="name" />
<p>Email</p>
<BaseInput v-model="email" />
<p>Address</p>
<BaseInput v-model="address" />
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator"
import BaseInput from "./BaseInput.vue"
@Component({
components: {
BaseInput,
},
})
export default class SampleForm extends Vue {
name = "default name"
email = "default email"
address = "default address"
}
</script>
input 要素と カスタムコンポーネントの違い
v-model は糖衣構文であると言いました。
では BaseInput を v-model を使わないパターンで書いてみましょう。
これが
<BaseInput v-model="name" />
<!-- 誤り -->
<BaseInput
:value="name"
@input="name = $event.target.value"
>
こうではありません。
正しくはこうなります。
<BaseInput
:value="name"
@input="name = $event"
>
BaseInput では @input="$emit('input', $event.target.value)"
として、input 要素から送られてくるイベントオブジェクトの殻を剥いて value だけを取り出して emit してくれています。
すなわち、受け取る側では $event がそのまま欲しい値となっているのです。
このように、 v-model は糖衣構文といってもどの要素に適用するかで動き方が変わってきます。
input 以外の html 要素へ v-model を適用する
ここまではプレーンな input 要素を例に紹介してきましたが、他の html 要素にも有効です。
v-model はそれぞれの要素に対して次のような組み合わせで働きます。
html 要素 | バインドする属性 | イベント |
---|---|---|
input type="text" | value | input |
textarea | value | input |
input type="checkbox" | checked | change |
input type="radio" | checked | change |
select | value | change |
この組み合わせに従うと、例えば checkbox は次のように書き換えられます。
<input
type="checkbox"
:value="checked"
@change="cheked = $event.target.value"
>
↓
<input
type="checkbox"
v-model="checked"
>
このように、html 要素ごとにも適用のされ方が異なることに注意が必要です。
公式ドキュメントに例が豊富なので参考になるとおもいます。
オブジェクトを v-model で扱う
ここまでは単体の input 要素に注目してきましたが、複数の値を v-model で扱いたいことが多々あります。
先ほど紹介した SampleForm の例を v-model が使えるコンポーネントに修正してみましょう。
最終的には
<SampleForm v-model="user" />
このようなインターフェースで使えるコンポーネントを目指します。
NG 例
まずはよくある NG 例を紹介します。
※ この NG 例に関しては本当に NG なのかどうか議論がありましたので[後述します](# (追記)イミュータブル VS 可読性)。
<template>
<div>
<p>Name</p>
<BaseInput v-model="value.name" />
<p>Email</p>
<BaseInput v-model="value.email" />
<p>Address</p>
<BaseInput v-model="value.address" />
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"
import BaseInput from "./BaseInput.vue"
interface User {
name: string
email: string
address: string
}
@Component({
components: {
BaseInput,
},
})
export default class SampleFormNG extends Vue {
@Prop({ default: {} })
value!: User
}
</script>
このコンポーネントは User オブジェクトをプロパティとして受け取り、子のコンポーネントにそのインスタンスのプロパティ value.name
value.email
value.address
をバインドしています。
実はこのコンポーネントは
<SampleFormNG v-model="user" />
のように呼び出すと、あたかも期待通りに動いているように見えます。
何がダメか
このコンポーネントを使う側で、v-model ではなく :value を使うとどうなるでしょうか。
<SampleFormNG :value="user" />
これも user を v-model に設定したときと同じように動きます。
すなわち、user が勝手に子コンポーネントに書き換えられてしまったということです。
これを許してしまうと、例えばユーザ情報を画面に表示だけし、編集はさせたくないというときに困ります。
何故こうなるのか
オブジェクトや配列の値は string や number などのプリミティブ型と異なり、値が参照渡しされるため、Vue が変更を検知してくれないことが原因です。
もう少し詳しく見ていきましょう。
<BaseInput v-model="value.name" />
これは
<BaseInput
:value="value.name"
@input="value.name = $event"
/>
これと等価です。value は prop ですが、@input で $event が代入されています。
prop の値の変更は Vue では推奨されていません。
value.name ではなく string や number などのプリミティブ値であればブラウザのコンソールにこんな感じのエラーが出て、値は変更されません。
vue.runtime.esm.js?2b0e:619 [Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "value"
found in
---> <BaseInput> at src/components/BaseInput.vue
<InputPage> at src/pages/InputPage.vue
<App> at src/App.vue
<Root>
value.name を v-model に設定したときは、Vue が value.name の変更を検知せず、スルーしてしまうため v-model が正しく動作しているように振る舞っていたのです。
どうすれば良いのか
では、オブジェクトを v-model で扱いたいときはどうすればいいでしょうか。
やり方は無数にありますが、とにかく prop に変更を加えないという点を守れば OK です。
以下に私が考えた一例を示します。
<template>
<div>
<p>Name</p>
<BaseInput
:value="value.name"
@input="onInput({ name: $event })"
/>
<p>Email</p>
<BaseInput
:value="value.email"
@input="onInput({ email: $event })"
/>
<p>Address</p>
<BaseInput
:value="value.address"
@input="onInput({ address: $event })"
/>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"
import BaseInput from "./BaseInput.vue"
interface User {
name: string
email: string
address: string
}
@Component({
components: {
BaseInput,
},
})
export default class SampleForm extends Vue {
@Prop({ default: {} })
value!: User
onInput(event: { key: string }): void {
this.$emit("input", {
...this.value,
...event,
})
}
}
</script>
ポイント
それぞれの BaseInput の @input
で、onInput メソッドを呼び出し、一組のキーと値を持つオブジェクトを渡しています。
<BaseInput
:value="value.name"
@input="onInput({ name: $event })"
/>
onInput では渡されたオブジェクトで this.value を上書きしたものを emit しています
onInput(event: { key: string }): void {
this.$emit("input", {
...this.value,
...event,
})
}
このようにすることで prop を直接更新せず、入力時に変更された値だけを更新して emit できます。
コードの重複も少ないです。
(追記)イミュータブル VS 可読性
ここまでの内容を社内の勉強会で話したところ、[NG 例](# NG 例)のような書き方でも問題ないのではないか、という議論がありました。
実際には今回の例より複雑になることが多い
今回の例では単方向データフローの原則を守る修正を比較的少ないコード量で実現できましたが、それは扱うオブジェクトが単純だからではないか、という意見がありました。
確かにその通りです。例えば user オブジェクトの address が更に複数のプロパティを持つときは、新たに onInputAddress
を定義する必要があります。
interface User {
name: string
email: string
address: {
state: string
city: string
street: string
}
}
onInputAddress(event: { key: string }) {
this.$emit("input", {
...this.value,
address: {
...this.value.address,
...event,
}
})
}
扱うオブジェクトが複雑になるほど、このような関数を更に定義していく必要が出てしまいます。
フォームコンポーネントはフォームの編集を前提としている
[NG 例](# NG 例)の実装では、prop として渡したオブジェクトが勝手に変更されるのが良くない、と述べました。
<SampleFormNG :value="user" />
これに対し、フォームのようなコンポーネントに対して、上記のように value だけを渡すケースはほぼありえないのでは、という意見がありました。
確かにそのとおりです。表示だけを責務とする UserInfoCard
のようなコンポーネントならありえますが、フォームのように編集を前提とするコンポーネントではこのような使われ方はしません。
結論
以上を踏まえて、下記の条件下では、v-model にオブジェクトを設定する使い方も有りなのではないかという結論に達しました。
- コードの可読性が大幅に改善するとき
- フォームのようにオブジェクトの編集を責務とするコンポーネントを書くとき
ただし、Vue.js の経験が浅い開発者がこのコードを見て、v-model にオブジェクトを設定するのが全く問題ないと判断するようなことがあってはいけません。コメントを残したり、チーム内で方針を共有するなどの工夫は必須となります。
まとめ
v-model の基本的な内容とハマりどころについて書いてみました。
ポイントは以下でした。
- v-model はケースによって若干違う動きをするので注意する
- 子コンポーネントは親コンポーネントの値を変えてはいけないという原則を守る
- オブジェクトや配列の変更は検知されないので注意する
- 可読性が大幅に改善される場合はオブジェクトを v-model でバインドすることを許可してもいいのでは
少し迷うこともある v-model ですが、コードを簡潔に書く上で欠かせません。
また、v-model が使えるコンポーネントを作る、ということはより Vue らしいコンポーネント分割の手助けになるはずです。
使える場面では積極的に使っていきましょう。