2020年2月29日に投稿

【Vue.js】Vuetifyのv-dialogの再利用性を高める

皆さんVuetify使ってますか?
便利な上にかっこいいUIが作れるので最近私はハマっています。
今日はそんなVuetifyの<v-dialog>に焦点を当てていきたいと思います。

v-dialogは長い

<v-dialog>を使ったことがある人ならご存知だと思いますが、これを実装しようとするとコードがかなり長くなります。
以下公式のドキュメントからの抜粋です。

<template>
  <v-row justify="center">
    <v-btn
      color="primary"
      dark
      @click.stop="dialog = true"
    >
      Open Dialog
    </v-btn>

    <v-dialog
      v-model="dialog"
      max-width="290"
    >
      <v-card>
        <v-card-title class="headline">Use Google's location service?</v-card-title>

        <v-card-text>
          Let Google help apps determine location. This means sending anonymous location data to Google, even when no apps are running.
        </v-card-text>

        <v-card-actions>
          <v-spacer></v-spacer>

          <v-btn
            color="green darken-1"
            text
            @click="dialog = false"
          >
            Disagree
          </v-btn>

          <v-btn
            color="green darken-1"
            text
            @click="dialog = false"
          >
            Agree
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </v-row>
</template>

これで出来上がるのは一つのボタンと、それを押した時に表示されるダイアログです。
もう一度言います。画面上の一つのボタンでこの長さのコードが必要です。

Vue.jsを使ったことがある人ならきっと大多数の人がこれをコンポーネント化したいと思うでしょう。(思いますよね?)
では次にこれをコンポーネントにする方法について検討していきます。

v-dialogをコンポーネント化する

今回はページ上にフォームを用意して、その内容をdialog上で編集するという機能を例に考えていきたいと思います。
完成イメージは以下の通りです。「開く」ボタンを押すとダイアログが表示されます。
スクリーンショット 2020-02-29 11.25.30

ダイアログ上のフォームに入力した値がページ側に反映されるという仕組みです。
まずはpageコンポーネント上に直接実装してみましょう。

pages/index.vue

<template>
  <div>
    <v-btn color="success" @click.stop="dialog = true">
      開く
    </v-btn>
    <v-dialog v-model="dialog">
      <v-card>
        <v-card-title>Component Title</v-card-title>
        <v-card-text>
          <v-container>
            <v-row>
              <v-col cols="12">
                <v-text-field label="Name" v-model="inputName" required></v-text-field>
              </v-col>
              <v-col cols="12">
                <v-text-field label="E-mail" v-model="inputEmail" required></v-text-field>
              </v-col>
            </v-row>
          </v-container>
        </v-card-text>
        <v-card-actions>
          <v-spacer></v-spacer>
          <v-btn text color="success" @click="onSubmit">
            OK
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
    <v-text-field v-model="name" label="Name" disabled></v-text-field>
    <v-text-field v-model="email" label="E-mail" disabled></v-text-field>
  </div>
</template>

<script>
export default {
  data() {
    return {
      name: '',
      email: '',
      inputName: '',
      inputEmail: '',
      dialog: false
    }
  },
  methods: {
    onSubmit() {
      this.dialog =false
      this.name = this.inputName
      this.email = this.inputEmail
    }
  }
}
</script>

愚直に書くとこんな感じでしょうか。
このコードの課題はダイアログの中身を直書きしているところです。なので<v-card></v-card>の部分をコンポーネント化していきます。
ここでポイントになるのは以下の点です。

  • ダイアログの開閉状態は親であるページコンポーネントで管理する
  • コンポーネント化したダイアログの中身は出来るだけ疎結合にする

では実装していきましょう。

components/DialogCard.vue

