JavaScript
es6
vue.js
Firebase
Firestore

FirestoreとVue.jsで連絡先管理アプリを作ってみる

こんにちは。
最近はVue.js x Firebaseの可能性に惹かれていて、そのあたりをよくウォッチしています。
この記事では最近気になっているCloud Firestoreを使ったサンプルアプリを作ってみたいと思います。

作るもの

連絡先管理アプリです。作る画面(機能)は以下になります。
1. 一覧の表示
2. 連絡先の新規追加
3. 詳細情報表示

スクリーンショット 2017-12-18 20.55.29.png

DB構成

contact_idが並列で並ぶイメージです。

contacts
    ├── contact_id_1
    │   ├── emailaddress
    │   ├── firstname
    │   ├── lastname
    │   ├── phonenumber
    │   └── slug
    └── contact_id_2
        ├── emailaddress
        ├── firstname
        ├── lastname
        ├── phonenumber
        └── slug
    ・
    ・
    ・

開発環境準備

今回は、vue-cliを使って開発をするのでインストールします。

npm install -g vue-cli

vue -V
2.9.1

さっそくvueコマンドを使って、プロジェクトディレクトリを作りましょう。今回はwebpackも使います。また、vue-routerを使用します。

vue init webpack firestoreapp

firestoreappというディレクトリが作成されるので、そこに移動して、以下のコマンドを実行しましょう。

cd firestoreapp
npm install firebase css-loader vue-style-loader sass-loader node-sass --save

firestoreappディレクトリに移動してnpm run devを実行してみてください。http://localhost:8080でサイトを確認できるようになります。

Cloud Firestoreのセットアップ

Firestoreを使うために、Firebase consoleでプロジェクトを作成しましょう。
スクリーンショット 2017-12-18 21.18.05.png

左のサイドバーからDatabaseの項目を選択し、Readltime DatabaseからCloud Firestoreに切り替えます。
switchtofitrestore.png

ルールを書く

DBのルールを書きます。ルールタブに切り替えてください。
dbrule.png

service cloud.firestore {
  match /databases/{database}/documents {
    match /contacts/{contact} {
      allow read: if request.auth == null;
      allow create: if request.auth == null;
      allow update: if request.auth == null;
    }
  }
}

上記のルールでは、一旦セキュリティは無視して誰でも読み書きできるようにしています。

コーディング

コーディングしていきます。
まずは、src/componentsディレクトリ以下にfirebaseConfig.jsfirebaseInit.jsの2つのJavaScriptファイルを作成します。
firebaseConfig.jsには、先程Firebase consoleで作成したプロジェクトの設定情報を持たせます。

firebaseconfig.png

firebaseConfig.js
export default {
  apiKey: 'AIzaSyDr0-Mef6D1RZsD2NoBaPOwordhUW58MyU',
  authDomain: 'contacts-app-dca62.firebaseapp.com',
  databaseURL: 'https://contacts-app-dca62.firebaseio.com',
  projectId: 'contacts-app-dca62',
  storageBucket: 'contacts-app-dca62.appspot.com',
  messagingSenderId: '715354469790'
}

firebaseInit.jsでは、名前の通りFirebaseをイニシャライズします。

firebaseInit.js
import firebase from 'firebase'
import 'firebase/firestore'
import firebaseConfig from './firebaseConfig'
const firebaseApp = firebase.initializeApp(firebaseConfig)
export default firebaseApp.firestore()

上のコードでは、firebaseConfig.jsを元にfirebaseを初期化しています。
これで、firebaseInit.jsをインポートすることで簡単に他のコンポーネントでFirebaseを使用することができるようになりました。

次は、routerの設定を行います。
具体的には、URLに応じて使用するコンポーネントを設定します。
src/router以下にあるindex.jsを編集していきましょう。

index.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from '@/components/Home'
import ViewContact from '@/components/ViewContact'
import NewContact from '@/components/NewContact'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'Home',
      component: Home
    },
    {
      path: '/add',
      name: 'new-contact',
      component: NewContact
    },
    {
      path: '/:person',
      name: 'view-contact',
      component: ViewContact
    }
  ]
})

