こんにちは。
最近はVue.js x Firebaseの可能性に惹かれていて、そのあたりをよくウォッチしています。
この記事では最近気になっているCloud Firestoreを使ったサンプルアプリを作ってみたいと思います。
作るもの
連絡先管理アプリです。作る画面(機能)は以下になります。
- 一覧の表示
- 連絡先の新規追加
- 詳細情報表示
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でプロジェクトを作成しましょう。
左のサイドバーからDatabaseの項目を選択し、Readltime Database
からCloud Firestore
に切り替えます。
ルールを書く
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.js
とfirebaseInit.js
の2つのJavaScriptファイルを作成します。
firebaseConfig.js
には、先程Firebase consoleで作成したプロジェクトの設定情報を持たせます。
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をイニシャライズします。
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
を編集していきましょう。
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;
}
}
}
上記のコードを解説していくと、まず、最初に作成したfirebaseInit
をdb
としてインポートしています。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
コンポーネントの完成です。以下が全体のコードになります。
<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
コンポーネントを作りましょう。
<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
によって↓のようなローディング中ステータスを表現できます。
v-if="loading"
でdev.loader-section
要素の表示非表示を切り替えています。
条件付きレンダリング — Vue.js
v-for=person in contacts
では、Firestoreから取得した連絡先情報一覧(contacts
)をループして、person
として扱っています。
リストレンダリング — Vue.js
また、以下の行は詳細情報表示画面へのリンクを表示します。
<router-link class="button is-primary" v-bind:to="{ name: 'view-contact', params: { person: person.slug }}">View Person</router-link>
router-link · vue-router
<script></script>
セクションでは、Firestoreから全連絡先情報の取得をしています。
created()内のdb.collection('contacts').get()
で情報を取ってきています。取得した情報を一件ずつ、data()で定義したcontacts
配列プロパティに追加しています。
最後にViewContact
コンポーネントを作ります。
<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()
はナビゲーションガードと言われるものです。
ナビゲーションガード · 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.person
で38bc715b-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を開きましょう!
おつかれさまでした!!
参考リンク
-
Getting Started with Firebase Cloud Firestore: Build a Vue Contact App ― Scotch
- 著者の方に↑の記事をかなり参考にすることの許可は事前に頂いております。Shout out to @yomieluwande !