pco2699’s blog

学んだコード・技術について、保存しておく場所

Vue.js + Vue-Material + Firebase でハッカブルな結婚式二次会フォームを爆速で実装する


こんにちは、実はこの度、結婚することになりまして、結婚式をあげる運びとなりました。(パチパチ)
せっかくなので、なにかエンジニアリング的なことを結婚式でやってみようと思いまして
とりあえず 結婚式の二次会受付フォーム を自作することにしました!
Vue.jsとFirebaseで自分でも思ったより爆速に仕上がったので、やりかたをまとめておきます。

つくったもの

f:id:pco2699:20180819192802g:plain

利用技術

  • Vue.js (2.5.2)
  • Vue Material (1.0.0 beta-10.2)
  • Firebase Cloud Firestore
  • Firebase Hosting
  • Firebase Cloud Functions(フォームの結果出力で利用、今回は説明は省略)

Typescriptも利用しようと思ったんですが
Vue.jsとTypescriptはいまのところ相性が良くなさそうで、普通のjsでやることにしました。 (今後のVerUpで良くなりそう。)

つくりかた

vue-cliの導入

なにわともあれ、Vue.jsを使う場合は、vue-cliから作るのが一番、爆速です。 vue-cliはNodeやnpm、yarnなどを利用しますがそこらへんの導入の仕方は、他のページなどを参照してください。
今回、私はyarnを利用して必要なパッケージを導入していきます。

$ yarn global add vue-cli

Vue プロジェクトの作成

先程の vue-cliでVueのProjectを作成しましょう

$ vue init webpack WeddingForm

色々聞かれると思うので、答えてプロジェクトを作りましょう。
ちなみに、Project Nameは大文字×らしいので、wedding_formとかにしておきます。
ESLintとかテストとかは、適当に選んでおきます。 (自分は、全部ONにしましたが、テストとか全く書かなかったので、不要だと思いました。)
最初のパッケージインストールはvue-cli側で勝手にやってくれます。

必要なパッケージ類の導入

できたページに必要なパッケージを導入していきましょう

# firebase, vue-materialなどの導入
$ cd WeddingForm
$ yarn add firebase vue-material
# バリデーション用パッケージ類の導入
$ yarn add vuelidate
# かな自動入力の際に使うパッケージの導入
$ yarn add historykana

とりあえずプロジェクトを立ち上げてみる

これだけで、とりあえずVueのサンプルプロジェクトができたので
早速立ち上げてみましょう。 かっこいいVueのロゴが現れます!

$ yarn run dev

f:id:pco2699:20180818002047p:plain

Vue-materialとかfirebaseを使えるようにする

パッケージを導入しただけだとVue-matrialとかfirebaseが使えないので セットアップしていきましょう。
基本的には[プロジェクトルート]/src/main.jsに設定は記入していきます。

# Vue-materialについて、必要な部品をインポート
import { MdButton, MdToolbar, MdField, MdRadio MdSnackbar, MdProgress } from 'vue-material/dist/components'

# firebase
import firebase from 'firebase/app'
import 'firebase/firestore'

# cssをインポート
import 'vue-material/dist/vue-material.min.css'

# importしたvue-materialの部品を読み込み
Vue.use(MdButton)
Vue.use(MdToolbar)
Vue.use(MdField)
Vue.use(MdRadio)
Vue.use(MdSnackbar)
Vue.use(MdProgress)

今回は、必要な部品のみ読み込むようにしてます。
ここで読み込むことで、全ての.vueファイルのテンプレート部でHTMLのコンポーネントとして上記の部品が使えるようになります。
ただJs上とHTML上だとMdButton -> <md-button></md-button> といった形で、記法が少々、違うので注意。

全部の部品を読み込む方法は↓です。(パフォーマンスが悪くなるのでおすすめしません。)

import Vue from 'vue'
import VueMaterial from 'vue-material'
import 'vue-material/dist/vue-material.min.css'

Vue.use(VueMaterial)

上記の読み込んだMdButtonなどのコンポーネント類の使い方の詳細は↓に載ってます。
App - Vue Material

