Help us understand the problem. What is going on with this article?

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

※UIライブラリにVuetify、効果音の実装にHowlerを使用(Web Audio API はめんどくさい!!)

早押しボタンの画面

スクリーンショット 2020-06-28 20.33.15.png

進行役の画面

スクリーンショット 2020-06-28 20.32.15.png

仕様

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

実装の指針

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

当初のコード

RDB上のデータ

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())
    })
}

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

問題点

数百ミリ秒の誤差でほぼ同時にボタンが押された場合、押したのが2番目以降で解答権を獲得できなかったにも関わらず、一瞬だけ解答権獲得の判定がされる。
これは、最初に押したか or 2番目以降に押したかの判定中にもcomputedは随時更新され、さらにrespondentObserverで監視しているRDBのクイズ解答者の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()
            })
    })
}

RDBのtransactionは引数のpostで現在のRDB上のデータを取得でき、返した値でRDBを更新できる非同期関数です。
なのでVueのインスタンスにtransactionが処理中かどうかのフラグを持たせ、
ボタンクリック時にフラグをtrueにし、その間は判定を待つ。
そしてtransactionの終了時にresolveし、thenでフラグをfalseにすることで最終的な判定が終わってから画面が更新されるようになりました。

おわりに

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

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

nonsugarless
フロントエンドしてます。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした