Cloud Firestore with Vue.jsで簡単なメモアプリを実装する

# 概要

以前に投稿した「[Vue.jsで簡単なメモアプリを実装する] (https://qiita.com/rubytomato@github/items/294ca52abc2be4ae6131) 」に続く記事で、メモアプリのデータ管理をGoogleのCloud FirestoreというNoSQLデータベースを利用するように改修しました。
この記事の前半でCloud Firestoreを利用するまでの手順、後半でメモアプリをどのように改修したのかを説明します。

**環境**

* Windows 10 Professional
* Node.js 8.11.1
* Vue.js 2.5.16
    * Vuex 3.0.1
    * vue-router 3.0.1
    * firebase 4.13.1
* Cloud Firebase Beta
* Visual Studio Code 1.23.0

**参考**

* [Cloud Firestore | Firebase] (https://firebase.google.com/docs/firestore/?hl=ja)
* [Overview | Firebase] (https://firebase.google.com/docs/reference/js/?hl=ja)

# Cloud Firestore

Googleが運営するFirebaseというMBaasサービスがありますが、Cloud FirestoreはFirebaseのプロダクトの1つでNoSQLデータベースです。現時点(2018/05)ではベータ版という扱いです。
また、GoogleにはGCP(Google Cloud Platform)というサービスもありますが、Firebaseは2014年にGoogleが買収したサービスで、このためかマネージメントコンソールはGCPと別々になっています。

> Cloud Firestore は、クラウドホストの NoSQL データベースであり、iOS アプリ、Android アプリ、およびウェブアプリからネイティブ SDK を介して直接アクセスできます。
> Cloud Firestore の NoSQL データモデルに従い、値に対応するフィールドを含むドキュメントにデータを格納します。これらのドキュメントはコレクションに格納されます。コレクションは、データの編成とクエリの作成に使用できるドキュメントのコンテナです。

Firestoreを簡単に触れてみたところMongoDBというNoSQLデータベースとよく似ていると感じました。（MongoDBと同等のものかどうかは、Googleのドキュメント上で確認した範囲では記述はみつけられませんでした。）

## Firebaseにプロジェクトを作成する

Cloud Firestoreを利用するには、Firebaseにプロジェクトを作成する必要があります。
Googleアカウントを持っていれば[Firebase console] (https://console.firebase.google.com/?hl=ja)から作成できます。

Firebase consoleにログインしたら「プロジェクトを追加」をクリックします。

![f1.png](https://qiita-image-store.s3.amazonaws.com/0/22772/2b8833ed-27b3-289f-8f42-bdb1d15a7e84.png)

プロジェクト名を入力、リージョンを選択して「プロジェクトを作成」をクリックします。

![f2.png](https://qiita-image-store.s3.amazonaws.com/0/22772/087ae62b-9339-fe14-7089-3ce80467cab1.png)

「次へ」をクリックします。

![f3.png](https://qiita-image-store.s3.amazonaws.com/0/22772/39f0a61c-caaa-3aad-c1f6-0b7573b0faef.png)

プロジェクト画面です。左側のメニューから「Database」をクリックします。

![f4.png](https://qiita-image-store.s3.amazonaws.com/0/22772/d1a017b2-cb9c-2e41-7fa2-a815b27295f6.png)

利用するデータベースにCloud Firestoreを選択します。

![f5.png](https://qiita-image-store.s3.amazonaws.com/0/22772/2370d906-4bd5-0bdd-564c-5d7758df1f21.png)

セキュリティルールは「テストモードで開始」を選択します。セキュリティールールは後から変更できます。

![f6.png](https://qiita-image-store.s3.amazonaws.com/0/22772/8a43e5e8-3fd2-7d03-0f73-a1757413e47d.png)

データベースの管理画面です。ここでコレクションやドキュメントの作成、更新、削除や、インデックスの作成、セキュリティルールの変更などが行えます。
メモアプリで使用するコレクションを作成するため「コレクションを追加」をクリックします。

![f7.png](https://qiita-image-store.s3.amazonaws.com/0/22772/b39941ff-8cb7-e7c1-9579-378378a6e614.png)

メモデータを管理するコレクション名は「memos」にしました。

![f8.png](https://qiita-image-store.s3.amazonaws.com/0/22772/5a944708-45c2-4d09-2400-e41768cadb11.png)

ドキュメント追加画面です。ここでテストデータを登録することができますが、NoSQLデータベースはスキーマレスなので毎回フィールド名、データタイプも含めて入力する必要があり面倒だったので、検証用に2,3件入力して終わりにしました。（残りはプログラムからまとめて登録しました）

![f9.png](https://qiita-image-store.s3.amazonaws.com/0/22772/2cb689fa-5bfe-c26a-87c5-62791715dca9.png)

ドキュメントの登録結果画面です。画面の中央に表示されている"EZR3..."という文字列は登録したドキュメントのIDです。ドキュメント追加画面で"自動ID"にするとこのような値が設定されます。自動IDにはせずに任意の値をIDにすることも可能です。（たとえばemailアドレスなどの一意性の高い値など）

![f10.png](https://qiita-image-store.s3.amazonaws.com/0/22772/d84b74c7-20b6-9ab1-5a1f-21eaa649cce0.png)

プロジェクト画面に戻り、アプリケーションからCloud Firestoreを利用するための設定情報を取得します。
画面の「ウェブアプリにFirebaseを追加」をクリックします。

![f11.png](https://qiita-image-store.s3.amazonaws.com/0/22772/74671cba-cbd5-5c44-cb68-29b93a3b0c9a.png)

必要な設定情報が表示されるので控えておきます。（スクリーンショットでは一部マスクしていますが、最終的にはjsファイルに貼り付けるので隠す必要はないです）

![f12.png](https://qiita-image-store.s3.amazonaws.com/0/22772/efa1cbf3-ff10-cdf2-c4e7-c148bb22b805.png)

* [Get Started with Firebase for Web App] (https://firebase.google.com/docs/web/setup?authuser=0)
* [Firebase Web SDK API Reference] (https://firebase.google.com/docs/reference/js/index-all?authuser=0)
* [Firebase Web Samples] (https://firebase.google.com/docs/samples/?authuser=0#web)

以上で、アプリケーションからCloud Firestoreを利用する準備が整いました。


# メモアプリケーションの改修

今回の改修点はいくつかありますが、主なものはメモデータをCloud Firestoreから取得する点と、データの取得をリアルタイムに行う点です。
ちなみに、前記事で作成したメモアプリは下記のようにメモデータをVuexを利用したストアのstate部分に直接記述していました。

```text
state: {
  memos: [
    { id: 1, title: '...', description: '...', platforms: ['...'], million: false, releasedAt: new Date ()}, 
    { id: 2, title: '...', description: '...', platforms: ['...'], million: false, releasedAt: new Date ()}, 
    { id: 3, title: '...', description: '...', platforms: ['...'], million: false, releasedAt: new Date ()}, 
    // ...省略 ...
    { id: 14, title: '...', description: '...', platforms: ['...'], million: false, releasedAt: new Date ()} 
  ],
  nextId: 15
}
```

## Firebaseモジュールのインストール

アプリケーションからCloud Firestoreを利用できるようにFirebaseモジュールをインストールします。

```text
npm install firebase --save
```

## データの取得方法について

前述した主な改修点の「データの取得をリアルタイム」に行う方法ですが、Cloud Firestoreにリアルタイムリスナーという機能があるのでこれを利用します。

> [Cloud Firestore] (https://firebase.google.com/docs/firestore/?hl=ja)
> 主な機能
> Realtime Database と同様に、Cloud Firestore はデータ同期を使用して、すべての接続端末のデータを更新します。ただし、シンプルな 1 回限りの取得クエリを効率的に実行するようにも設計されています。

**get**

通常のクエリを発行する場合はgetメソッドを使用します。

```text
firebase.firestore.CollectionReference#get()
```

**onSnapshot**

リアルタイムリスナーの場合はonSnapshotを使用します。この記事ではこちらを使ってデータを取得、監視を行います。

```text
firebase.firestore.CollectionReference#onSnapshot(
  optionsOrObserverOrOnNext,
  observerOrOnNextOrOnError,
  onError,
  onCompletion)
```

## ソースコードの変更点

### プロジェクトの構造

```text
exercise-vue
 |
 +--- /src
 |      |
 |      +--- /components
 |      |      |
 |      |      +--- MemoListCard.vue
 |      |      |
 |      |      +--- MemoListForm.vue
 |      |
 |      +--- /constants
 |      |      |
 |      |      +--- index.js
 |      |
 |      +--- /firebase
 |      |      |
 |      |      +--- firestore.js         // 1. Firebaseの設定を追加
 |
 |      +--- /pages
 |      |      |
 |      |      +--- MemoList.vue         // 4. メモ一覧コンポーネント
 |      |      |
 |      |      +--- MemoDetails.vue      // 4. メモ詳細コンポーネント
 |      |
 |      +--- /router
 |      |      |
 |      |      +--- index.js
 |      |
 |      +--- /store
 |      |      |
 |      |      +--- /modules
 |      |      |      |
 |      |      |      +--- Memo.js       // 3. モジュールを追加
 |      |      |      | 
 |      |      |      +--- Memos.js      // 3. モジュールを追加
 |      |      |
 |      |      +--- index.js             // 2. モジュール化
 |      |
 |      +--- App.vue
 |      |
 |      +--- main.js
 |
 +--- index.html
 |
 +--- package.json
```

### 1. firebase/firestore.js

Firestoreを利用できるようにするためのコンフィグレーションです。
configにはFirebase Consoleで取得した設定情報を貼り付けます。

```js:firebase/firestore.js
import Firebase from 'firebase'
import 'firebase/firestore'

const config = {
  apiKey: '<API_KEY>',
  authDomain: '<PROJECT_ID>.firebaseapp.com',
  databaseURL: 'https://<PROJECT_ID>.firebaseio.com',
  projectId: '<PROJECT_ID>',
  storageBucket: '<PROJECT_ID>.appspot.com',
  messagingSenderId: '<SENDER_ID>'
}

const firebaseApp = Firebase.initializeApp(config, 'exercise-vue')
const firestore = firebaseApp.firestore()
firestore.settings({ timestampsInSnapshots: true })

export default firestore
```

### 2. store/index.js

ストアでは、メモ一覧とメモ詳細をそれぞれモジュール化し、index.jsに実装していたstate、getters、mutationsはモジュールへ移動させました。

```js:store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import memo from '@/store/modules/Memo'
import memos from '@/store/modules/Memos'

Vue.use(Vuex)

const store = new Vuex.Store({
  modules: {
    memo: memo,
    memos: memos
  },
  strict: process.env.NODE_ENV !== 'production'
})

export default store
```

### 3. store/modules/Memos.js

メモ一覧のストアモジュールです。メモ詳細で表示するメモの配列を管理します。

#### 処理フロー

メモ一覧コンポーネントが呼ばれて実際にデータ表示されるまでの処理の流れは以下の通りです。

1. メモ一覧コンポーネントのmountedフックでストアのリスナーを起動(memos/startListener)する
2. メモ一覧コンポーネントはストアのゲッターを通してステート(data)を監視する
3. リスナーでCloud Firestoreのmemosコレクションを検索
4. 取得したデータをストアのミューテーションを通してステート(data)を更新
5. ステート(data)の状態が変わったためビューが更新される
6. 他のところでデータを更新するとFirestoreからリスナーへ変更が通知される -> 3へ
7. メモ一覧から他のページへ画面遷移する
8. メモ一覧コンポーネントのdestroyedフックでストアのリスナーを停止(memos/stopListener)する


#### コレクションの参照

以下のようにコレクション名を指定します。

```js
const memosRef = firestore.collection('memos')
```

collectionメソッドの

```text
firebase.firestore.Firestore.collection(collectionPath)
```

メソッドの戻り値は[CollectionReference] (https://firebase.google.com/docs/reference/js/firebase.firestore.CollectionReference)クラスです。

```text
firebase.firestore.CollectionReference
```

#### リスナー内で行うドキュメント変更の検知

[QuerySnapshot] (https://firebase.google.com/docs/reference/js/firebase.firestore.QuerySnapshot)のdocChangesプロパティを使用するとドキュメント変更を検知でき、

> docChanges
> non-null Array of non-null firebase.firestore.DocumentChange

DocumentChangeのtypeプロパティの値（`added`,`modified`,`removed`の何れか）でドキュメントの変更の種類が判断できます。

```js
querySnapshot.docChanges.forEach(change => {

  // ...省略...

  // 4. ミューテーションを通してステートを更新する
  if (change.type === 'added') {
    commit('add', payload)
  } else if (change.type === 'modified') {
    commit('set', payload)
  } else if (change.type === 'removed') {
    commit('remove', payload)
  }
})
```

ただし注意点があり、DocumentChange.typeが`removed`は必ずしもドキュメントが削除されたときに発生する訳ではないようです。
たとえば、以下のような条件付きのリスナーが監視するデータを、条件から外れるような（`million=false`）更新をした場合にもtypeは`removed`になります。

```js
this.unsubscribe = memosRef.where('million', '==', true).orderBy('releasedAt', 'asc').onSnapshot(querySnapshot => {

  // ...省略...

}
```

#### メモの追加、削除時のストアの処理

アクションに定義したaddMemoやdeleteMemoでは、Firestoreのドキュメントを更新する処理だけ実装しています。
この処理内でステートを更新しないのは、リスナーがドキュメントの変更を検知して自動的に更新するためです。
逆にリスナーを使用しない実装の場合は、下記のようにステートの更新を行う必要があります。

```js
memosRef.add(payload)
  .then(doc => {
    commit('add', doc.data())
  })
  .catch(err => {
    console.error('Error adding document: ', err)
  })
```

#### ソースコード全体

```js:store/modules/Memos.js
import firestore from '@/firebase/firestore'

const memosRef = firestore.collection('memos')

export default {
  namespaced: true,
  unsubscribe: null,
  state () {
    return {
      data: []
    }
  },
  mutations: {
    init (state, payload) {
      state.data = payload
    },
    add (state, payload) {
      state.data.push(payload)
    },
    set (state, payload) {
      const index = state.data.findIndex(memo => memo.id === payload.id)
      if (index !== -1) {
        state.data[index] = payload
      }
    },
    remove (state, payload) {
      const index = state.data.findIndex(memo => memo.id === payload.id)
      if (index !== -1) {
        state.data.splice(index, 1)
      }
    }
  },
  // 2. コンポーネントはゲッターを通してステートを監視する
  getters: {
    data (state) {
      return state.data
    }
  },
  actions: {
    clear ({ commit }) {
      commit('init', [])
    },
    // 1. リスナーの起動
    startListener ({ commit }) {
      if (this.unsubscribe) {
        console.warn('listener is running. ', this.unsubscribe)
        this.unsubscribe()
        this.unsubscribe = null
      }
      // 3. Firestoreからデータを検索する
      this.unsubscribe = memosRef.orderBy('releasedAt', 'asc').onSnapshot(querySnapshot => {

        // 6. データが更新されるたびに呼び出される
        querySnapshot.docChanges.forEach(change => {

          const payload = {
            id: change.doc.id,
            title: change.doc.data().title,
            description: change.doc.data().description,
            platforms: change.doc.data().platforms,
            million: change.doc.data().million,
            releasedAt: new Date(change.doc.data().releasedAt.seconds * 1000)
          }

          // 4. ミューテーションを通してステートを更新する
          if (change.type === 'added') {
            commit('add', payload)
          } else if (change.type === 'modified') {
            commit('set', payload)
          } else if (change.type === 'removed') {
            commit('remove', payload)
          }
        })
      },
      (error) => {
        console.error(error.name)
      })
    },
    // 8. リスナーの停止
    stopListener () {
      if (this.unsubscribe) {
        console.log('listener is stopping. ', this.unsubscribe)
        this.unsubscribe()
        this.unsubscribe = null
      }
    },
    addMemo ({ commit }, payload) {
      memosRef.add(payload)
        .then(doc => {
          // Do not mutate vuex store state outside mutation handlers.
        })
        .catch(err => {
          console.error('Error adding document: ', err)
        })
    },
    deleteMemo ({ commit }, payload) {
      memosRef.doc(payload.id).delete()
        .then(() => {
          // Do not mutate vuex store state outside mutation handlers.
        })
        .catch(err => {
          console.error('Error removing document: ', err)
        })
    }
  }
}
```

### 3. store/modules/Memo.js

メモ詳細のストアモジュールです。メモ詳細で表示するメモ1件分のデータを管理します。
メモ一覧のストアモジュールと同じ処理なので説明は省略します。

```js:store/modules/Memo.js
import CONSTANTS from '@/constants'
import firestore from '@/firebase/firestore'

const memosRef = firestore.collection('memos')

export default {
  namespaced: true,
  unsubscribe: null,
  state () {
    return {
      data: {}
    }
  },
  mutations: {
    set (state, payload) {
      state.data = payload
    }
  },
  getters: {
    data (state) {
      return state.data
    }
  },
  actions: {
    clear ({ commit }) {
      commit('set', CONSTANTS.NEW_EMPTY_MEMO())
    },
    startListener ({ commit }, payload) {
      if (this.unsubscribe) {
        console.warn('listener is running. ', this.unsubscribe)
        this.unsubscribe()
        this.unsubscribe = null
      }
      this.unsubscribe = memosRef.doc(payload.id).onSnapshot(doc => {
        commit('set', {
          id: doc.id,
          title: doc.data().title,
          description: doc.data().description,
          platforms: doc.data().platforms,
          million: doc.data().million,
          releasedAt: new Date(doc.data().releasedAt.seconds * 1000)
        })
      })
    },
    stopListener () {
      if (this.unsubscribe) {
        console.log('listener is stopping. ', this.unsubscribe)
        this.unsubscribe()
        this.unsubscribe = null
      }
    },
    updateMillion ({ state }) {
      const million = !state.data.million
      memosRef.doc(state.data.id).update({ million: million })
        .then(() => {
          // Do not mutate vuex store state outside mutation handlers.
        })
        .catch(err => {
          console.error('Error updating document: ', err)
        })
    },
    updatePlatforms ({ state }, payload) {
      const platforms = [].concat(state.data.platforms)
      if (platforms.includes(payload.platform)) {
        platforms.splice(platforms.indexOf(payload.platform), 1)
      } else {
        platforms.push(payload.platform)
      }
      memosRef.doc(state.data.id).update({ platforms: platforms })
        .then(() => {
          // Do not mutate vuex store state outside mutation handlers.
        })
        .catch(err => {
          console.error('Error updating document: ', err)
        })
    }
  }
}
```

### 4. pages/MemoList.vue

メモ一覧のコンポーネントです。
コンポーネントインスタンスのmountedライフサイクルフックでリスナーを開始し

```text
start () {
  this.$store.dispatch('memos/startListener')
}
```

destroyedライフサイクルフックで停止させるようにしています。画面は離れるときなどでリスナーをデタッチしておかないとストア上でリスナーが動き続けるため、コンポーネントのインスタンスがマウントされるたびにリスナーが増え続けることになります。

```text
stop () {
  this.$store.dispatch('memos/stopListener')
}
```

コンポーネントのscript（ストアに関係のないコードは省略しています）は下記の通りです。

```vue:pages/MemoList.vue
<script>
import MemoListCard from '@/components/MemoListCard'
import MemoListForm from '@/components/MemoListForm'

export default {
  name: 'MemoList',
  components: {
    'memo-list-card': MemoListCard,
    'memo-list-form': MemoListForm
  },
  data () {
    return {
    }
  },
  mounted () {
    this.init()
    this.start()
  },
  destroyed () {
    this.stop()
  },
  methods: {
    init () {
      this.$store.dispatch('memos/clear')
    },
    start () {
      this.$store.dispatch('memos/startListener')
    },
    stop () {
      this.$store.dispatch('memos/stopListener')
    },
    remove (id) {
      this.$store.dispatch('memos/deleteMemo', { id })
    }
  },
  computed: {
    memos () {
      return this.$store.getters['memos/data']
    }
  }
}
</script>
```

### 4. pages/MemoDetails.vue

メモ詳細のコンポーネントです。メモ詳細で起動するリスナーは表示するメモ1件のデータ更新を監視しています。
リスナーの起動、停止はメモ一覧と同じタイミングです。

コンポーネントのscript（ストアに関係のないコードは省略しています）は下記の通りです。

```vue:pages/MemoDetails.vue
<script>
export default {
  name: 'MemoDetails',
  data () {
    return {
      targetId: this.id
    }
  },
  // routeの動的セグメント
  props: ['id'],
  mounted () {
    this.init()
    this.start()
  },
  destroyed () {
    this.stop()
  },
  beforeRouteUpdate (to, from, next) {
    // pathの:idを直接書き換えたときの対応
    this.targetId = to.params.id
    next()
  },
  methods: {
    init () {
      this.$store.dispatch('memo/clear')
    },
    start () {
      this.$store.dispatch('memo/startListener', { id: this.targetId })
    },
    stop () {
      this.$store.dispatch('memo/stopListener')
    },
    updateMillion (million) {
      this.$store.dispatch('memo/updateMillion')
    },
    updatePlatform (platform) {
      this.$store.dispatch('memo/updatePlatforms', { platform: platform })
    }
  },
  computed: {
    memo () {
      return this.$store.getters['memo/data']
    }
  }
}
</script>
```

# 操作している様子

操作している様子をgifにしました。
ブラウザを2つ立ち上げ、片方のブラウザで行ったデータ更新が、もう片方のブラウザにリアルタイムに反映されている様子です。
（画面下の黒いボタンは削除ボタンです。）

![xxx2234.gif](https://qiita-image-store.s3.amazonaws.com/0/22772/bfce9535-969a-c379-7563-43180d13fcaa.gif)