フォーム部の設定

navbarとか基本的なvue-materialの使い方は、他のページに譲るとして
今回は、一番苦労したフォーム部のを重点的に説明していきます。

基本的な使い方編

src/componentsにWForm.vueのようなvueファイルを作成して、 そこにフォームの基本的なデザインとVueで使うモデルを定義していきましょう。


HTML部(抜粋)

<template>
  <form class="Wform" @submit.prevent="validate">
    <div class="md-layout md-gutter">
      <div class="md-layout-item md-medium-size-50">
        <md-field>
          <label for="first_name"></label>
          <md-input id="first_name" name="first_name" v-model="form.firstName" @input="first_phonetic" @keyup="first_phonetic" required :disabled="sending">
          </md-input>
          <span class="md-error" v-if="!$v.form.firstName.required">必須項目です</span>
        </md-field>
      </div>
       <div class="md-layout-item md-medium-size-50">
        <md-field>
          <label for="first_name"></label>
          <md-input id="last_name" name="last_name" v-model="form.lastName" @input="last_phonetic" @keyup="last_phonetic" required :disabled="sending">
          </md-input>
          <span class="md-error" v-if="!$v.form.lastName.required">必須項目です</span>
       </md-field>
      </div>
    </div>
    <md-button class="md-raised md-primary" type="submit" :disabled="sending">送信</md-button>
    <md-progress-bar md-mode="indeterminate" v-if="sending" />
    <md-snackbar :md-active.sync="userSaved">登録ありがとうございました!</md-snackbar>
  </form>
</template>

以下、コードの詳細です。

横並びレイアウト

<div class="md-layout md-gutter">

今回は姓と名で横並びのレイアウトにしたいので、md-layoutというclassを付与します。
ここらへんはvue-materialのcss内に定義されてるのでBootstrapっぽい要領でレイアウトしてくれます。
md-layoutで横並びのレイアウトのコンテナ的なものを作ります。
md-gutterは横並びのレイアウトのパターンで、横並びに一定の溝(gutter)をつけてレイアウトします。

ここらへんのレイアウトもvue-materialのレイアウトを参考にしてます。

インプット部品

          <md-input id="last_name" name="last_name" v-model="form.lastName" @input="last_phonetic" @keyup="last_phonetic" required :disabled="sending">
          </md-input>

md-inputでテキストを入力する部品を定義出来ます。v-modelで後で定義するモデルと紐付けるようにしましょう。 @input@keyupで姓を入力したときに同時にふりがなを入力する関数と紐付けています。 また、送信中に、フォームの入力等を避けるため、sendingという状態を持っており、sendingがtrueのときはdisabledになるようにします。

送信部分

    <md-button class="md-raised md-primary" type="submit" :disabled="sending">送信</md-button>
    <md-progress-bar md-mode="indeterminate" v-if="sending" />
    <md-snackbar :md-active.sync="userSaved">登録ありがとうございました!</md-snackbar>

ボタンはmd-raisedという属性をつけておくことで、画面から浮いた (raised)のようなデザインにすることができます。
md-progress-barはボタン押下後に、表示されるローディングバーなので、sending状態のみ出るようにします。
md-snackbarは、登録成功時に画面下に「登録されました」と表示されるバーです。
これは:md-actice.sync="userSaved"とつけておき、データが登録されたタイミングでuserSavedをtrueにすることで 本スナックバーが表示されるようにします。


スクリプト部(抜粋)

<script>
import { db } from '../main'

export default {
  name: 'WForm',
  data () {
     return {
       form: {
        lastName: '',
        firstName: '',
        lastNamePhonetic: '',
        firstNamePhonetic: '',
        presence: true,
        message: '',
        contact: ''
       },
       first_history: [],
       last_history: [],
       userSaved: false,
       sending: false
     }
   },
   methods: {
    validate () {
      this.$v.$touch()
      if (!this.$v.$invalid) {
        this.sending = true
        db.collection('presences').add(this.form).then(() => {
          this.userSaved = true
          this.sending = false
          this.clearForm()
        }).catch(() => {
          this.sending = false
        })
      }
    },
    clearForm () {
      this.$v.$reset()
      for (let field in this.form) {
        if (this.form.hasOwnProperty(field)) {
          this.form[field] = ''
        }
      }
    }
   }
}
</script>

