[Vue.js] カスタムコンポーネントで v-model を使えるのを知って幸せになれた話

作成日: 2020-01-26 /

TL;DR

  1. 自分が実装したカスタム(子)コンポーネントに v-model を書き、データの双方向データバイディングができる。
  2. 基本的にはデフォルトでは、そのカスタム(子)コンポーネントで、 value のキーの props でデータを受け取る。inputのイベント名で変更したいデータを emitすれば、親のほうで v-modelで渡しているデータが更新される。
  3. そのカスタム(子)コンポーネントで、props のキーだったり、イベント名を変更したい場合は、model プロパティに変更したいデータのプロパティと変更したいときの使うカスタムイベント名を定義する。

はじめに

メディア運営会社のエンジニアです。メディアコンテンツの入稿ツール(以下: ダッシュボード)を Vue.js(Nuxt.js) で開発しているとき、子コンポーネントを使用するときに、双方向データバイディングについて悩むことがありました。 いい方法をググっていたところ、自分が実装したカスタムコンポーネントでも v-model を使えることを知り開発が楽になったので、学んだことをここに書きます。

学ぶ前と学んだ後でどれだけコードが変わるのかの before / after も書きます。UI フレームワークは vuetify を使用しています。

そもそも v-model って何について

はじめに v-model についておさらいすると、双方向データバインディングとして紹介されています。具体的な使用例としては、メールアドレスやチェックボックスなどにあるフォームが多いです。下だと input で書いた内容がそのまま email に反映されます。

公式のドキュメントによると、v-modelvalue プロパティを通して、inputtextareaselect 要素にデータを渡します。一方でそれぞれの要素が発行する inputchange イベントを通して value の props 渡したデータを更新しているというものです。

v-model-to-input.vue
<template>
  <div class="example-form">
    <!-- 下2つは同じこと -->
    <input v-model="email" type="text" />
    <input :value="email" @input="email = $event.target.value" type="text">

    <p> {{ email }} <p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      email: '',
    }
  },
}
</script>

さらに、公式の v-model を使ったコンポーネントのカスタマイズを読むと、デフォルトでは v-modelvalue をプロパティとして、input をイベントして使います とあります。

言い換えると、inputtextarea 要素も 1 つのコンポーネントと、value プロパティでデータをもらったり、input イベント経由でもらったデータを仮定します。どんなコンポーネントでも同じこと、つまり valueprops でデータをもらい、 input イベントでもらったデータを更新すると、 v-model が使えます。

下の例だと custom-input は、更新するデータを value で受け取り、データの更新時に input イベントを発行すれば、呼び出し側で v-model を使用できます。

custom-input-wrapper.vue
<custom-input v-model="searchText" />
customer-input.vue
<template>
  <input
    :value="value"
    @input="$emit('input', $event.target.value)"
  >
</template>

<script>
export default {
  name: 'custom-input',
  props: ['value'],
}
</script>

でも、v-model を使用するのに、別のプロパティだったり、違うイベント名を使いたいケースが考えられます。 そんなときは、v-model を使ったコンポーネントのカスタマイズにあるように、model プロパティを使用すれば大丈夫です。

下の例だと、デフォルトの value の代わりに checked が使用され、input の代わりに change が使用されています。

base-checkbox-wrapper.vue
<base-checkbox v-model="lovingVue"></base-checkbox>
base-checkbox.vue
<template>
  <input
    type="checkbox"
    :checked="checked"
    @change="$emit('change', $event.target.checked)"
  >
</template>

<script>
export default {
  model: {
    prop: 'checked',
    event: 'change'
  },

  props: {
    checked: Boolean,
  },
}
</script>

色々と書きましたが、実務でどう役に立ったかを書いていきます。

実装するカスタムコンポーネント

下のダイヤログになります。

12

Before

