334
251

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Vue.js:v-modelと$emitを使ってデータを読み書きする子コンポーネントをつくる (2019/10/13追記)

Last updated at Posted at 2018-05-09

追記(2019/10/13)

以下の項を修正・追加しました。

修正

v-modelを使う要素をラップする
今更で恐縮ですが、以前紹介していたものよりもっと簡潔で良いと思われる方法を追記しました。

#####追加
複雑なコンポーネントを薄くラップする


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を使う要素をラップする

2019/10/13 更新

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

これはだめ
<template>
  <input v-model="value" /> // valueはread only! 怒られます
</template>
<script>
export default {
  props: ['value']
}
</script>

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

<template>
  <input 
    :value="value"
    @input="$emit('input', $event.target.value)"
  />  
</template>

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

上記でも触れたとおり、v-model:value@inputの糖衣構文です。それを利用し、ラップしたい子コンポーネントではv-modelを使わずに:value@inputに分解してしまえば大丈夫です。

どうしても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に反映されるわけです。
以前はこちらを紹介していたのですが、internalValueという余分なプロパティができてしまう分、新しく追記した前者の方法を取ったほうが簡潔で良いように思います。

<input /><textarea />等のフォーム要素をラップする場合の留意点

<input /><textarea />inputイベントはEventオブジェクトを$emitします。そのためv-model内部では入力値を取得するのに$event.target.valueが呼ばれています。
以上のようにネイティブの要素に使われるv-modelの実装は要素ごとに少しずつ挙動が違うので、分解して使いたい場合はこちら(フォーム入力バインディング#基本的な使い方)をチェックしてみてください。
カスタムコンポーネントにv-modelを使っていた場合は$emitされたものがそのまま渡されますので、@input="$emit('input', $event)"だけで大丈夫です。


// <input />の場合
<input v-model="text" /> // 糖衣構文
<input :value="text" @input="text = $event.target.value" /> // 展開後

// カスタムコンポーネントの場合
<custom-component v-model="text"/> // 糖衣構文
<input :value="text" @input="text = $event" /> // 展開後

参考: https://jp.vuejs.org/v2/guide/components.html#コンポーネントで-v-model-を使う

複雑なコンポーネントを薄くラップする

2019/10/13 追加

Vue 2.4.0+の話です。
外部Vueライブラリを使っている場合、提供されるコンポーネントに少し機能を追加したり、挙動を修正したいことがあります。
その際、コンポーネントの挙動ををすべて再定義するのは避けたいですし、将来的に外部コンポーネントの仕様に追加・変更があったときにとても困ります。

そこで2.4.0で追加された、$attrs$listenersというインスタンスプロパティが便利です。
https://jp.vuejs.org/v2/api/#vm-attrs
https://jp.vuejs.org/v2/api/#vm-listeners

それぞれには以下が収納されます。

  • $attrs
    • コンポーネントに渡されるバインディング(v-modelv-bind)
    • HTML属性(srcplaceholderなど)
      • ただし、classstyleは含まれません!
  • $listeners
    • イベントリスナ(@click, @hoverなど)

これを利用し、以下のようにラッパーコンポーネントを定義できます。

CustomQInput.vue
// カスタムコンポーネント CustomQInput

// 属性とリスナが格納されている$attrsと$listenersをそのまま渡します
<template>
  <q-input v-bind="$attrs" v-on="$listeners"/>
</template>
こうやって使えます
<custom-q-input placeholder="ここに文章" v-model="textData"/>
// これが$attrsと$listenersによって以下のように展開されます
<q-input placeholder="ここに文章" v-model="textData"/>

$attrs$listenersはただのオブジェクトですので、渡す前にcomputedプロパティを使って中身を操作することも可能です。

また、inheritAttrsというプロパティを利用して親からの属性継承を禁止し、再度$attrs$listenersを利用することでルート要素以外を継承先に指定することもできます。(詳しくはこちら

注意 ラップするコンポーネントにラップされるコンポーネントと同名のpropsが定義されていると$attrsに格納されず、上手く受け渡されないので注意が必要です。

.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が足されたという経緯のようで、__本質的には同じもの__のようです。

334
251
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
334
251

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?