Vue.js + Firebase でリアルタイムアンケートWEBアプリを作ってみた

## 目的
Firebase と Vue.js を使ったデータのリアルタイム同期がおもしろそうだったので、とりあえず触ってみる。
※ とりあえず、触ってみるのが目的なので、セキュリティの設定やデザイン、画面の挙動、UI/UXなど荒い部分が多々あります。

## つくったもの
**リアルタイムアンケートWebアプリ**

1. 管理画面から質問と選択肢を設定し、公開する
1. ユーザ画面に質問と選択肢が表示される
1. ユーザが選択肢を選ぶ (1度しか選べない)
1. 管理画面にユーザの解答の集計がリアルタイムで表示される。
1. 管理画面で解答を締め切り、次の質問の設定に進む

## つかったもの
- Vue CLI 3
- Vue.js
- Vue Router
- VueFire
- Firebase (Authentication, Firestore)
- ElementUI

## 環境確認
```Bash
# node
$ node -v
v10.13.0
# npm
$ npm -v
6.9.0
```

## Vue CLIのインストール
```Bash:
$ npm install -g @vue/cli
#バージョン確認
$ vue --version
3.5.1
```

## Vueプロジェクトの作成
```Bash:
$ cd /path/to/workspace/
$ vue create realtime-answer
```

<details>
<summary>**長いのでアコーディオンにしてあります**</summary>
<div>

```Bash:

Vue CLI v3.5.1

? Please pick a preset: 
  default (babel, eslint) 
❯ Manually select features 

? Check the features needed for your project: 
 ◉ Babel
 ◯ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◉ Router
 ◯ Vuex
❯◉ CSS Pre-processors
 ◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing

? Use history mode for router? (Requires proper server setup for index fallback in production) (Y/n) Y

? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): 
  Sass/SCSS (with dart-sass) 
❯ Sass/SCSS (with node-sass) 
  Less 
  Stylus

? Pick a linter / formatter config: 
  ESLint with error prevention only 
  ESLint + Airbnb config 
❯ ESLint + Standard config 
  ESLint + Prettier 

? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection)
❯◉ Lint on save
 ◯ Lint and fix on commit

? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? (Use arrow keys)
❯ In dedicated config files 
  In package.json 

? Save this as a preset for future projects? (y/N) N

# 自動的に環境構築が進む

Vue CLI v3.5.1
✨  Creating project in /path/to/workspace/realtime-answer.
🗃  Initializing git repository...
⚙  Installing CLI plugins. This might take a while...


> fsevents@1.2.7 install /path/to/workspace/realtime-answer/node_modules/fsevents
> node install

[fsevents] Success: "/path/to/workspace/realtime-answer/node_modules/fsevents/lib/binding/Release/node-v64-darwin-x64/fse.node" is installed via remote

> yorkie@2.0.0 install /path/to/workspace/realtime-answer/node_modules/yorkie
> node bin/install.js

setting up Git hooks
done

added 1191 packages from 659 contributors and audited 23505 packages in 57.878s
found 0 vulnerabilities

🚀  Invoking generators...
📦  Installing additional dependencies...


> node-sass@4.11.0 install /path/to/workspace/realtime-answer/node_modules/node-sass
> node scripts/install.js

Cached binary found at /Users/ohnaka/.npm/node-sass/4.11.0/darwin-x64-64_binding.node

> node-sass@4.11.0 postinstall /path/to/workspace/realtime-answer/node_modules/node-sass
> node scripts/build.js

Binary found at /path/to/workspace/realtime-answer/node_modules/node-sass/vendor/darwin-x64-64/binding.node
Testing binary
Binary is fine
added 176 packages from 102 contributors, updated 2 packages, moved 9 packages and audited 24422 packages in 17.734s
found 0 vulnerabilities

⚓  Running completion hooks...

📄  Generating README.md...

🎉  Successfully created project realtime-answer.
👉  Get started with the following commands:

 $ cd realtime-answer
 $ npm run serve

```

</div>
</details>

### 開発環境の動作確認
```Bash
$ cd realtime-answer
$ npm run serve
```
ブラウザで http://localhost:8080/ にアクセスするとVueのサンプルページが表示される。

