Vueで簡単クイズアプリを作ってみる

2021-06-12

概要

Vueの練習をかねて何か作ってみたいと思い、クイズアプリを作ってみました。
比較的簡単に制作できるとおもいます。

完成

GitHub Pagesにて公開しています。
https://k-logic24.github.io/vue-quiz-app/

リポジトリはこちら。
https://github.com/k-logic24/vue-quiz-app

環境

@vue/cli 4.5.13

Vue3.0 + TypeScriptの環境で制作しました。

コード

コードの中身をみていきます。
とはいえ、ざっくりとした解説になりますので、解説以外の不明な点は調べてみてください。

全体概観

まずはマウントするファイルのApp.vueがあります。
その他、問題を表示させるQuestion.vueと、結果を表示させるResult.vueがあります。

App.vueからそれぞれのコンポーネントにデータを渡す設計になっていますので、理解しやすいと思います。

変数定義ファイル

今回使用する変数を、以下のファイルに変数を記述しています。
質問内容と結果の配列オブジェクトです。

constants.ts
import { ResultProps } from "@/types";

const questions = [
  {
    question: "お酢に卵を殻ごといれると卵はどうなるでしょう?",
    hint: "小学校の自由研究でもやったよね。\n確かスケスケになったような....",
    answer: [
      {
        content: "透明な卵になる",
        iscorrect: true,
      },
      {
        content: "鏡のようになんでもうつる卵になる",
        iscorrect: false,
      },
      {
        content: "卵が溶けてなくなる",
        iscorrect: false,
      },
      {
        content: "卵が石のように堅くなる",
        iscorrect: false,
      },
    ],
  },
  {
    question: "リンカーンは大統領になる前は何をしていたでしょうか?",
    hint: "屈強そうな感じですよねー\nなにしてたんだろ。",
    answer: [
      {
        content: "プロ野球選手",
        iscorrect: false,
      },
      {
        content: "猟師",
        iscorrect: false,
      },
      {
        content: "レスラー",
        iscorrect: true,
      },
      {
        content: "タクシー運転手",
        iscorrect: false,
      },
    ],
  },
  {
    question:
      "飛行機の中で食べるように作られた野菜があります。その野菜はどれでしょう?",
    hint: "これ知っている人、いる?手軽に食べれそうなものだと思うけど....",
    answer: [
      {
        content: "ミニトマト",
        iscorrect: true,
      },
      {
        content: "パプリカ",
        iscorrect: false,
      },
      {
        content: "アボカド",
        iscorrect: false,
      },
      {
        content: "ズッキーニ",
        iscorrect: false,
      },
    ],
  },
  {
    question: "東京から見て、地球の裏側にあるのは?",
    hint: "お笑い芸人の人がいるけど、果たしてそうなのかな?\n惑わされないで。",
    answer: [
      {
        content: "ブラジル",
        iscorrect: false,
      },
      {
        content: "ウルグアイ",
        iscorrect: false,
      },
      {
        content: "アルゼンチン",
        iscorrect: false,
      },
      {
        content: "大西洋",
        iscorrect: true,
      },
    ],
  },
];

const results: ResultProps[] = [
  {
    min: 0,
    max: questions.length - 1,
    title: "Oops!!",
    description: "Please Retry it.",
  },
  {
    min: questions.length,
    max: questions.length,
    title: "Congraturation!!",
    description: "Your score is Full.",
  },
];

export { questions, results };

App.vue

一挙にいれると長くなりそうなので、ViewとLogicで分けます。

View

<template>
  <div class="wrapper">
    <!--1-->
    <transition mode="out-in" name="slidein">
      <!--2-->
      <Question
        v-if="step !== questions.length"
        :questions="questions"
        :step="step"
        @select="handleClickAnswer"
      >
        <template #progress>
          <div class="progress">
            <div class="progress-bar" :style="`width: ${progressValue}%`"></div>
          </div>
        </template>
      </Question>
      <!--3-->
      <Result
        v-else
        :title="results[resultIndex].title"
        :description="results[resultIndex].description"
        :score="`${correctCount} / ${questions.length}`"
        :missList="missList"
      >
        <template #reset>
          <button type="button" class="reset-btn" @click="handleClickReset">
            Play Again
          </button>
        </template>
      </Result>
    </transition>
  </div>
</template>

・1について
transitionコンポーネントを使用しています。これはVueが提供しているアニメーションのためのコンポーネントです。
nameに任意の名前をつけ、cssで動きを再現します。
独自のタイミングがありますので、以下の公式から参考にしてみてください。
https://jp.vuejs.org/v2/guide/transitions.html

・2について
問題内容を表示させるコンポーネントです。
ここではv-ifを使用し、問題がある限りレンダリングさせるようにします。
@selectですが、これはemit機能を使用しています。

・3について
結果を表示させるコンポーネントです。
v-elseで全ての問題に答え終えた時にレンダリングさせます。

・共通
コンポーネントの中に<template></template>で囲まれた部分がありますが、これもVueが提供するslotという機能です。
今回は名前付きslotにしています。
https://jp.vuejs.org/v2/guide/components-slots.html

Logic

import { defineComponent, reactive, toRefs, computed, onMounted } from "vue";
import shuffle from "lodash/shuffle";

import Result from "@/components/Result.vue";
import Question from "@/components/Question.vue";
import { questions, results } from "@/constants";
import { QuestionProps } from "@/types";

interface StateProps {
  step: number;
  correctCount: number;
  missList: QuestionProps[];
  questions: QuestionProps[];
}