<template>
  <v-card>
    <v-card-title>Component Title</v-card-title>
    <v-card-text>
      <v-container>
        <v-row>
          <v-col cols="12">
            <v-text-field label="Name" v-model="returnData.name" required></v-text-field>
          </v-col>
          <v-col cols="12">
            <v-text-field label="E-mail" v-model="returnData.email" required></v-text-field>
          </v-col>
        </v-row>
      </v-container>
    </v-card-text>
    <v-card-actions>
      <v-spacer></v-spacer>
      <v-btn text color="success" @click="submit">
        OK
      </v-btn>
    </v-card-actions>
  </v-card>
</template>

<script>
export default {
  props: {
    name: '',
    email: ''
  },
  data() {
    return {
      returnData: {
        name: this.name,
        email: this.email
      }
    }
  },
  methods: {
    submit() {
      this.$emit('clickSubmit', this.returnData)
    }
  }
}
</script>

続いて親側のページコンポーネントです。

pages/index.vue

<template>
  <div>
    <v-btn color="success" @click.stop="dialog = true">
      開く
    </v-btn>
    <v-dialog v-model="dialog">
      <dialog-card
        v-on:clickSubmit="onSubmit"
        :name="name"
        :email="email"
      ></dialog-card>
    </v-dialog>
    <v-text-field v-model="name" label="Name" disabled></v-text-field>
    <v-text-field v-model="email" label="E-mail" disabled></v-text-field>
  </div>
</template>

<script>
import DialogCard from '@/components/DialogCard'

export default {
  components: {
    DialogCard
  },
  data() {
    return {
      name: '',
      email: '',
      dialog: false
    }
  },
  methods: {
    onSubmit(params) {
      this.dialog =false
      this.name = params.name
      this.email = params.email
    }
  }
}
</script>

<style>

</style>

まずはダイアログの中身のコンポーネントについて見ていきます。ポイントをまとめてみましょう。

  • propsに親側のnameemailの値を渡している
  • 親側のnameemailの値をダイアログ側の変数の初期値に設定している
  • 「OK」ボタンをクリックした時に引数と共にイベントの発火だけを親側に伝えている

親側でダイアログの開閉状態を保持しているので、ダイアログ側では一切関与する必要はありません。
親側は、ただボタンが押されたかどうかとフォームの値だけ分かればOKですね。

続いて親側のコンポーネントも見ていきます。

  • v-onでダイアログ側のイベント発火をlistenしている
  • イベントが発火したらdialog = falseとしてダイアログを閉じる
  • ダイアログ側のフォームの値を取り出して反映する

特に難しいことは何もしていません。これでダイアログの中身だけ完全にコンポーネント化できました。
そしてこの形で実装することで何が嬉しいのかというと、画面によってダイアログの中身を切り替えることが容易になることです。
<dialog-card></dialog-card>のコンポーネントを別のものに置き換えて、必要な変数をバインドしてあげるだけで様々なダイアログに対応できます。
今回はダイアログ側から値を受け取って処理をしていますが、単純に「確認ダイアログ」のようにボタンだけしかない場合はボタンクリックのイベントだけ拾ってあげればいいので更に簡単に実装できます。

別の方法としてslotを使ったやり方などもあるのですが、ちょっと複雑になるので、コンポーネントの数が増えることが気にならない場合は、ダイアログの数だけコンポーネントを用意して切り替えて使う方法が良いかなと思います。

おまけ

本題とはあまり関係ありませんが、propsに値を渡すときの注意点を少しだけ。

Javascriptのオブジェクトと配列は参照渡しになるので親と子のプロパティの間で単方向のバインディングは成り立ちません
つまり親から渡したプロパティが子によって書き換えられてしまいます
今回の例でもそれを避けるために親側のデータはnameemailとしてそれぞれ定義しています。
仮にこれを

data () {
  return {
    inputData: {
      name: '',
      email: ''
    }
  }
}

のようにオブジェクトとしてしまうと、ダイアログ側のフォーム値と結びついて文字を入力した瞬間(inputイベントが発火したとき)に親側のデータに反映されてしまいます。
興味のある方は試してみてください。
場合によっては予期せぬ動きになってしまうので、参照渡しであることは頭の片隅に置いておきましょう・・・。

今日は以上です!