### RESS の導入
```Bash
$ npm install --save ress
```
```diff:src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
+ import 'ress'

Vue.config.productionTip = false
```

## ElementUI の導入
```Bash
$ vue add element
```

<details>
<summary>**長いのでアコーディオンにしてあります**</summary>
<div>

```Bash
📦  Installing vue-cli-plugin-element...

+ vue-cli-plugin-element@1.0.1
added 1 package from 1 contributor and audited 29022 packages in 9.35s
found 0 vulnerabilities

✔  Successfully installed plugin: vue-cli-plugin-element

? How do you want to import Element? (Use arrow keys)
❯ Fully import 
  Import on demand 

? Do you wish to overwrite Element’s SCSS variables? (y/N) y

? Choose the locale you want to load 
  hu 
  id 
  it 
❯ ja 
  km 
  ko 
  ku 
(Move up and down to reveal more choices)


🚀  Invoking generator for vue-cli-plugin-element...
📦  Installing additional dependencies...

added 8 packages from 8 contributors and audited 24433 packages in 7.986s
found 0 vulnerabilities

⚓  Running completion hooks...

✔  Successfully invoked generator for plugin: vue-cli-plugin-element
   The following files have been updated / added:

     src/element-variables.scss
     src/plugins/element.js
     package-lock.json
     package.json
     src/App.vue
     src/main.js

   You should review these changes with git diff and commit them.

```

</div>
</details>

### 動作確認
```Bash
$ npm run serve
```
ブラウザで http://localhost:8080/ にアクセスし、ElementUIのボタンが表示されていることを確認する。

