Vue.js:v-modelと$emitを使ってデータを読み書きする子コンポーネントをつくる

Vue 2.2.0+の話です。

最近仕事でVueを使い始めました。フレームワークには珍しくブラックボックス感があまり無く、ツールを介しながら直接DOMやJSを扱っているような見通しの良さと自由度があり、今のところ気に入っています。あと公式のドキュメントがすごく読みやすく情報豊富…なので大抵のことは公式ガイドが解決・解説してくれるのですが、実際扱うまでピンと来なかった親子間のデータ受け渡しについて書き留めておきます。
公式のこちらに当たる内容です。

親子間でデータ受け渡し

さてVueの鉄則の一つとして親子間でデータを受け渡ししたい時:
- 親→子はpropsを介して渡す
- 子→親はイベントを$emitする
というのがあります。
例えばこれに反して子からpropsで渡された値を更新しようとすると怒られます(道を踏み外していると叱ってくれるのが良いです)。子が自由に渡された値をなんでもかんでも編集できてしまうと、どこでなぜ値が変化したのか辿るのが大変になるからということのようです。

アンチパターン
let child = {
  props: ['fromParent'],
  methods: {
    someMethod () {
      this.fromParent = 'new value' // 怒られる!
    }
  }
}

でもイベントでいちいちデータ更新ってピンとこない

というのが最初の感想でした。子から親のデータを操作したい度にいちいちイベントを書かなきゃいけないのは面倒くさいな〜と…。配列内の要素毎に値を操作できるカスタムコンポーネントを割り当てたい時とかどうすればいいのかな、まさかイベントにindexとデータを載せるのではあるまいな?などと思っていました。v-modelのあるinput系要素ならかんたんなのに・・・ん?v-modelって何だ?

v-modelって何だ?

公式で以前読んだはずなのにすっかり忘れていたのですが、v-modelは実はただの糖衣構文。:value(prop)と@input(event)に展開して扱われます。

糖衣構文
<syntax-sugar v-model="cup"></syntax-sugar>
<syntax-sugar :value="cup" @input="val => cup = val"></syntax-sugar>

v-modelは一見双方向バインディングしているようで、実際はVueルールに則ってpropsで親から値を渡しeventで子から値を更新しているわけですね。
一般的な要素でv-modelが使えるのはinputやtextareaなどに限られるのですが、このv-model、自作コンポーネントでも大いに活用することができます。

v-modelをカスタムコンポーネントで使おう/valueとinputがキーワード

v-model="cup"をカスタムコンポーネントに使うと、自動的に:value="cup" @input="val => cup = valに展開されます。
つまり、
- v-modelで渡した値がコンポーネントのvalueに単方向バインドされる
- コンポーネントから$emit('input', newValue)を呼ぶとv-modelの値が更新される
- v-modelが更新されると、単方向バインドされているvalueも更新される
という流れになります。

const child = {
  props: ['value'], // v-modelが単方向バインドされる
  methods: {
    updateModel (newValue) {
      // inputイベントによってv-modelが更新され、するとバインドされているthis.valueも更新される。
      this.$emit('input', newValue) 
    }
  }
}

値を変更したいタイミングでinputイベントを$emitします。このときemitする値は自由なので、書き出し値の操作も容易です。

また、v-modelは糖衣構文にすぎないので特にv-modelを使うよ、という宣言は必要ないです。
ただし、このpropsとしてのvalueとeventとしてのinputはv-modelを使う際実質予約キーワードとなっています。なおこのキーワードはmodel: { prop: '<プロパティ名>', event: '<イベント名>'}オプションを使って変更することも可能です。

v-modelを使う要素をラップする

巨大なコンポーネントや再利用できそうな部品はパーツ分けしたくなりますよね。その時、パーツ分けしたいコンポーネントがv-modelを扱っている時はどうラップすればよいか?

これはだめ
<template>
  <input v-model="value" />  
</template>
<script>
export default {
  props: ['value']
}
</script>

valueはあくまで親からのpropsでread onlyなので、そのままネスト要素のv-modelに渡すことはできません。ということで

ワンクッションおく
<template>
  <input v-model="internalValue" />  
</template>

<script>
export default {
  props: ['value'],
  computed: {
    internalValue: {
      get () {
        return this.value
      },
      set (newVal) {
        if (this.value !== newVal) this.$emit('input', newVal)
      }
    }
  }
}
</script>

このようにcomputedとそのget/setを利用して仲介してやるとうまくいきます。internalValueが読み出されるときはthis.value(=親からのv-model)を返し、internalValueに書き込まれるときはinputイベントで親に通達できるわけです。そしてv-modelの変更はちゃんとvalueとinternalValueに反映されるわけです。Voila!

.syncって何?

Vue 2.3.0+の話です。
2.3.0+から追加された.syncという構文があり、v-modelとほぼおなじ働きをします。詳細
例によってこれも糖衣構文で、以下のように展開して扱われます。

<syntax-sugar :cup.sync="mag"></syntax-sugar>
<syntax-sugar :cup="mag" @update:cup="val => mag = val"></syntax-sugar>

.syncを使うことで指定のプロパティに対してv-modelと似たような挙動を与えることができます。update:プロパティ名が値更新用のイベント名です。

v-modelとの違いはあるの?

こちらのコア開発メンバーによる質問回答を見るに、複数のプロパティをv-modelのような使い方をしたいという要望に答えて.syncが足されたという経緯のようで、本質的には同じもののようです。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.