Nuxt.js + Firebaseで早押しボタンを作ってみた

# はじめに
昨年会社のイベントでクイズ大会を行いました。
参加者は100人弱で、人数分早押しボタンを用意するわけにもいかないのでWebアプリを構築したのですが、早押し判定でハマったので今更ながらサンプル版として残しておきたいと思います。
この記事ではFirebaseの基本的なセットアップや実装、Vue・Nuxtについての解説は行いません。

# 環境
* Yarn v1.15.2
* Node v12.13.0
* Nuxt v2.13.2
* Firebase v7.15.5

# 完成品

サンプルをgithubにて公開しています。
[quizBuzzer](https://github.com/nonsugarless/quizBuzzer)

※UIライブラリに[Vuetify](https://vuetifyjs.com/ja/)、効果音の実装に[Howler](https://howlerjs.com/)を使用(Web Audio API はめんどくさい！！)

## 早押しボタンの画面
<img width="633" alt="スクリーンショット 2020-06-28 20.33.15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253045/077320f5-e81d-c7f4-4d17-f6ebbb77d5eb.png">

## 進行役の画面
<img width="772" alt="スクリーンショット 2020-06-28 20.32.15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/253045/d2962e01-fcf1-4439-0517-9b08adaa1eaf.png">


# 仕様
* クイズ参加者と進行役で別の画面を操作
* 参加者はそれぞれのデバイスの参加者用の画面で早押しボタンを押して解答権を獲得できる
* 進行役は専用の管理画面で事前に決められた問題の切り替え、参加者の解答に応じてピンポン・ブーを鳴らすことができる
* クイズの正誤判定は人力(笑)

# 実装の指針
* Realtime databaseには現在表示されている問題のIDと参加者のIDを保存する
* 参加者のIDは、手軽さを考慮しログイン機能を持たせたくないため、クライアントサイドで生成したランダムな文字列を使用

# 当初のコード
Realtime database上のデータ

```
quiz-state: {
  current-quiz: 1,
  respondent-id: "",
}
```
```@/pages/index.vue
<template>
	<div>
		<p class="text">{{ currentQuiz.text }}</p>
		<v-btn
			v-if="waitingClicked"
			:disabled="!!respondentId"
			type="button"
			x-large
			outlined
			color="indigo"
			@click="postQuizState"
			>PUSH!</v-btn
		>
		<p v-else-if="clickedAtFirst">解答権GET!</p>
		<p v-else>他の人に解答権があります</p>
	</div>
</template>

<script>
import Vue from 'vue'
import { Howl } from 'howler'
import {
	postQuiz,
	respondentObserver,
	currentQuizObserver,
} from '@/api/firebase'
import quizData from '@/static/data/quiz'

export default Vue.extend({
	data: () => ({
		quizData,
		currentQuizIndex: 0,
		userId: '',
		respondentId: '',
		sound: new Howl({
			src: ['/audio/sound-btn.wav'],
		}),
	}),
	computed: {
		currentQuiz() {
			return this.quizData[this.currentQuizIndex]
		},
		waitingClicked() {
			return !this.clickedAtFirst && !this.clickedAtSecondAndLater
		},
		clickedAtFirst() {
			const result =
				this.respondentId &&
				this.userId === this.respondentId
			return result
		},
		clickedAtSecondAndLater() {
			return !!this.respondentId && this.userId !== this.respondentId
		},
	},
	watch: {
		clickedAtFirst(newVal) {
			if (newVal) {
				this.sound.play()
			}
		},
	},
	created() {
		currentQuizObserver((val) => {
			this.currentQuizIndex = val
		})
		respondentObserver((val) => {
			this.respondentId = val
		})
	},
	mounted() {
		this.userId = this.$store.state.userId
	},
	methods: {
		postQuizState() {
			if (this.respondentId) return
			postQuiz(this.userId)
		},
	},
})
</script>

```


```@/api/firebase.js
import { db } from '@/plugins/firebase'

const respondentRef = db.ref('/quiz-state/respondent-id')

export function postQuiz(val) {
	respondentRef
		// eslint-disable-next-line consistent-return
		.transaction((post) => {
			if (!post) return val
		})
}

export function respondentObserver(callback) {
	respondentRef.on('value', (snapshot) => {
		callback(snapshot.val())
	})
}

export function resetBtn() {
	respondentRef.set('')
}

const currentQuizRef = db.ref('/quiz-state/current-quiz')

export function changeQuiz(val) {
	currentQuizRef.set(val)
}

export function currentQuizObserver(callback) {
	currentQuizRef.on('value', (snapshot) => {
		callback(snapshot.val() === null ? 0 : snapshot.val())
	})
}

```
Realtime database上の解答者のIDのsnapshotとユーザーIDを比較して最初にクリックしたか or 2番目以降にクリックしたかの判定を行なっています。

# 問題点
数百ミリ秒の誤差でほぼ同時にボタンが押された場合、押したのが2番目以降で解答権を獲得できなかったにも関わらず、一瞬だけ解答権獲得の判定がされる。
これは、最初に押したか or 2番目以降に押したかの判定中にもcomputedは随時更新され、さらに`respondentObserver`で監視しているRealtime databaseのクイズ解答者のIDのsnapshotは一時的にそのデバイスの更新が優先された値になることがあるためです。


# 解決したコード
```@/pages/index.vue
<script>
...

export default Vue.extend({
	data: () => ({
		...
		isTransactioning: false,
		...
	}),
	computed: {
		...
		clickedAtFirst() {
			const result =
				!this.isTransactioning &&
				this.respondentId &&
				this.userId === this.respondentId
			return result
		},
		...
	},
    ...
	methods: {
		postQuizState() {
			this.isTransactioning = true
			if (this.respondentId) return
			postQuiz(this.userId).then(() => {
				this.isTransactioning = false
			})
		},
	},

...
</script>
```

```@/api/firebase.js
export function postQuiz(val) {
	return new Promise((resolve) => {
		respondentRef
			// eslint-disable-next-line consistent-return
			.transaction((post) => {
				if (!post) return val
			})
			.then(() => {
				resolve()
			})
			.catch(() => {
				resolve()
			})
	})
}
```
Realtime databaseのtransactionは引数のpostで現在のRealtime database上のデータを取得でき、返した値でRealtime databaseを更新できる非同期関数です。
なのでVueのインスタンスにtransactionが処理中かどうかのフラグを持たせ、
ボタンクリック時にフラグをtrueにし、その間は判定を待つ。
そしてtransactionの終了時にresolveし、thenでフラグをfalseにすることで最終的な判定が終わってから画面が更新されるようになりました。

# おわりに
実際にプロダクトで使用する際はユーザーIDのランダムな文字列をハッシュ値にする、はたまたログイン制にしてFirebaseのセキュリティルールもしっかり設定するなど課題はありますね。

あと今回の実装では、すでにRealtime databaseのデータがあればtransactionで値を返さない、というロジックにしたので`eslint-disable-next-line`に逃げてしまったのですが他に良い方法があれば是非ご教授ください🙏