Vue2系と3系で微妙に挙動が違うので、備忘録として。
・v-model便利すぎワロタ
Vueで何とも便利なのがv-modelとかいう機能である。これを書くだけで勝手に双方向データバインディングをやってくれるので面倒なことを考えなくて済む。
これは、かなり単純化された理解としては、 v-bind:valueとv-on:inputに関するシンタックスシュガー(糖衣構文)である: つまり
<input v-model="foo" />
は、以下の記述と動作的には等価である。
<input
:value="foo"
@input="foo = $event.target.value"
>
input要素のtypeが違う場合にはバインドされるプロパティが変わる(checkboxやradioなら:valueの代わりに:checkedのように)が、展開される中身は同じである。
・複数ある時は?
ところが、これは単一のinput要素に対してv-modelでバインドするときの話で、複数のチェックボックスに対して同じv-modelをバインドするときには話が変わってくる。試しに上で示したような "等価な" input要素を複数並べてみれば、全然動かないことはすぐに明らかになるはずだ。
実際に複数のチェックボックスに対して同一のv-modelをバインドするときには、バインドされる変数は配列であるので、実際に等価な内容に展開しようとすると以下のようになるはずである。
<input type="checkbox" :value="thisValue" v-model="bindValue">
//上のものと同じ動作をする冗長な記述
<input
type="checkbox"
:value="thisValue"
:checked="bindValue.includes(thisValue)"
@input="bindValue = bindValue.includes($event.target.value) ?
bindValue.filter((v) => v !== $event.target.value) :
bindValue.concat($event.target.value)
"
>
つまりバインドされた変数 ("bindValue")は、どの要素にチェックが入っているかを表す配列であって、checked属性は配列に値が含まれているかで判断するし、inputイベントの際には配列に値を出したり入れたりしなければならないということである。
このようにv-modelはその要素のタイプや数によって展開される内容は大きく変わってくるし、そしてコンポーネント化する際にはそれを十分に把握しておく必要がある。
・コンポーネント化してみる
さて、カスタムのコンポーネントにv-modelを使う場合であるが、特にtypeを指定しなければ、Vue2系なら、バインドされる属性は(冒頭に示した例のように):valueと@inputである。(Vue3系の話は後でする。本質的には同じだからとりあえず置いておく)
であるからには、コンポーネントはpropsとしてvalueを持っておき、(コンポーネントはpropsで受け取った値を直接変更できないから)inputイベントをemitすればよいはずである。
したがって実直にいくならば、先ほど掲げた等価な展開を多少弄って下のようにすればよい。
<template>
<div class="multi-checkbox">
<input
type="checkbox"
:checked="value.includes(thisValue)"
@input="(event) =>{
$emit('input', value.includes(event.target.value) ?
value.filter(v => v !== event.target.value) :
value.concat(event.target.value)
)
}"
:id="id"
:value="thisValue"
>
<slot />
</div>
</template>
<script>
export default {
props: ['value', 'id', 'thisValue']
}
</script>
変わっているのは、@inputの中身が直接値を書き換えるのではなくて、inputイベントをemitしていることだけである。
コンポーネントを使う側は普通にv-modelを使えばよい。ただしコンポーネント内のinput要素に渡す必要がある:value属性の値はprops経由だから、そこは気を付ける必要がある。
<template>
<div>
<multi-checkbox v-model="checkList" :id="0" :thisValue="0">hoge</multi-checkbox>
<multi-checkbox v-model="checkList" :id="1" :thisValue="1">fuga</multi-checkbox>
<multi-checkbox v-model="checkList" :id="2" :thisValue="2">piyo</multi-checkbox>
...
</div>
</template>
<script>
import MultiCheckbox from "./components/MultiCheckbox.vue"
export default {
components: { MultiCheckbox },
data() {
return {
checkList: []
}
}
}
</script>
ただし、これにはもっと簡単な方法があって、それはコンポーネントの中でもv-modelを使うというものである。そのままv-modelを使ってもうまくいかないが、v-modelにバインドされた変数をget/setするプロパティを作成して、そのsetterの中でイベントをemitすることで
<template>
<div class="multi-checkbox-prop">
<input type="checkbox" v-model="bindProp" :id="id" :value="thisValue" />
<slot />
</div>
</template>
<script>
export default {
props: ["value", "id", "thisValue"],
computed:{
bindProp:{
get(){ return this.value },
set(newValue) { this.$emit('input', newValue) }
}
}
}
</script>
のように書くことができる。これを使えばわざわざ冗長に展開された"等価"な処理を書かずとも、厄介な処理を全部v-modelに放り投げることができる。実用的にはこちらが一番だろう。
・Vue3のときは?
前節は、Vue2系のときは、と念押ししておいたが、Vue3系の時でも本質的には同じである。ただし微妙な仕様の変更により、バインドされるイベント・属性が変わっているので、そこだけ抑えればよろしい。
具体的には、Vue2系では(通常のinput要素と同様に):valueと@inputだったものが、:modelValueと@update:modelValue に変更されている。したがって上で掲げたコンポーネントの記述のうちvalueをmodelValueに、inputをupdate:modelValueに置き換えればよいだけである。
なおVue3からはコンポーネントがemitするイベントをemitsオプションで宣言することが推奨されているから、propsの下にemits: ['update:modelValue']を追記しておくとなお良いだろう。なくても動くが。
今回はVue SFC Playgroundで好きに弄って遊べるようにしてみたので興味があれば行ってみて実際に動いているか確認したり改変してみて試してみるとよいだろう。ただしVue3系のみである。一応これと同じソースコードはgistに置いてあるから、ローカル環境で触りたい人はこちらからダウンロードするとよい。
jsは嫌いだがこういう実装を追うのは言語に依らず好きだ。jsは嫌いだが。