## Firebase の設定
### Firebase のプロジェクト作成
[Firebaseのコンソール](https://console.firebase.google.com/u/2/?hl=ja)へアクセスし、プロジェクトを作成する。

### Authentication の設定
作成したFirebaseプロジェクトで、 `Authentication > ログイン方法` から `匿名` のログインを有効にする

### Database の設定
作成したFirebaseプロジェクトで、 `Database` から `Firestore` の設定を行う。
※セキュリティモードはひとまず、`テストモード`を選択

### firebase-toolsのインストール
```Bash
$ npm install -g firebase-tools
```

### firebase-toolsでのログイン
```Bash
$ firebase login
```
ブラウザでgoogleのログイン画面が開くので、Firebaseの設定をしたアカウントでログインする

### Firebaseプロジェクトの初期化
```Bash
# Firebaseのコンソールで作成したプロジェクトのプロジェクトIDを確認し、オプションで指定する
$ firebase init --project realtime-answer-XXXXX
```

<details>
<summary>**長いのでアコーディオンにしてあります**</summary>
<div>

```Bash

     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You’re about to initialize a Firebase project in this directory:

  /Volumes/Data/Repository/Project/realtime-answer

Before we get started, keep in mind:

  * You are currently outside your home directory

? Which Firebase CLI features do you want to setup for this folder? Press Space to select features, then En
ter to confirm your choices. 
 ◯ Database: Deploy Firebase Realtime Database Rules
 ◉ Firestore: Deploy rules and create indexes for Firestore
 ◉ Functions: Configure and deploy Cloud Functions
❯◉ Hosting: Configure and deploy Firebase Hosting sites
 ◯ Storage: Deploy Cloud Storage security rules
# なぜかスペースバーが効かないので、数字キーで選択。。。

=== Project Setup

First, let’s associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add, 
but for now we’ll just set up a default project.

i  Using project realtime-answer-53bff (Realtime-Answer)

=== Firestore Setup

Firestore Security Rules allow you to define how and when to allow
requests. You can keep these rules in your project directory
and publish them with firebase deploy.

? What file should be used for Firestore Rules? (firestore.rules) 

Firestore indexes allow you to perform complex queries while
maintaining performance that scales with the size of the result
set. You can keep index definitions in your project directory
and publish them with firebase deploy.

? What file should be used for Firestore indexes? (firestore.indexes.json) 

=== Functions Setup

A functions directory will be created in your project with a Node.js
package pre-configured. Functions can be deployed with firebase deploy.

? What language would you like to use to write Cloud Functions? (Use arrow keys)
❯ JavaScript 
  TypeScript 

? Do you want to use ESLint to catch probable bugs and enforce style? (y/N) y
✔  Wrote functions/package.json
✔  Wrote functions/.eslintrc.json
✔  Wrote functions/index.js
✔  Wrote functions/.gitignore

? Do you want to install dependencies with npm now? (Y/n) n

=== Hosting Setup

Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.

? What do you want to use as your public directory? (public) 
? Configure as a single-page app (rewrite all urls to /index.html)? (y/N) y
? File public/index.html already exists. Overwrite? (y/N) N
i  Skipping write of public/index.html

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...

✔  Firebase initialization complete!
```
</div>
</details>

### Firebaseモジュールのインストール
```Bash
$ npm install --save firebase vuefire@next
```

### VueプロジェクトにFirebaseの設定を行う
```javascript:src/plugins/firebase.js
import Vue from 'vue'
import { firestorePlugin } from 'vuefire'
import firebase from 'firebase/app'
import 'firebase/firestore'
import 'firebase/auth'

Vue.use(firestorePlugin)

const config = {
  apiKey: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
  authDomain: 'realtime-answer-XXXX.firebaseapp.com',
  databaseURL: 'https://realtime-answer-XXXX.firebaseio.com',
  projectId: 'realtime-answer-XXXX',
  storageBucket: 'realtime-answer-XXXXX.appspot.com',
  messagingSenderId: 'XXXXXXXX'
}

const firebaseApp = firebase.initializeApp(config)

export const db = firebaseApp.firestore()

export const auth = firebaseApp.auth()
```
```diff:src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import 'ress'
import './plugins/element.js'
+ import './plugins/firebase'

Vue.config.productionTip = false

```

## ルーティングの設定
```javascript:src/router.js
import Vue from 'vue'
import Router from 'vue-router'

// あとで作る
import UserHome from '@/views/UserHome.vue'
import AdminHome from '@/views/AdminHome.vue'

Vue.use(Router)

export default new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'userHome',
      component: UserHome
    },
    {
      path: '/admin',
      name: 'AdminHome',
      component: AdminHome
    }
  ]
})
``` 

## 画面の作成
### ベースのViewの作成
```Vue:src/App.vue
<template>
  <router-view></router-view>
</template>

<style lang="scss">
body {
  color: #333;
  font-size: 16px;
}
</style>

<script>
export default {}
</script>
```
### ユーザ用のViewの作成
<details>
<summary>**長いのでアコーディオンにしてあります**</summary>
<div>

```Vue:src/views/UserHome.vue
<template>
  <el-container>
    <el-main v-if="question">
      <p class="ra-question">
        {{ question.text }}
        <span v-if="isAnswered">
          (解答終了)
        </span>
      </p>
      <el-button
        class="ra-select"
        v-for="selection in question.selectionRefs"
        :key="selection.id"
        :disabled="isAnswered"
        @click="select($event, selection)"
      >{{ selection.text }}</el-button>
    </el-main>
    <el-main class="ra-message" v-else>Please wait for question.</el-main>
  </el-container>
</template>

<style lang="scss" scoped>
.ra-question {
  margin-top: 3rem;
  margin-bottom: 3rem;
  width: 100%;
  text-align: center;
  font-size: 1rem;
}
.ra-select {
  display: block;
  width: 100%;
  font-size: 1rem;
  & + & {
    margin: 2rem 0 0;
  }
}
.ra-message {
  width: 100%;
  height: 100vh;
  background-color: #4caf50;
  color: #fff;
  font-size: 1.5rem;
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

<script>
import firebase from 'firebase/app'
import { db, auth } from '@/plugins/firebase'

const questions = db.collection('questions')
const activeQuestions = questions.where('isFinished', '==', false)
const selections = db.collection('selections')
const answers = db.collection('answers')

export default {
  data () {
    return {
      uid: null,
      questions: []
    }
  },
  async created () {
    // 匿名認証を行う
    await auth.signInAnonymously()
    auth.onAuthStateChanged((user) => {
      // 取得したUIDを保持
      this.uid = user.uid
    })

    // dataのquestionsにバインド
    // note: maxRefDepthをデフォルトの2から変更するため、ここで処理
    await this.$bind(
      'questions',
      activeQuestions,
      {
        maxRefDepth: 3
      }
    )
  },
  computed: {
    /**
     * @var Object 問題
     */
    question: {
      get () {
        return (this.questions.length) ? this.questions[0] : null
      }
    },
    /**
     * @var Boolean 解答済みかどうか
     */
    isAnswered: {
      get () {
        if (!this.question) {
          return false
        }
        if (this.question.isClosed) {
          return true
        }
        // note: questionがVueFireで複数回に分けて更新されるため、Objectのチェックを行う
        for (const selectionRef of this.question.selectionRefs) {
          if (typeof selectionRef !== 'object') {
            continue
          }
          for (const answerRef of selectionRef.answerRefs) {
            if (typeof answerRef !== 'object') {
              continue
            }
            if (answerRef.uid === this.uid) {
              // 自身の解答があれば、解答済み
              return true
            }
          }
        }
        return false
      }
    }
  },
  methods: {
    /**
     * 選択処理
     */
    async select (event, selection) {
      // 解答を保存
      const answerRef = await answers.add({
        uid: this.uid
      })
      // 選択肢に解答を紐づけ
      const selectionRef = await selections.doc(selection.id)
      await selectionRef.set(
        {
          answerRefs: firebase.firestore.FieldValue.arrayUnion(answerRef)
        },
        {
          merge: true
        }
      )
    }
  }
}
</script>
```

</div>
</details>

### 管理者用のViewの作成
<details>
<summary>**長いのでアコーディオンにしてあります**</summary>
<div>

```Vue:src/views/AdminHome.vue
<template>
  <el-container>
    <el-main v-if="!question">
      <hr class="separator">
      <el-input placeholder="質問を入力してください" class="question" v-model="edit.question.text"></el-input>
      <hr class="separator">
      <el-input
        class="selection"
        placeholder="選択肢を入力してください"
        v-model="selectionText.text"
        v-for="(selectionText, index) in edit.selections"
        :key="index"
      ></el-input>
      <hr class="separator">
      <el-button type="primary" class="button" @click="save($event)">保存</el-button>
    </el-main>
    <el-main v-else>
      <hr class="separator">
      <p>{{ question.text }}</p>
      <hr class="separator">
      <table class="table">
        <thead>
          <tr>
            <th>回答</th>
            <th>回答数</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="selectionRef in question.selectionRefs" :key="selectionRef.index">
            <td>{{ selectionRef.text }}</td>
            <td>{{ (selectionRef.answerRefs) ? selectionRef.answerRefs.length : 0 }}</td>
          </tr>
        </tbody>
      </table>
      <hr class="separator">
      <el-button type="warning" class="button" @click="close($event)" v-if="!question.isClosed">解答締切</el-button>
      <el-button type="danger" class="button" @click="finish($event)" v-else>次の質問へ</el-button>
    </el-main>
  </el-container>
</template>

<style lang="scss" scoped>
.separator {
  margin: 1.5rem 0;
  border: none;
  border-bottom: 1px solid #999;
}
.selection {
  & + & {
    margin-top: 1rem;
  }
}
.table {
  width: 100%;
  color: #999;
  & thead th {
    border-bottom: 1px solid #ccc;
  }
  & th, & td {
    padding: .5rem;
  }
  & td:last-child {
    text-align: right;
  }
}
.button {
  display: block;
  margin: 0 auto;
}
</style>

<script>
import { db } from '@/plugins/firebase'

const questions = db.collection('questions')
const activeQuestions = questions.where('isFinished', '==', false)
const selections = db.collection('selections')

export default {
  data () {
    return {
      questions: [],
      edit: {
        question: {
          text: ''
        },
        selections: [
          {
            text: ''
          },
          {
            text: ''
          },
          {
            text: ''
          },
          {
            text: ''
          }
        ]
      }
    }
  },
  async created () {
    // dataのquestionsにバインド
    // note: maxRefDepthをデフォルトの2から変更するため、ここで処理
    await this.$bind(
      'questions',
      activeQuestions,
      {
        maxRefDepth: 3
      }
    )
  },
  computed: {
    /**
     * @var Object 問題
     */
    question: {
      get () {
        return (this.questions.length) ? this.questions[0] : null
      }
    }
  },
  methods: {
    /**
     * 保存処理
     */
    async save (event) {
      // バリデーション
      if (!this.edit.question.text) {
        alert('質問を入力してください')
        return
      }
      let selectionTextJoined = ''
      for (const selection of this.edit.selections) {
        selectionTextJoined += selection.text
      }
      if (!selectionTextJoined) {
        alert('選択肢を入力してください')
        return
      }

      // 保存処理
      const selectionRefs = []
      for (const selection of this.edit.selections) {
        if (!selection.text) {
          continue
        }
        // 選択肢保存処理
        const selectionRef = await selections.add({
          text: selection.text,
          answerRefs: []
        })
        // Refを保持
        selectionRefs.push(selectionRef)
      }
      // 質問保存処理
      await questions.add({
        text: this.edit.question.text,
        isFinished: false,
        isClosed: false,
        selectionRefs: selectionRefs
      })
      // 入力値クリア
      this.edit.question.text = ''
      for (const index in this.edit.selections) {
        this.edit.selections[index].text = ''
      }
    },
    /**
     * 解答締切処理
     */
    async close (event, selection) {
      const questionRef = await questions.doc(this.question.id)
      // 質問のステータスを更新
      await questionRef.set(
        {
          isClosed: true
        },
        {
          merge: true
        }
      )
    },
    /**
     * 質問終了処理
     */
    async finish (event, selection) {
      // 質問のステータスを更新
      const questionRef = await questions.doc(this.question.id)
      await questionRef.set(
        {
          isFinished: true
        },
        {
          merge: true
        }
      )
    }
  }
}
</script>

```

</div>
</details>


## 動作確認
```Bash
$ npm run serve
```
ブラウザでアクセスし、動作確認を行う
ユーザ画面： http://localhost:8080/
管理画面： http://localhost:8080/admin

## デプロイ

### Functions の環境構築

```Bash
$ cd functions
$ npm install
$ cd ../
```

### vueプロジェクトのbuild
```Bash
$ npm run build
```

### デプロイの設定
```diff:firebase.json
  "hosting": {
-     "public": "public",
+     "public": "dist",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }
```

```diff:firestore.rules
 service cloud.firestore {
   match /databases/{database}/documents {
     match /{document=**} {
-      allow read, write;
+      allow read, write: if true;
     }
   }
 }
```

### デプロイ
```Bash
$ firebase deploy

=== Deploying to 'realtime-answer-xxxxx'...

i  deploying firestore, functions, hosting
Running command: npm --prefix "$RESOURCE_DIR" run lint

> functions@ lint /path/to/workspace/realtime-answer/functions
> eslint .

✔  functions: Finished running predeploy script.
i  firestore: checking firestore.rules for compilation errors...
i  firestore: reading indexes from firestore.indexes.json...
✔  firestore: rules file firestore.rules compiled successfully
i  functions: ensuring necessary APIs are enabled...
✔  functions: all necessary APIs are enabled
i  firestore: uploading rules firestore.rules...
✔  firestore: deployed indexes in firestore.indexes.json successfully
i  functions: preparing functions directory for uploading...
i  hosting[realtime-answer-xxxxx]: beginning deploy...
i  hosting[realtime-answer-xxxxx]: found 10 files in dist
✔  hosting[realtime-answer-xxxxx]: file upload complete
✔  firestore: released rules firestore.rules to cloud.firestore
i  hosting[realtime-answer-xxxxx]: finalizing version...
✔  hosting[realtime-answer-xxxxx]: version finalized
i  hosting[realtime-answer-xxxxx]: releasing new version...
✔  hosting[realtime-answer-xxxxx]: release complete

✔  Deploy complete!

Please note that it can take up to 30 seconds for your updated functions to propagate.
Project Console: https://console.firebase.google.com/project/realtime-answer-xxxxx/overview
Hosting URL: https://realtime-answer-xxxxx.firebaseapp.com
```

Hosting URLに表示されたURLで動作確認を行う

## まとめ
- リアルタイム同期は意外と簡単に実装できる
- NoSQLのDB設計は、RDBとは考え方が違うので頭の切り替えが必要
- Firebaseは、プロジェクトのプロトタイプなどで使えそう。(本番に使うにはよーく考えないといけない)
- 認証周りも、もう少し触ってみたい