さて、ついに具体的な機能面の実装に入っていきます。
冒頭で述べた通り今回のサンプルアプリでは一覧の表示(Home)、連絡先の新規追加(NewContact)、詳細情報表示(ViewContact)の3つの画面を作るので、それぞれに対応したコンポーネントを作成していきます。
まずは、連絡先の新規追加(NewContact)のコンポーネントを作ります。
src/components以下でNewContact.vueを開いてください。.vueファイルでは、<template></template><script></script><style></style>の3つのセクションに分けて書きます。セクションごとにコードを載せていきます。
<script></script>セクションでは、JavaScriptを記述できます。

import db from './firebaseInit'
export default {
  name: 'new-contact',
  data () {
    return {
      firstname: null,
      lastname: null,
      emailaddress: null,
      phonenumber: null
    }
  },
  methods: {
    saveContact () {
      db.collection('contacts').add({
        firstname: this.firstname,
        lastname: this.lastname,
        emailaddress: this.emailaddress,
        phonenumber: this.phonenumber,
        slug: this.generateUUID()
      }).then(function (docRef) {
        console.log('Document written with ID: ', docRef.id);
      }).catch(function (error) {
        console.error('Error adding document: ', error);
      });
    },
    generateUUID () {
      let d = new Date().getTime();
      let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
        let r = (d + Math.random() * 16) % 16 | 0
        d = Math.floor(d / 16)
        return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
      });
      return uuid;
    }
  }
}

上記のコードを解説していくと、まず、最初に作成したfirebaseInitdbとしてインポートしています。methodsオブジェクトでは、saveContactというメソッドを定義しています。これは、新規連絡先をFirestoreに保存します。実際に保存を行っているのはdb.collection('contacts').add()です。add()の内部では、HTMLのフォームに記述された値を取得しています。
次は<template></template>を編集し、新規登録のためのフォームを作ります。

<template>
  <section class="container">
    <h1>Add New Contact</h1>
    <form @submit.prevent="saveContact">
      <div class="field">
        <label class="label">First Name</label>
        <div class="control">
          <input class="input" type="text" placeholder="First Name" v-model="firstname" required>
        </div>
      </div>
      <div class="field">
        <label class="label">Last Name</label>
        <div class="control">
          <input class="input" type="text" placeholder="Last Name" v-model="lastname" required>
        </div>
      </div>
      <div class="field">
        <label class="label">Email Address</label>
        <div class="control">
          <input class="input" type="email" placeholder="Email Address" v-model="emailaddress" required>
        </div>
      </div>
      <div class="field">
        <label class="label">Phone Number</label>
        <div class="control">
          <input class="input" type="text" placeholder="Phone Number" v-model="phonenumber" required>
        </div>
      </div>

      <div class="field">
        <div class="control">
          <button type="submit" class="button is-link">Submit</button>
        </div>
      </div>
    </form>
  </section>
</template>

フォームの各々の値は、Vue.jsのv-modelによってdata()関数で定義されたプロパティとバインディングされて(紐付けられて)います。
最後に<style></style>内にCSSを記述していきます。

section {
  height: 100vh;
}
h1 {
  font-size: 30px;
  margin: 30px 0;
}
.input {
  height: 40px;
}

NewContactコンポーネントの完成です。以下が全体のコードになります。

NewContact.vue
<template>
  <section class="container">
    <h1>Add New Contact</h1>
    <form @submit.prevent="saveContact">
      <div class="field">
        <label class="label">First Name</label>
        <div class="control">
          <input class="input" type="text" placeholder="First Name" v-model="firstname" required>
        </div>
      </div>
      <div class="field">
        <label class="label">Last Name</label>
        <div class="control">
          <input class="input" type="text" placeholder="Last Name" v-model="lastname" required>
        </div>
      </div>
      <div class="field">
        <label class="label">Email Address</label>
        <div class="control">
          <input class="input" type="email" placeholder="Email Address" v-model="emailaddress" required>
        </div>
      </div>
      <div class="field">
        <label class="label">Phone Number</label>
        <div class="control">
          <input class="input" type="text" placeholder="Phone Number" v-model="phonenumber" required>
        </div>
      </div>

      <div class="field">
        <div class="control">
          <button type="submit" class="button is-link">Submit</button>
        </div>
      </div>
    </form>
  </section>