以下、コードの詳細です。 フォームに紐づくモデルの設定

  data () {
     return {
       form: {
        lastName: '',
        firstName: '',
        lastNamePhonetic: '',
        firstNamePhonetic: '',
        presence: true,
        message: '',
        contact: ''
       },

フォームで用いるモデルをdataで定義していきます。
dataは関数で返す必要があるのでdata(): {return ...}といった形で記載してます。
(コンポーネントを同ページで何回も利用したときに、値がコピーされずに同じモデルに紐付いちゃうから関数で返す必要がある、という理解です。↓参照)

コンポーネントの基本 — Vue.js

そして各フォームに紐づく、モデルのデータはすべてformの子に定義しておきます。
こうすることで、firebaseの登録時にthis.formだけで、フォームのデータを送信することが出来て、後から項目が増えても、コードに手を入れる部分が減ります。

その他の機能で用いるモデルの定義

       first_history: [],
       last_history: [],
       userSaved: false,
       sending: false

その他のフォームには紐付かないものの、機能として利用するモデルはformの外に定義します。
それぞれ以下のような使い方をしています。

first_history, last_history: かな入力機能で利用(first:姓, last:名で利用、ホントはもっとスマートにできそう...)
userSaved: 情報が保存されたかどうかを示すフラグ
sending: 情報を送信中かどうかを示すフラグ

関数の定義
フォームで必要な関数を定義していきます。

    validate () {
      this.$v.$touch()
      if (!this.$v.$invalid) {
        this.sending = true
        db.collection('presences').add(this.form).then(() => {
          this.userSaved = true
          this.sending = false
          this.clearForm()
        }).catch(() => {
          this.sending = false
        })
      }
    },

validateと書いてますが、validateしてfirebaseに登録する処理を行っています。
送信開始時にsending=trueとして、値が無事に追加される or エラーとなったらsending=falseで値を元に戻します。
db.collection('presences').add(this.form)でfirestoreに値を追加します。
.thenで正常系の処理、.catchでエラー時の処理を記載します。 今回はエラー時はあまり考慮しないので、sendingフラグを元に戻すだけにしてます。(ここらへんはもっと改善したいですね。)

clearForm () {
      this.$v.$reset()
      for (let field in this.form) {
        if (this.form.hasOwnProperty(field)) {
          this.form[field] = ''
        }
      }
}

clearFormで、モデルの状態をリセットして送信後にフォームがリセットされるようにします。
(でないと連打して登録しまくる人がいそうなので...)

かな自動入力編

今回、名前を入力する際に同時にかな入力されるようにしてあります。 主に以下を参考にしつつ実装しました。

qiita.com

HTML部の定義

<md-input id="last_name" name="last_name" v-model="form.lastName" @input="last_phonetic" @keyup="last_phonetic" required :disabled="sending"></md-input>

@input, @keyup時に後述するmethodを呼び出すようにフォーム側で指定します。
前述の記事だと@inputだけつけてるんですが、かながうまく入らなかったので@keyup時にも指定しています。

モデル・スクリプト部の定義

data() {
   return {
      first_history: [],
      last_history: []
   }
}

姓・名それぞれのかなを管理するためのモデルを定義しています。

methods{
    first_phonetic () {
      const input = document.getElementById('first_name').value
      this.first_history.push(input)
      this.form.firstNamePhonetic = historykana(this.first_history)
    },
    last_phonetic() {
      // 上と同じなので省略
    }
}

前述の記事で、 関数の引数からフォームの入力値をとってきているんですが、それがうまくいかなく
document.getElementById('first_name')で無理やり値を持ってきています。
で、とってきた値を上記で定義したモデルにpushしていって、そのhisotryをhistorykanaにつっこめば、かな自動入力ができます。

バリデーション編

フォームなのでバリデーションは欠かせません。今回はvuelidateというVueのバリデーション用ライブラリを使いました。 monterail.github.io

HTML部の定義

        <md-field :class="getValidationClass('firstName')">
          <label for="first_name"></label>
          <md-input id="first_name" name="first_name" v-model="form.firstName" @input="first_phonetic" @keyup="first_phonetic" required :disabled="sending"></md-input>
          <span class="md-error" v-if="!$v.form.firstName.required">必須項目です</span>
        </md-field>

バリデーションでポイントになるのが、md-inputの下の<span>クラスです。
あとで、説明しますが、モデルの$vの下に、バリデーションの結果が入るので、そこをチェックして
falseの場合、バリデーションがエラーなので、エラーとして表示します。

モデル・スクリプト部の実装

  validations: {
    form: {
      lastName: {
        required
      },
      firstName: {
        required
      },
      firstNamePhonetic: {
        required
      },
      lastNamePhonetic: {
        required
      },
      presence: {
        required
      },
      contact: {
        email,
        required
      }
    }

まず、どのフォームのどの部品をバリデーションするかを定義します。
dataとかmethodsと並列で、validationsというモデルを定義すればOKです。

    validate () {
      this.$v.$touch()
      if (!this.$v.$invalid) {
        this.sending = true
        db.collection('presences').add(this.form).then(() => {
          this.userSaved = true
          this.sending = false
          this.clearForm()
        }).catch(() => {
          this.sending = false
        })
      }
    },

上記では、this.$v.$touch()ですべてのフォーム部品をユーザが触った状態にします。
(デフォルト値のままで、ユーザが触ってない部品があったりすると、その時点でバリデーションエラーになってしまうため)
そして、$v.$invalidでフォーム全体のバリデーションがOKかの値が入っているので、これをチェックすることでデータ送信可能か確認します。

テーマ色の設定

最後に、テーマ色が青色で超Googleっぽくて味気ないので別の色に変えておきましょう。
vue-materialのテーマ色をカスタマイズできるようにするには、まずscssを使えるようにする必要があります。

scssを使えるようにする設定
scssを使えるようにするには、webpackの設定を変更します。 build/webpack.base.conf.jsのmodule.rulesに以下を追加します。

  module: {
    rules: [
      // もともと書かれている設定は省略
      {
        test: /\.scss$/,
        use: [
          'vue-style-loader',
          'css-loader',
          'sass-loader'
       ]
    ]
  }

次にutilsで設定されているcssLoaderの設定を変更してscssをコメントアウトします。 build/utils.jsの以下の部分をコメントアウトします。 (ここに気づくのに結構時間がかかった...)

exports.cssLoaders = function (options) {
  //省略
  return {
    css: generateLoaders(),
    postcss: generateLoaders(),
    less: generateLoaders('less'),
    sass: generateLoaders('sass', { indentedSyntax: true }),
    // ↓を省略する!
    // scss: generateLoaders('sass'),
    stylus: generateLoaders('stylus'),
    styl: generateLoaders('stylus')
  }
}

テーマ設定用scssファイルの作成・インポート
こうすることでようやくscssをインポートできるようになります。
では、scssを作成してインポートしましょう。

src/assets/cssというディレクトリを作ってtheme.scssというファイルを以下の内容で作成しましょう

@import "~vue-material/dist/theme/engine"; // Import the theme engine

@include md-register-theme("default", (
  primary: md-get-palette-color(pink, A200), // The primary color of your application
  accent: md-get-palette-color(blue, A200) // The accent or secondary color
));

@import "~vue-material/dist/theme/all"; // Apply the theme

md-register-themeで、primaryやaccentの色を指定してます。
今回は、結婚式らしくメインの色はピンク色、アクセントで青を設定してます。

これをVue.js側で読み込めば完了です。
src/main.jsに以下のimport文を追加します 。

import './assets/css/theme.scss'

Github

github.com

その他

ここまでは実際のものの作り方でしたが、firebase-hostingをつかって実際に本番にデプロイもしたり、functionsを使って、入力された情報をcsvで吐き出したりしています。
このfirebase-hostingが鬼楽で、超絶よかったので、また別記事に纏めたいと思います〜