export default defineComponent({
  components: {
    Question,
    Result,
  },
  setup() {
    const state = reactive<StateProps>({
      step: 0,
      missList: [],
      correctCount: 0,
      questions,
    });
    // 1
    const init = () => {
      state.step = 0;
      state.correctCount = 0;
      state.missList.splice(0);
    };
    // 2
    const resultIndex = computed(() => {
      let index = 0;
      results.forEach((e, i) => {
        if (e.min <= state.correctCount && e.max >= state.correctCount) {
          index = i;
        }
      });
      return index;
    });
    // 3
    const progressValue = computed(() => {
      return (state.step / state.questions.length) * 100;
    });
    // 4
    const handleClickAnswer = (payload: {
      itemNumber: number;
      iscorrect: boolean;
    }) => {
      if (payload.iscorrect) {
        state.correctCount++;
      } else {
        const missItem = state.questions[payload.itemNumber];
        state.missList.push(missItem);
      }

      state.step++;
    };
    const handleClickReset = () => {
      init();
      shuffleItems();
    };
    // 5
    const shuffleItems = () => {
      state.questions = shuffle(state.questions);
    };
    onMounted(() => {
      shuffleItems();
    });
    return {
      // 6
      ...toRefs(state),
      results,
      resultIndex,
      progressValue,
      handleClickAnswer,
      handleClickReset,
    };
  },
});

・1について
初期化の関数です。
リセットする際にデータを初期化する役割をします。

・2について
結果のオブジェクトのインデックスを計算する処理です。
resultsはconstants.tsからインポートしたものです。
ステートの正解数とresultsのmin, maxを比較し、それぞれで該当したオブジェクトのインデックスを取得します。

・3について
問題の進捗度を計算します。
(現在の問題の進捗step / 問題数) * 100

・4について
回答項目をクリックした際に、その項目が正解かどうかを判断する処理です。ステップは必ず進みます。
その際、選んだ項目が正しいならばcorrectCountを1増やします。
正しくないならば、その問題をmissListに格納し、のちに結果として表示させます。

・5について
これはあってもなくてもいいです。
毎回同じ問題順番だとつまらないので、lodashのshuffleメソッドを使用して毎回違う出題順番にするようにしています。

・6について
state変数をそのまま返すのもいいですがテンプレート側でstate.xxxと書き換える必要があり冗長なので、toRefsを使用しています。
そのままスプレッドのみで...stateとするとリアクティブが失われるので、...toRefs(state)を記述することでリアクティブを保ったまま展開させています。

Question.vue

こちらは問題表示のコンポーネントになります。

View

<template>
  <div>
    <slot name="progress"></slot>
    <div
      class="content"
      v-for="(item, itemNumber) in questions"
      :key="itemNumber"
    >
      <section v-show="itemNumber === step">
        <h2 class="title">{{ item.question }}</h2>
        <ul class="answer-list">
          <li
            v-for="(answer, index) in item.answer"
            :key="index"
            @click="selectAnswer(itemNumber, answer.iscorrect)"
          >
            {{ answer.content }}
          </li>
        </ul>
      </section>
    </div>
  </div>
</template>

Logic

import { defineComponent, PropType } from "vue";

import { QuestionProps } from "@/types";

export default defineComponent({
  props: {
    questions: {
      type: Object as PropType<QuestionProps[]>,
    },
    step: {
      type: Number,
    },
  },
  // 1
  emits: ["select"],
  setup(_, ctx) {
    const selectAnswer = (itemNumber: number, iscorrect: boolean) => {
      ctx.emit("select", { itemNumber, iscorrect });
    };

    return {
      selectAnswer,
    };
  },
});

ここでつまづいた点は、1のemitsの部分です。setup内でemitしているので大丈夫かと思いきや、emitsなしでは警告がでました。
どうやらemitsで配列形式でemits名を記述する必要があるらしいです。詳しい解説は以下の記事が役に立つと思います。
https://qiita.com/n-makoto/items/72bfc378eab464629b5f

Result.vue

結果を表示するコンポーネントです。

View

<template>
  <article class="result">
    <h2 class="result__title">{{ title }}</h2>
    <p class="result__score">Score: {{ score }}</p>
    <p class="result__description">
      {{ description }}
    </p>
    <!--1-->
    <section class="miss" v-show="missList.length">
      <h3 class="miss__title">間違えた問題</h3>
      <div class="miss-list__wrapper">
        <dl class="miss-list" v-for="(item, key) in missList" :key="key">
          <div class="miss-list__inner">
            <dt class="miss-list__term">題目:</dt>
            <dd>{{ item.question }}</dd>
          </div>
          <div class="miss-list__inner">
            <dt class="miss-list__term">ヒント:</dt>
            <dd style="white-space: pre-wrap">{{ item.hint }}</dd>
          </div>
        </dl>
      </div>
    </section>
    <slot name="reset"></slot>
  </article>
</template>

1ではmissList、つまり間違った選択があった問題があればそれを表示させるようにします。

Logic

import { defineComponent, PropType } from "vue";

import { QuestionProps } from "@/types";

export default defineComponent({
  props: {
    title: {
      type: String,
    },
    description: {
      type: String,
    },
    score: {
      type: String,
    },
    missList: {
      type: Object as PropType<QuestionProps[]>,
    },
  },
});

ロジックは特にいうことはないですね。

制作を終えて

Vueはとても便利だなと感じました。
特に3系だと、setup内でほぼ処理が完了してしまうことが大きいです。より複雑なロジックになると2系だと分離していたので追うのが難しかったですが、3系は同じ関数内に存在しているため把握しやすいです。

運営について

Natural Tearoomはシステム開発会社フロントエンドエンジニアがんちゃんが運営するメディアです。
フロントエンド技術を中心に発信しています。

· プライバシーポリシー

SNS

© 2021 天然珈琲店