</template>

<script>
import db from './firebaseInit'
export default {
  name: 'new-contact',
  data () {
    return {
      firstname: null,
      lastname: null,
      emailaddress: null,
      phonenumber: null
    }
  },
  methods: {
    saveContact () {
      db.collection('contacts').add({
        firstname: this.firstname,
        lastname: this.lastname,
        emailaddress: this.emailaddress,
        phonenumber: this.phonenumber,
        slug: this.generateUUID()
      }).then(function (docRef) {
        console.log('Document written with ID: ', docRef.id);
      }).catch(function (error) {
        console.error('Error adding document: ', error);
      });
    },
    generateUUID () {
      let d = new Date().getTime();
      let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
        let r = (d + Math.random() * 16) % 16 | 0
        d = Math.floor(d / 16)
        return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
      });
      return uuid;
    }
  }
}
</script>

<style scoped>
section {
  height: 100vh;
}
h1 {
  font-size: 30px;
  margin: 30px 0;
}
.input {
  height: 40px;
}
</style>

次は、Homeコンポーネントを作りましょう。

Home.vue
<template>
  <section class="container">
    <div class="columns">
      <div class="column is-8">
        <h1>All Contacts</h1>

        <router-link class="button is-primary" to="/add">Add New Contact</router-link>

        <div class="loader-section" v-if="loading">
          <div class="user-list">
            <div class="columns">
              <div class="column is-8">
                <p class="user-list__header animated-background__header"></p>
                <p class="user-list__sub animated-background__sub"></p>
                <p class="user-list__sub animated-background__sub"></p>
              </div>
              <div class="column is-4 right">
                <router-link class="button is-primary" to="/user">View Person</router-link>
              </div>
            </div>
          </div>

          <div class="user-list">
            <div class="columns">
              <div class="column is-8">
                <p class="user-list__header animated-background__header"></p>
                <p class="user-list__sub animated-background__sub"></p>
                <p class="user-list__sub animated-background__sub"></p>
              </div>
              <div class="column is-4 right">
                <router-link class="button is-primary" to="/user">View Person</router-link>
              </div>
            </div>
          </div>

          <div class="user-list">
            <div class="columns">
              <div class="column is-8">
                <p class="user-list__header animated-background__header"></p>
                <p class="user-list__sub animated-background__sub"></p>
                <p class="user-list__sub animated-background__sub"></p>
              </div>
              <div class="column is-4 right">
                <router-link class="button is-primary" to="/user">View Person</router-link>
              </div>
            </div>
          </div>

          <div class="user-list">
            <div class="columns">
              <div class="column is-8">
                <p class="user-list__header animated-background__header"></p>
                <p class="user-list__sub animated-background__sub"></p>
                <p class="user-list__sub animated-background__sub"></p>
              </div>
              <div class="column is-4 right">
                <router-link class="button is-primary" to="/user">View Person</router-link>
              </div>
            </div>
          </div>
        </div>

        <div class="user-list" v-for="person in contacts">
          <div class="columns">
            <div class="column is-8">
              <p class="user-list__header">{{person.firstname}} {{person.lastname}}</p>
              <div class="inner">
                <div class="left">
                  <p class="user-list__sub"><strong>Email</strong>: {{person.emailaddress}}</p>
                </div>
                <div class="right">
                  <p class="user-list__sub"><strong>Phone Number</strong>: {{person.phonenumber}}</p>
                </div>
              </div>
            </div>
            <div class="column is-4 right">
              <router-link class="button is-primary" v-bind:to="{ name: 'view-contact', params: { person: person.slug }}">View Person</router-link>
            </div>
          </div>
        </div>
      </div>
    </div>
  </section>

</template>

<script>
  import db from './firebaseInit'

  export default {
    name: 'home',
    data () {
      return {
        contacts: [],
        loading: true
      }
    },
    created () {
      db.collection('contacts').get().then((querySnapshot) => {
        this.loading = false
        querySnapshot.forEach((doc) => {
          let data = {
            'id': doc.id,
            'firstname': doc.data().firstname,
            'lastname': doc.data().lastname,
            'emailaddress': doc.data().emailaddress,
            'phonenumber': doc.data().phonenumber,
            'slug': doc.data().slug
          }
          this.contacts.push(data)
        })
      })
    }
  }