components/molecules/r-custom-dialog.vue
<template>
  <v-dialog :value="_dialog" max-width="500" @click:outside="closeDialog">
    <v-card>
      <v-card-title class="headline grey lighten-2 text-center" primary-title>
        v-model
      </v-card-title>

      <v-card-actions>
        <v-btn color="accent" text @click="closeDialog">閉じる</v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

<script>
export default {
  props: {
    dialog: {
      type: Boolean,
      default: false,
    },
  },

  computed: {
    _dialog: {
      get() {
        return this.dialog
      },

      set(val) {
        this.$emit('close:dialog')
      },
    },
  },

  methods: {
    closeDialog() {
      this._dialog = false
    },
  },
}
</script>
pages/model-examples/index.vue
<template>
  <v-container fill-height>
    <v-layout align-center>
      <v-flex class="text-center">
        <r-custom-dialog :dialog="dialog" @close:dialog="closeDialog()" />

        <v-btn color="primary" @click="openDialog">CLICK ME</v-btn>
      </v-flex>
    </v-layout>
  </v-container>
</template>

<script>
import RCustomDialog from '~/components/page-organisms/articles/r-custom-dialog.vue'

export default {
  components: {
    RCustomDialog,
  },

  data() {
    return {
      dialog: false,
    }
  },

  methods: {
    openDialog() {
      this.dialog = true
    },

    closeDialog() {
      this.dialog = false
    },
  },
}
</script>

Vue.js では親から props でもらったデータを子コンポーネント内で変更したときにエラーが出てきます。 エラーを回避するために、子コンポーネントの computed 内で props に代わるプロパティを用意し、その代用されたプロパティに対して変更を加えています。そして、そのプロパティの内容が変更されたときに、カスタムイベントを発火するようにしています。

改善したいなと感じたのが、親が子に渡したデータを変更していることと子のほうで代用のプロパティを用意していることです。 親と子でデータを無理やり同期しているように感じるのと、子のほうでわざわざ gettersetter を用意するのは手間がかかるなと思いました。

そんな中、カスタムコンポーネントで v-model が使えるとわかり、コードを書き換えると、

After

components/molecules/r-custom-dialog.vue
<template>
  <v-dialog :value="dialog" max-width="500" @click:outside="closeDialog">
    <v-card>
      <v-card-actions>
        <v-btn color="accent" text @click="closeDialog">
          閉じる
        </v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

<script>
export default {
  model: {
    prop: 'dialog',
    event: 'change-dialog',
  },

  props: {
    dialog: {
      type: Boolean,
      default: false,
    },
  },

  methods: {
    closeDialog() {
      this.$emit('change-dialog', false)
    },
  },
}

/* デフォルトだとこう書く。
export default {
  props: {
    value: {
      type: Boolean,
      required: true,
    },
  },

  methods: {
    closeDialog() {
      this.$emit('input', false)
    },
  },
}
*/
</script>
pages/model-examples/index.vue
<template>
  <div class="model-examples-index">
     <r-custom-dialog v-model="dialog" />

     <v-btn @click="openDialog()" />
  </div>
</template>

<script>
import RCustomDialog from '~/components/page-organisms/articles/r-custom-dialog.vue'

export default {
  data() {
    return {
      dialog: false,
    }
  },

  methods: {
    openDialog() {
      this.dialog = true
    },
  },
}

</script>

まさにこんなのが欲しかったなと思いました。双方向データバイディングができて、ただただ変更が加わったデータを扱えばよくなりました。子のほうで、余分な computed も用意しなくてもいいのですっきりしました。

ポイントを以下にまとめると、

  1. model プロパティに親から渡されるデータの名前を prop に、変更が加わるときのイベント名を change-dialog にする。
  2. しっかりと model プロパティの prop に指定するデータpropsの中で定義されていること。

まとめ

カスタムコンポーネントでv-model が書ける話をまとめました。これを知ってからは、無駄なcomputedgettersetter を書かなくても大丈夫ですし、親と子でしっかり双方向データバインディングができているので、すっきり書けました。