Nuxt.js + Firebaseに入門してみた(その2)

前置き

こんにちわ。神戸です。

前回に引き続き、雑な家計簿(ZATSUBO)をNuxt.jsとFirebaseで作成していきます。

blog.engineer.adways.net

流れ

全体的な流れ

  1. Nuxtでベースプロジェクトの作成
  2. Firebaseにデプロイ
  3. 認証処理の追加
  4. DBの追加
  5. 少し手を加える

今回は、3〜5を実施して行きます。

主な内容は認証処理の導入・DBの導入となります。

前回の修正

Nuxt.jsのインストールからスタートアップの起動まで

についてなのですが、Nuxtの日本語のドキュメントのバージョンが古く(2018年10月15日現在)、1.4.0の記述がありました。
現行は2.1.0なので、英語のドキュメントを読み漁ったほうが良いみたいです。

導入に関しては、むしろ簡易的になりました。

npxでプロジェクトを立ち上げていますが、
yarnがあればyarnを勧めています。

# vue-cliのインストール
npm install -g @vue/cli @vue/cli-init

# Nuxtのスタートテンプレートでプロジェクトを作成。(<project-name>はzatsuboにしました)
npx create-nuxt-app <project-name>

#- 前回同様プロジェクトの名前や必要なモジュールをインストールするか聞かれます。
#- 途中、ユニバーサルアプリかSPAかどうかを聞かるので、SPAを選択します。
#- あとは、ほとんどEnterを入力すれば、ベースが出来上がります。(今回は簡易的な物なので、本格的に導入する場合はテストやフォーマッターの導入を推奨しています)

認証処理:Authenticationの導入

f:id:AdwaysEngineerBlog:20181101173308p:plain

Authenticationを設定する場合、FireBaseのモジュールが必要になるので、事前に取得しておきます。
(もし余裕がある場合は、firebaseuiの導入を検討ください!今回は時間の関係上省いています)
https://firebase.google.com/docs/auth/web/firebaseui?hl=ja

npm install -s firebase

Firebaseのコンソールで、

1. 認証方式の設定
2. 認証情報の取得

を行います。
認証方式の設定は、Firebaseのコンソール上でGoogle認証を選択して有効にしておきます。

f:id:AdwaysEngineerBlog:20181101173514p:plain

認証設定のポップアップを表示させ、内容を確認(コピー)を行い、プロジェクトに貼り付けて認証機能を有効にします。

f:id:AdwaysEngineerBlog:20181101173542p:plain

個人的に貼り付ける先は、FirebaseのAPIのキーをPagesのindex.vueにそのまま貼り付ける形にしていたのですが、
基本的には外に出している記事が多かったので、Pluginsディレクトリにfirebase.jsを作成し、nuxt.config.jsの設定を変更します。

@/plugins/firebase.js

import firebase from 'firebase'

if (!firebase.apps.length) {
    firebase.initializeApp({
      /* 認証情報を貼り付ける箇所 */
        apiKey: "",
        authDomain: "",
        databaseURL: "",
        projectId: "",
        storageBucket: "",
        messagingSenderId: ""
    })
  }
  
  export default firebase

nuxt.config.js

  plugins: [
    {src: '~/plugins/firebase.js', ssr: false},
  ],

準備が整ったので、実際に認証機能を使ってログインするような実装をLogo.vueに実装していきます。

@/components/Logo.vue

<template>
  <div class="certification">
    <div class="links">
      <button v-if="!isLogin" @click="googleLogin">google Login</button>
      <button v-if="isLogin" @click="googleLogout">Logout</button>
      {{userData}}
    </div>
  </div>
</template>

<script>

import firebase from '@/plugins/firebase'

export default {
  name:'init',
  data: function(){
    return{
        isLogin: false,
        userData: ''
    }
  },
  methods: {
    googleLogin: function() {
      firebase.auth().signInWithRedirect(new firebase.auth.GoogleAuthProvider())
    },
    googleLogout: function() {
      firebase.auth().signOut()
    }
  },
  mounted: function() {
    firebase.auth().onAuthStateChanged(user => {
      console.log(user)
      if (user) {
        this.isLogin = true
        this.userData = user
      } else {
        this.isLogin = false
        this.userData = null
      };
    });
  },
}
</script>