</script>

<style lang="scss" scoped>
  h1 {
    font-size: 30px;
    margin: 30px 0;
  }
  .user-list {
    margin-top: 30px;
    background-color: white;
    padding: 20px;
    box-shadow: 0 0 5px 0 rgba(0,0,0,0.05);
    .column {
      height: 120px;
    }
    .inner {
      .left {
        width: 50%;
        float: left;
        text-align: left;
      }
      .right {
        width: 50%;
        float: left;
        text-align: left;
        p {
          width: 100%;
          text-align: left;
        }
      }
    }
    .right {
      display: flex;
      align-items: center;
      justify-content: center;
      button {
        background: #4B75FF;
      }
    }
    .user-list__header {
      font-size: 20px;
      font-weight: 700;
    }
    .user-list__sub {
      font-size: 15px;
      margin-top: 10px;
    }
  }
  @keyframes placeHolderShimmer{
    0%{
      background-position: -468px 0
    }
    100%{
      background-position: 468px 0
    }
  }
  .animated-background__header {
    -webkit-animation-duration: 1s;
    animation-duration: 1s;
    -webkit-animation-fill-mode: forwards;
    animation-fill-mode: forwards;
    -webkit-animation-iteration-count: infinite;
    animation-iteration-count: infinite;
    -webkit-animation-name: placeHolderShimmer;
    animation-name: placeHolderShimmer;
    -webkit-animation-timing-function: linear;
    animation-timing-function: linear;
    background: #f6f7f8;
    background: #eeeeee;
    background: -webkit-gradient(linear, left top, right top, color-stop(8%, #eeeeee), color-stop(18%, #dddddd), color-stop(33%, #eeeeee));
    background: -webkit-linear-gradient(left, #eeeeee 8%, #dddddd 18%, #eeeeee 33%);
    background: linear-gradient(to right, #eeeeee 8%, #dddddd 18%, #eeeeee 33%);
    -webkit-background-size: 800px 104px;
    background-size: 800px 104px;
    height: 20px;
    width: 400px;
    position: relative;
  }
  .animated-background__sub {
    -webkit-animation-duration: 1s;
    animation-duration: 1s;
    -webkit-animation-fill-mode: forwards;
    animation-fill-mode: forwards;
    -webkit-animation-iteration-count: infinite;
    animation-iteration-count: infinite;
    -webkit-animation-name: placeHolderShimmer;
    animation-name: placeHolderShimmer;
    -webkit-animation-timing-function: linear;
    animation-timing-function: linear;
    background: #f6f7f8;
    background: #eeeeee;
    background: -webkit-gradient(linear, left top, right top, color-stop(8%, #eeeeee), color-stop(18%, #dddddd), color-stop(33%, #eeeeee));
    background: -webkit-linear-gradient(left, #eeeeee 8%, #dddddd 18%, #eeeeee 33%);
    background: linear-gradient(to right, #eeeeee 8%, #dddddd 18%, #eeeeee 33%);
    -webkit-background-size: 800px 104px;
    background-size: 800px 104px;
    height: 20px;
    width: 200px;
    position: relative;
  }
</style>

loader-sectionクラスのdevによって↓のようなローディング中ステータスを表現できます。
20171213-224739_capture.gif
v-if="loading"dev.loader-section要素の表示非表示を切り替えています。
:bulb: 条件付きレンダリング — Vue.js
v-for=person in contactsでは、Firestoreから取得した連絡先情報一覧(contacts)をループして、personとして扱っています。
:bulb: リストレンダリング — Vue.js

また、以下の行は詳細情報表示画面へのリンクを表示します。

<router-link class="button is-primary" v-bind:to="{ name: 'view-contact', params: { person: person.slug }}">View Person</router-link>

:bulb: router-link · vue-router
<script></script>セクションでは、Firestoreから全連絡先情報の取得をしています。
created()内のdb.collection('contacts').get()で情報を取ってきています。取得した情報を一件ずつ、data()で定義したcontacts配列プロパティに追加しています。

最後にViewContactコンポーネントを作ります。

ViewContact.vue
<template>
  <section class="container">

    <h1>View Contact</h1>

    <div class="contact--section">
      <p class="__name">{{firstname}} {{lastname}}</p>

      <p>{{emailaddress}}</p>

      <p>{{phonenumber}}</p>

    </div>
  </section>
</template>

<script>
  import db from './firebaseInit'
  export default {
    name: 'view-contact',
    data () {
      return {
        firstname: null,
        lastname: null,
        emailaddress: null,
        phonenumber: null
      }
    },
    beforeRouteEnter (to, from, next) {
      db.collection('contacts').where('slug', '==', to.params.person).get().then((querySnapshot) => {
        querySnapshot.forEach((doc) => {
          next(vm => {
            vm.firstname = doc.data().firstname
            vm.lastname = doc.data().lastname
            vm.emailaddress = doc.data().emailaddress
            vm.phonenumber = doc.data().phonenumber
          })
        })
      })
    },
    watch: {
      '$route': 'fetchData'
    },
    methods: {
      fetchData () {
        db.collection('contacts').where('slug', '==', this.$route.params.person).get().then((querySnapshot) => {
          querySnapshot.forEach((doc) => {
            console.log(doc.id, ' => ', doc.data())
            this.firstname = doc.data().firstname
            this.lastname = doc.data().lastname
            this.emailaddress = doc.data().emailaddress
            this.phonenumber = doc.data().phonenumber
          })
        })
      }
    }
  }
</script>

<style lang="scss" scoped>
  section {
    height: 100vh;
  }
  h1 {
    font-size: 30px;
    margin: 30px 0;
  }
  p {
    margin-bottom: 20px;
  }
  .contact--section {
    background-color: white;
    padding: 20px;
    box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.05);
    .__name {
      font-size: 30px;
    }
  }
</style>

<template></template>セクションでは、data()で定義されているプロパティを表示しています。
<script></script>セクションに目を移してみます。
beforeRouteEnter()ナビゲーションガードと言われるものです。
:bulb: ナビゲーションガード · vue-router

いつナビゲーションがトリガーされようとも、グローバル before ガードは作られた順番で呼び出されます。ガードは非同期に解決されるかもしれません。そしてそのナビゲーションは全てのフックが解決されるまで 未解決状態 として扱われます。

ここではbeforeRouteEnter()内でFirestoreから詳細情報を取得しています。言い換えるならば、詳細情報の取得が完了するまではこのルートに遷移することはできないということです。これは、ユーザーの認証状態のチェックなど、ページを表示する前に事前に何かを行いたいときに非常に役に立つと思います。
今回の場合は、Firestoreからの情報取得成功を保証するためにナビゲーションガードを使用しています。

さて、では実際に詳細情報を取得している箇所を見てみましょう。

db.collection('contacts').where('slug', '==', to.params.person).get().then((querySnapshot) => {
  querySnapshot.forEach((doc) => {
    next(vm => {
      vm.firstname = doc.data().firstname
      vm.lastname = doc.data().lastname
      vm.emailaddress = doc.data().emailaddress
      vm.phonenumber = doc.data().phonenumber
    })
  })
})

SQL、CQLなどを書いたことがある方にはおなじみ、whereを使っていますね(今回はメソッドですが)。where()slug==to.params.personの3つの引数を取っています。slugカラムがto.params.personと等しいという条件を表していますね。
ちなみにこのViewContactコンポーネントの画面URLは例えばhttp://localhost:8080/#/38bc715b-f25d-4d18-9599-bcdf94335d70になります。to.params.person38bc715b-f25d-4d18-9599-bcdf94335d70を取得できます。

最後にindex.htmlを編集してBulma CSS frameworkを読み込むようにしましょう。

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.6.1/css/bulma.min.css">

これでFirestore x Vue.jsサンプルアプリの完成です。

npm run devを実行して、http://localhost:8080を開きましょう!

おつかれさまでした!!

参考リンク