<style>
.certification {
  display: inline-block;
  position: relative;
  overflow: hidden;
  height: 180px;
  width: 245px;
}
.links {
  padding-top: 15px;
}
</style>

ここで一度npm run buildでビルドし、firebase serveでローカルの動作を確認します。
(必要な場合Firebaseのエイリアスの変更などは適宜行ってください。)
ログインを押すと、Googleアカウントでの認証作業が始まり、数秒後、認証情報が画面に表示されると思います。
(色々表示されるので、カレーで隠しています)

f:id:AdwaysEngineerBlog:20181101173932p:plain

やや動作にラグがあると思いますが、コレで管理画面とかの実装が可能になりました。
(本来ならfirebaseuiが補完してくれます。)

DB(Cloud Firestore)の追加

f:id:AdwaysEngineerBlog:20181101174008p:plain

FirebaseのDBに関しては、二種類存在し、

  1. Realtime Database
  2. Cloud Firestore

どちらもNoSQLとなっています。

それぞれの特徴として、
https://firebase.google.com/docs/database/rtdb-vs-firestore

  1. iOS と Android のみのモバイルクライアント向け。
    データを 1 つの大きな JSON ツリーとして保存します。
    スケーリングにはシャーディング(パーティショニング)が必要です。

  2. iOS、Android、ウェブクライアント向け。
    現在(2018年10月)ベータ版でリリースされています。
    複合型の並べ替えとフィルタリング機能を備えたインデックス付きクエリで情報をやり取りします。
    スケーリングは自動的に行われます。

今回は、ちょっと挑戦して、ベータ版のCloud Firestoreを使って見たいと思います。

まず、Firebaseのコンソールに入り、ルールの変更を行います。

今回使用する簡易的なセキュリティルール

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.auth != null;
    }
  }
}

Cloud Firestoreに追加するデータは、

  • コレクション
  • ドキュメント

に分かれています。

コレクションがテーブル。ドキュメントがレコードのキーという認識です。
コレクションの中に複数のドキュメントが入る形です

呼び出すメソッドは、データを取り扱う上で主なメソッドは4つで

  • add (単一のドキュメントを作成または上書きすし、ID を自動的に生成する)
  • set (単一のドキュメントを作成または上書きする)
  • get (select)
  • delete (delete)

add(...) と .doc().set(...) は同等らしいので、どちらでも大丈夫だそうです。

リアルタイムに監視を行う場合は、

onSnapshot()
関数を用いたりします。
https://firebase.google.com/docs/firestore/query-data/listen
上記の説明は申し訳ございませんが割愛させていただきます。

下記が実装(コード)になるのですが、API叩いているのでコード内の
let self = this
に注意してください

実装は下記2つのメソッドで、ユーザーの情報を元にコレクションを選択して、金額の情報を取得しています。

  • dbGetOrInit
    • やや複雑になっていますが、ユーザーがいない場合といる場合で、初期化を行うかデータを取得するかを行っています。
  • dbUpdate
    • 入力された金額に応じてDBの更新を行っています
...
methods: {
    googleLogin: function() {
      loading: true
      firebase.auth().signInWithRedirect(new firebase.auth.GoogleAuthProvider())
    },
    googleLogout: function() {
      firebase.auth().signOut()
    },
    dbGetOrInit: function(userName) {
      let self = this
      db.collection("users").doc(userName).get()
      .then(function(data){
        if(data.exists){
          self.money = parseInt(data.data().money)
          self.loading = false
        }else{
          db.collection("users").doc(userName).set({
            money: 0
          })
          .then(function(docRef) {
            console.log("addUser")
          })
          .catch(function(error) {
            console.error("Error: ", error)
          })
        }
      })
    },
    dbUpdate: function(userName)  {
      this.slideValue = 0
      db.collection("users").doc(userName).set({
        money: this.money
      })
      .then(function(docRef) {
          console.log("Document written")
      })
      .catch(function(error) {
          console.error("Error:", error)
      })
    }
    ...

上記をボタンやフォームに対して、動作させるようにテンプレートを記述してmoneyを表示させてみます。

f:id:AdwaysEngineerBlog:20181101174244p:plain

少し手を加える

少し手を加えていきます。
正直に申しますと、ほとんどの内容が別のブログや記事に記述されていることなので、
参考になることは余りありません。

なので、実際にアプリケーション(ZATSUBO)として確立させていきます。

まずIndex.vueとLogo.vueを統合して必要な部分のみにします。
(そもそも何故分けていたのかは不明です…。)

ものすごいシンプルになりました。

index.vue

<template>
  <section class="container">
    <div class="links">
      <button v-if="!isLogin" @click="googleLogin">google Login</button>
      <div v-if="isLogin">
        <h1>{{money}}</h1>
        <input type="number" v-model="money"/>
        <button @click="dbUpdate(userData.displayName)">
          update
        </button>
        <div>
          {{userData.displayName}}
        </div>
        <div>
          <button @click="googleLogout">Logout</button>
        </div>
      </div>
    </div>
  </section>
</template>

<script>

import firebase from '@/plugins/firebase'
const db = firebase.firestore()
const settings = {timestampsInSnapshots: true}
db.settings(settings)

export default {
  name:'init',
  data: function(){
    return{
      isLogin: false,
      money: 0,
      userData: ''
    }
  },
  mounted: function() {
    firebase.auth().onAuthStateChanged(user => {
      if (user) {
        this.isLogin = true
        this.userData = user
        this.money = this.dbGetOrInit(this.userData.displayName)
      } else {
        this.isLogin = false
        this.userData = null
      }
    })
  },
  methods: {
    googleLogin: function() {
      firebase.auth().signInWithRedirect(new firebase.auth.GoogleAuthProvider())
    },
    googleLogout: function() {
      firebase.auth().signOut()
    },
    dbGetOrInit: function(userName) {
      let self = this
      db.collection("users").doc(userName).get()
      .then(function(data){
        if(data.exists){
          self.money = parseInt(data.data().money)
        }else{
          db.collection("users").doc(userName).set({
            money: 0
          })
          .then(function(docRef) {
            console.log("addUser")
          })
          .catch(function(error) {
            console.error("Error: ", error)
          })
        }
      })
    },
    dbUpdate: function(userName)  {
      db.collection("users").doc(userName).set({
        money: this.money
      })
      .then(function(docRef) {
          console.log("Document written")
      })
      .catch(function(error) {
          console.error("Error:", error)
      })
    }
  }
}
</script>

<style>

.container {
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  text-align: center;
}

.title {
  font-family: 'Quicksand', 'Source Sans Pro', -apple-system, BlinkMacSystemFont,
    'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  display: block;
  font-weight: 300;
  font-size: 100px;
  color: #35495e;
  letter-spacing: 1px;
}

.subtitle {
  font-weight: 300;
  font-size: 42px;
  color: #526488;
  word-spacing: 5px;
  padding-bottom: 15px;
}

.links {
  padding-top: 15px;
}
.certification {
  display: inline-block;
  position: relative;
  overflow: hidden;
  height: 180px;
  width: 245px;
}
.links {
  padding-top: 15px;
}
</style>

ここで僕の大好きなelement-uiをいれて、入力フォームを作成し、gsapで軽いアニメーションを導入します。

npm i element-ui -S

Vueのアニメーションのサンプルを元に数値テキストのアニメーションを設置していきます。
CDNでgsapのTweenMaxをインポートして、数値のアニメーションを補完する様にします。
(普段はanime.jsを使っていますが、導入を検討していたところ数値の操作に少し工夫が必要だったので、今回は手っ取り早くこちらをおすすめしています。)

nuxt.config.jsにElementUIの導入を行う記述を行います。

const pkg = require('./package')

module.exports = {
  mode: 'spa',

  /*
  ** Headers of the page
  */
  head: {
    title: pkg.name,
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: pkg.description }
    ],
    link: [
      { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
      { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Heebo'}
    ]
  },

  /*
  ** Customize the progress-bar color
  */
  loading: { color: '#fff' },

  /*
  ** Global CSS
  */
  css: [
    'element-ui/lib/theme-chalk/index.css'
  ],

  /*
  ** Plugins to load before mounting the App
  */
  plugins: [
    {src: '~/plugins/firebase.js', ssr: false}
  ],
  /*
  ** Nuxt.js modules
  */
  modules: [
  ],

  /*
  ** Build configuration
  */
  build: {
    vendor: ['firebase','element-ui'],
    /*
    ** You can extend webpack config here
    */
    extend(config, ctx) {
      
    }
  }
}

index.vue
テンプレート部分に対して

<template>
  <section class="container">
    <div class="links">
      <el-button v-if="!isLogin" @click="googleLogin">google Login</el-button>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/1.20.3/TweenMax.min.js"></script>
      <div v-if="isLogin">
        <p>{{ animatedNumber }}</p>
        <div class="money-output" id="output">{{moneyString}}¥</div>
          <div class="input-form">
            <div class="slide">
              <el-slider v-model="slideValue" @change="slideChange" :min="-1000" :max="1000" style="width:90%;margin:20px;"></el-slider>
              <div hidden>
                <el-input-number v-model="money"></el-input-number>
              </div>
            </div>
            <div>
              <el-input-number style="width:50%;" v-model="inputValue"></el-input-number>
              <el-button style="width:30%;" type="primary" @click="inputChange" round>add costs</el-button>
            </div>
            <div class="update">
              <el-button type="danger" style="margin:50px 0px;width:40%;height:70px;font-size:20px;" round @click="dbUpdate(userData.displayName)">save this!!</el-button>
            </div>
            </div>
          <div>
          {{userData.displayName}}
          <el-button @click="googleLogout">Logout</el-button>
        </div>
      </div>
    </div>
  </section>
</template>

スクリプト部分に対して

<script>
import Vue from 'vue'
import firebase from '@/plugins/firebase'
import ElementUI from 'element-ui'
import locale from 'element-ui/lib/locale/lang/ja'

Vue.use(ElementUI, { locale })

const db = firebase.firestore()
const settings = {timestampsInSnapshots: true}
db.settings(settings)

export default {
  name:'init',
  data: function(){
    return{
      isLogin: false,
      money: 0,
      moneyString:'',
      userData: '',
      loading: false,
      slideValue: 0,
      inputValue: 0,
      changeFlag: false ,
      tweenedNumber: 0
    }
  },
  mounted: function() {
    firebase.auth().onAuthStateChanged(user => {
      if (user) {
        this.isLogin = true
        this.userData = user
        this.money = this.dbGetOrInit(this.userData.displayName)
      } else {
        this.isLogin = false
        this.userData = null
      }
    })
  },
  updated: function(){
    // パワーコード
    if(this.changeFlag === true)this.moneyString = this.money !== undefined ? (parseInt(this.money) + parseInt(this.animatedNumber)).toString().split("").reverse().join("").split(/(.{3})/).filter(v =>{return v}).join(",").split("").reverse().join("") : ''
    if(parseInt(this.animatedNumber) === 0)this.changeFlag = false
  },

  computed: {
    animatedNumber: function() {
      return this.tweenedNumber.toFixed(0);
    }
  },
  watch: {
    slideValue : function(newValue) {
      TweenLite.to(this.$data, 0.5, { tweenedNumber: newValue });
    },
    inputValue : function(newValue) {
      TweenLite.to(this.$data, 0.5, { tweenedNumber: newValue });
    }
  },

  methods: {
    googleLogin: function() {
      loading: true
      firebase.auth().signInWithRedirect(new firebase.auth.GoogleAuthProvider())
    },
    googleLogout: function() {
      firebase.auth().signOut()
    },
    dbGetOrInit: function(userName) {
      let self = this
      db.collection("users").doc(userName).get()
      .then(function(data){
        if(data.exists){
          self.money = parseInt(data.data().money)
          // パワーコード
          self.moneyString = self.money !== undefined ? self.money.toString().split("").reverse().join("").split(/(.{3})/).filter(v =>{return v}).join(",").split("").reverse().join("") : ''
          self.loading = false
        }else{
          db.collection("users").doc(userName).set({
            money: 0
          })
          .then(function(docRef) {
            console.log("addUser")
            self.money = 0
          })
          .catch(function(error) {
            console.error("Error: ", error)
          })
        }
      })
    },
    dbUpdate: function(userName)  {
      this.slideValue = 0
      db.collection("users").doc(userName).set({
        money: this.money
      })
      .then(function(docRef) {
          console.log("Document written")
      })
      .catch(function(error) {
          console.error("Error:", error)
      })
    },
    slideChange: function(){
      this.changeFlag = true
      this.money = this.money + this.slideValue
      this.slideValue = 0
      
    },
    inputChange: function(){
      this.changeFlag = true
      this.money = this.money + this.inputValue
      this.inputValue = 0
    },
  }
}
</script>

ついでに最近話題のダークモードのような雰囲気にしてしまいます。

<style>

.container {
  background-color: #00051a;
  color: rgb(231, 236, 255);
  min-height: 100vh;
  display: flex;
  width: 100%;
  justify-content: center;
  align-items: center;
  text-align: center;
}

.money-output {
  font-family: 'Heebo', sans-serif;
  display: block;
  font-weight: 300;
  font-size: 75px;
  color: #c5d5e7;
  letter-spacing: 1px;
}

.links {
  padding-top: 15px;
}
.certification {
  display: inline-block;
  position: relative;
  overflow: hidden;
  height: 180px;
  width: 200px;
}
.input-form{
  width:100%;
  margin: 10px 0px;
}
.slide{
  margin: 10px 0;
  display: inline;
  align-items: center;
  vertical-align: middle;
}
.input{
  margin: 20px 0px;
}
.links {
  padding-top: 15px;
}
</style>

f:id:AdwaysEngineerBlog:20181101174431p:plain

あっという間に完成です!!
UIは適当につけてあるのですが、スライドバーを操作すると1000円の範囲内でぱぱっと入力できます。
一部、数値にカンマを入れるため、パワーコードになっています...。

デプロイして、お金の管理が上手ではない先輩にテストしてもらいます。

# 動作確認
# 対象のdistファイルをFirebaseでデプロイするディレクトリに入れ一度ローカルでテストします
firebase serve

# 動作の確認が出来たらデプロイします。
firebase deploy

先輩に投げたらバグが見つかりました。

アニメーション周りでバグが発生していたみたいで、20桁以上の入力をするとバグが発生するみたいです。

浮動小数点表記法あたりが原因かもしれません。

まとめ

今回、Nuxt.jsとFirebaseを用いて認証とDBを簡単に作成してみました。

ほぼ始めて触る自分でもここまでできるほど簡単でした!

参考

ご参考にさせていただきました!ありがとうざいます!

最終的なindex.vue全文

<template>
  <section class="container">
    <div class="links">
      <el-button v-if="!isLogin" @click="googleLogin">google Login</el-button>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/1.20.3/TweenMax.min.js"></script>

      <div v-if="isLogin">
            <el-button style="right:10px;:right;top:10px;position:fixed;" @click="hint" icon="el-icon-question" circle></el-button>

        <div style="font-size:10px;margin: 20px 0px;">
          
        </div>
        <p>{{ animatedNumber }}</p>
        <div class="money-output" id="output">¥{{moneyString}}-</div>
          <div class="input-form">
            <div class="slide">
              <el-slider v-model="slideValue" @change="slideChange" :min="-1000" :max="1000" style="width:90%;margin:20px;"></el-slider>
              <div hidden>
                <el-input-number v-model="money"></el-input-number>
              </div>
            </div>
            <div>
              <el-input-number style="width:50%;" v-model="inputValue"></el-input-number>
              <el-button style="width:30%;" type="primary" @click="inputChange" round>add costs</el-button>
            </div>
            <div class="update">
              <el-button type="danger" style="margin:50px 0px;width:40%;height:70px;font-size:20px;" round @click="dbUpdate(userData.displayName)">save this!!</el-button>
            </div>
            </div>
          <div>
          {{userData.displayName}}
          <el-button @click="googleLogout">Logout</el-button>
        </div>
      </div>
    </div>
  </section>
</template>

<script>
import Vue from 'vue'
import firebase from '@/plugins/firebase'
import ElementUI from 'element-ui'
import locale from 'element-ui/lib/locale/lang/ja'

Vue.use(ElementUI, { locale })

const db = firebase.firestore()
const settings = {timestampsInSnapshots: true}
db.settings(settings)

export default {
  name:'init',
  data: function(){
    return{
      isLogin: false,
      money: 0,
      moneyString:'',
      userData: '',
      loading: false,
      slideValue: 0,
      inputValue: 0,
      changeFlag: false ,
      tweenedNumber: 0
    }
  },
  mounted: function() {
    firebase.auth().onAuthStateChanged(user => {
      if (user) {
        this.isLogin = true
        this.userData = user
        this.money = this.dbGetOrInit(this.userData.displayName)
      } else {
        this.isLogin = false
        this.userData = null
      }
    })
  },
  updated: function(){
    //  カンマ出力させるために実装したパワーコード
    if(this.changeFlag === true)this.moneyString = this.money !== undefined ? (parseInt(this.money) - parseInt(this.animatedNumber)).toString().split("").reverse().join("").split(/(.{3})/).filter(v =>{return v}).join(",").split("").reverse().join("") : ''
    if(parseInt(this.animatedNumber) === 0)this.changeFlag = false
  },

  computed: {
    animatedNumber: function() {
      return this.tweenedNumber.toFixed(0);
    }
  },
  watch: {
    slideValue : function(newValue) {
      TweenLite.to(this.$data, 0.5, { tweenedNumber: newValue });
    },
    inputValue : function(newValue) {
      TweenLite.to(this.$data, 0.5, { tweenedNumber: newValue });
    }
  },

  methods: {
    googleLogin: function() {
      loading: true
      firebase.auth().signInWithRedirect(new firebase.auth.GoogleAuthProvider())
    },
    googleLogout: function() {
      firebase.auth().signOut()
    },
    dbGetOrInit: function(userName) {
      let self = this
      db.collection("users").doc(userName).get()
      .then(function(data){
        if(data.exists){
          self.money = parseInt(data.data().money)
          self.moneyString = self.money !== undefined ? self.money.toString().split("").reverse().join("").split(/(.{3})/).filter(v =>{return v}).join(",").split("").reverse().join("") : ''
          self.loading = false
        }else{
          db.collection("users").doc(userName).set({
            money: 0
          })
          .then(function(docRef) {
            console.log("addUser")
            self.money = 0
          })
          .catch(function(error) {
            console.error("Error: ", error)
          })
        }
      })
    },
    dbUpdate: function(userName)  {
      this.slideValue = 0
      db.collection("users").doc(userName).set({
        money: this.money
      })
      .then(function(docRef) {
          console.log("Document written")
      })
      .catch(function(error) {
          console.error("Error:", error)
      })
    },
    slideChange: function(){
      this.changeFlag = true
      this.money = this.money + this.slideValue
      this.slideValue = 0
      
    },
    inputChange: function(){
      this.changeFlag = true
      this.money = this.money + this.inputValue
      this.inputValue = 0
    },
    hint: function(){
      this.$notify({
          title: '使い方',
          message: 'スライドバーで−1000〜1000までの入力ができるよ!\n'+
          '数値フォームに入力した後、「add cost」を押下すると金額を反映できるよ!\n'+
          '入力が完了したら「Save This!!!」と書かれたボタンで保存しよう!',
          duration: 0
        });
    }
  }
}
</script>

<style>

.container {
  background-color: #00051a;
  color: rgb(231, 236, 255);
  min-height: 100vh;
  display: flex;
  width: 100%;
  justify-content: center;
  align-items: center;
  text-align: center;
}

.money-output {
  font-family: 'Heebo', sans-serif;
  display: block;
  font-weight: 300;
  font-size: 75px;
  color: #c5d5e7;
  letter-spacing: 1px;
}

.links {
  padding-top: 15px;
}
.certification {
  display: inline-block;
  position: relative;
  overflow: hidden;
  height: 180px;
  width: 200px;
}
.input-form{
  width:100%;
  margin: 10px 0px;
}
.slide{
  margin: 10px 0;
  display: inline;
  align-items: center;
  vertical-align: middle;
}
.input{
  margin: 20px 0px;
}
.links {
  padding-top: 15px;
}
</style>