Project presentations

Project presentations

Program source code

Program source code

Accompanying explanation video

Accompanying the first section of the video

Accompanying the second section of the video

Other versions

The React version

Wechat small program version

tutorial

This tutorial is intended for those who have some basic knowledge of Vue, but don’t know how to use it in a comprehensive way, and haven’t yet developed a small App from scratch using Vue. This tutorial does not cover all the Vue concepts, but rather builds a complete small project from zero to one. The current online tutorials are not just scattered knowledge points; Throw out a big open source project that a beginner reader will struggle to download and run, let alone understand how the project was developed step by step. This tutorial attempts to fill that gap.

1. Initialize the project

1.1 Creating a Project using the Vue CLI

If you don’t already have VueCLI installed, run the following command to install or upgrade:

npm install --global @vue/cli
Copy the code

Enter the following command on the command line to create the Vue project:

vue create vue-quiz
Copy the code
Vue CLI v4.3.1? Please pick a preset: > default (babel, eslint) Manually select featuresCopy the code

Default: select Babel and eslint by default and press Enter to directly load the package

Manually: user-defined Select feature configuration. After the selection is complete, the package is installed

Select the first default.

When the installation is complete, the following information is displayed:

Enter your project directory
cd vue-quiz

# Start the development service
npm run serve
Copy the code

The command line outputs the HTTP access address of the project. Open your browser and enter any of these addresses to access

If you can see this page, congratulations, the project has been created successfully.

1.2 Initial Directory Structure

Once the project is created, let’s look at the initial directory structure:

1.3 Adjust the initial directory structure to achieve the game setting page

The default generated directory structure did not meet our development needs, so we needed to make some custom changes.

Here we deal with the following:

  • Delete the initialized default file
  • Added tweaks to the directory structure we need

Delete the default example file:

  • src/components/HelloWorld.vue
  • src/assets/logo.png

Modify package.json to add project dependencies:

 "dependencies": {
    "axios": "^ 0.19.2"."bootstrap": "^ 4.4.1." "."bootstrap-vue": "^ 2.5.0"."core-js": "^ 3.6.5." "."vue": "^ 2.6.11." "."vue-router": "^ 3.1.5." "
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~ 4.4.0"."@vue/cli-plugin-eslint": "~ 4.4.0"."@vue/cli-plugin-router": "~ 4.4.0"."@vue/cli-service": "~ 4.4.0"."babel-eslint": "^ 10.1.0"."eslint": "^ 6.7.2." "."eslint-plugin-vue": "^ 6.2.2." "."vue-template-compiler": "^ 2.6.11." "
  },
Copy the code

Then run YARN Install to install the dependency.

Modify the project entry file main.js to introduce bootstrap-vue.

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import BootstrapVue from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'

Vue.config.productionTip = false

Vue.use(BootstrapVue)

const state = { questions: [] }

new Vue({
  router,
  data: state,
  render: h => h(App)
}).$mount('#app')

Copy the code

Define a State object to share answer data (answer page and result page shared)

const state = { questions: [] }
Copy the code

Add the eventBus.js message bus to the SRC directory to pass messages between components as follows:

import Vue from 'vue'
const EventBus = new Vue()
export default EventBus
Copy the code

Modify app. vue, CSS style omitted, please refer to the source code.

<template>
  <div id="app" class="bg-light">
    <Navbar></Navbar>
    <b-alert :show="dismissCountdown" dismissible variant="danger" @dismissed="dismissCountdown = 0">
      {{ errorMessage }}
    </b-alert>
    <div class="d-flex justify-content-center">
      <b-card no-body id="main-card" class="col-sm-12 col-lg-4 px-0">
        <router-view></router-view>
      </b-card>
    </div>
  </div>
</template>

<script>
import EventBus from './eventBus'
import Navbar from './components/Navbar'

export default {
  name: 'app',
  components: {
    Navbar
  },
  data() {
    return {
      errorMessage: ' ',
      dismissSecs: 5,
      dismissCountdown: 0
    }
  },
  methods: {
    showAlert(error) {
      this.errorMessage = error
      this.dismissCountdown = this.dismissSecs
    }
  },
  mounted() {
    EventBus.$on('alert-error', (error) => {
      this.showAlert(error)
    })
  },
  beforeDestroy() {
    EventBus.$off('alert-error')
  }
}
</script>
Copy the code

Added Components/navbar. vue to define the navigation section.

<template>
    <b-navbar id="navbar" class="custom-info" type="dark" sticky>
      <b-navbar-brand id="nav-logo" :to="{ name: 'home' }">Vue-Quiz</b-navbar-brand>

      <b-navbar-nav class="ml-auto">
        <b-nav-item :to="{ name: 'home' }">New Game </b-nav-item>
        <b-nav-item href="#" target="_blank">About</b-nav-item>
      </b-navbar-nav>
    </b-navbar>
</template>

<script>
export default {
  name: 'Navbar'
}
</script>

<style scoped>

</style>


Copy the code

Add router/index.js in the SRC directory to define the home route.

import Vue from 'vue'
import VueRouter from 'vue-router'
import MainMenu from '.. /views/MainMenu.vue'


Vue.use(VueRouter)

const routes = [
  {
    name: 'home',
    path: '/',
    component: MainMenu
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router

Copy the code

SRC added views/ mainmenu. vue, MainMenu mainly contains GameForm component.

<template>
<div>
  <b-card-header class="custom-info text-white font-weight-bold">New Game</b-card-header>
  <b-card-body class="h-100">
    <GameForm @form-submitted="handleFormSubmitted"></GameForm>
  </b-card-body>
</div>
</template>

<script>
import GameForm from '.. /components/GameForm'

export default {
  name: 'MainMenu',
  components: {
    GameForm
  },
  methods: {
    /** Triggered by custom 'form-submitted' event from GameForm child component. 
     * Parses formData, and route pushes to 'quiz' with formData as query
     * @public
     */
    handleFormSubmitted(formData) {
      const query = formData
      query.difficulty = query.difficulty.toLowerCase()
      this.$router.push({ name: 'quiz', query: query })
    }
  }
}
</script>


Copy the code

New SRC/components/GameForm vue, realize the initial setup.

<template>
  <div>
    <LoadingIcon v-if="loading"></LoadingIcon>

    <div v-else>
      <b-form @submit="onSubmit">
        <b-form-group 
          id="input-group-number-of-questions"
          label="Select a number"
          label-for="input-number-of-questions"
          class="text-left"
        >
          <b-form-input
            id="input-number-of-questions"
            v-model="form.number"
            type="number"
            :min="minQuestions"
            :max="maxQuestions"
            required 
            :placeholder="`Between ${minQuestions} and ${maxQuestions}`"
          ></b-form-input>
        </b-form-group>

        <b-form-group id="input-group-category">
          <b-form-select
            id="input-category"
            v-model="form.category"
            :options="categories"
          ></b-form-select>
        </b-form-group>

        <b-form-group id="input-group-difficulty">
          <b-form-select
            id="input-difficulty"
            v-model="form.difficulty"
            :options="difficulties"
          ></b-form-select>
        </b-form-group>

        <b-form-group id="input-group-type">
          <b-form-select
            id="input-type"
            v-model="form.type"
            :options="types"
          ></b-form-select>
        </b-form-group>

        <b-button type="submit" class="custom-success">Submit</b-button>
      </b-form>
    </div>
  </div>
</template>

<script>
import LoadingIcon from './LoadingIcon'
import axios from 'axios'

export default {
  components: {
    LoadingIcon
  },
  data() {
    return {
      // Form data, tied to respective inputs
      form: {
        number: ' ',
        category: ' ',
        difficulty: ' '.type: ' '
      },
      // Used for form dropdowns and number input
      categories: [{ text: 'Category', value: ' ' }],
      difficulties: [{ text: 'Difficulty', value: ' ' }, 'Easy'.'Medium'.'Hard'],
      types: [
        { text: 'Type', value: ' ' }, 
        { text: 'Multiple Choice', value: 'multiple' }, 
        { text: 'True or False', value: 'boolean'}
      ],
      minQuestions: 10,
      maxQuestions: 20,
      // Used for displaying ajax loading animation OR form
      loading: true}},created() {
    this.fetchCategories()
  },
  methods: {
    fetchCategories() {
      axios.get('https://opentdb.com/api_category.php')
      .then(resp => resp.data)
      .then(resp => {
        resp.trivia_categories.forEach(category => {
          this.categories.push({text: category.name, value: `${category.id}`})}); this.loading =false;
      })
    },
    onSubmit(evt) {
      evt.preventDefault()
       /** Triggered on form submit. Passes form data
        * @event form-submitted
        * @type {number|string}
        * @property {object}
        */
      this.$emit('form-submitted', this.form)
    }
  }
}
</script>
Copy the code

The GameForm component, which uses AXIos to make a request for all subject categories:

axios.get('https://opentdb.com/api_category.php')
Copy the code

New SRC/components/LoadingIcon vue, the asynchronous request data did not return, rendering waiting icon.

<template>
  <div id="loading-icon" class="h-100 d-flex justify-content-center align-items-center">
    <img src="@/assets/ajax-loader.gif" alt="Loading Icon">
  </div>
</template>

<script>
export default {
  name: 'LoadingIcon'
}
</script>

Copy the code

Add the animation file SRC /assets/ajax-loader. GIF, please refer to the project source code.

1.4 Running the Project

yarn run serve
Copy the code

2. Answer page development

2.1 Modifying a Route

Modify the router/index. Js:

import Vue from 'vue'
import VueRouter from 'vue-router'
import MainMenu from '.. /views/MainMenu.vue'
import GameController from '.. /views/GameController.vue'

Vue.use(VueRouter)

const routes = [
  {
    name: 'home',
    path: '/',
    component: MainMenu
  }, {
    name: 'quiz',
    path: '/quiz',
    component: GameController,
    props: (route) => ({ 
      number: route.query.number, 
      difficulty: route.query.difficulty, 
      category: route.query.category,
      type: route.query.type
    })
  }
]

const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
  routes
})

export default router
Copy the code

2.2 Answer Page

New views/GameController. Vue

This page is the most important module of this project, showing the questions and dealing with the answers submitted by users.

1. The fetchQuestions function obtains the list of questions by requesting the remote interface.

2. SetQuestions saves a list of remote responses to a local array.

3. OnAnswerSubmit processes the user-submitted options and calls the nextQuestion function to return to the nextQuestion.

<template>
  <div class="h-100">
    <LoadingIcon v-if="loading"></LoadingIcon>
    <Question :question="currentQuestion" @answer-submitted="onAnswerSubmit" v-else></Question>
  </div>
</template>

<script>
import EventBus from '.. /eventBus'
import ShuffleMixin from '.. /mixins/shuffleMixin'
import Question from '.. /components/Question'
import LoadingIcon from '.. /components/LoadingIcon'
import axios from 'axios'

export default {
  name: 'GameController',
  mixins: [ShuffleMixin],
  props: {
    /** Number of questions */
    number: {
      default: '10'.type: String,
      required: true
    },
    /** Id of category. Empty string if not included in query */
    category: String,
    /** Difficulty of questions. Empty string if not included in query */
    difficulty: String,
    /** Type of questions. Empty string if not included in query */
    type: String
  },
  components: {
    Question,
    LoadingIcon
  },
  data() {
    return {
      // Array of custom question objects. See setQuestions() for format
      questions: [],
      currentQuestion: {},
      // Used for displaying ajax loading animation OR form
      loading: true}},created() {
    this.fetchQuestions()
  },
  methods: {
    /** Invoked on created()
     * Builds API URL from query string (props).
     * Fetches questions from API.
     * "Validates" return from API and either routes to MainMenu view, or invokes setQuestions(resp).
     * @public
     */
    fetchQuestions() {
      let url = `https://opentdb.com/api.php?amount=${this.number}`
      if (this.category)   url += `&category=${this.category}`
      if (this.difficulty) url += `&difficulty=${this.difficulty}`
      if (this.type)       url += `&type=${this.type}`

      axios.get(url)
        .then(resp => resp.data)
        .then(resp => {
          if (resp.response_code === 0) {
            this.setQuestions(resp)
          } else {
            EventBus.$emit('alert-error'.'Bad game settings. Try another combination.')
            this.$router.replace({ name: 'home' })
          }
        })
    },
    /** Takes return data from API call and transforms to required object setup. 
     * Stores return in $root.$data.state.
     * @public
     */
    setQuestions(resp) {
      resp.results.forEach(qst => {
        const answers = this.shuffleArray([qst.correct_answer, ...qst.incorrect_answers])
        const question = {
          questionData: qst,
          answers: answers,
          userAnswer: null,
          correct: null
        }
        this.questions.push(question)
      })
      this.$root.$data.state = this.questions
      this.currentQuestion = this.questions[0]
      this.loading = false
    },
    /** Called on submit.
     * Checks if answer is correct and sets the user answer.
     * Invokes nextQuestion().
     * @public
     */
    onAnswerSubmit(answer) {
      if (this.currentQuestion.questionData.correct_answer === answer) {
        this.currentQuestion.correct = true
      } else {
        this.currentQuestion.correct = false
      }
      this.currentQuestion.userAnswer = answer
      this.nextQuestion()
    },
    /** Filters all unanswered questions, 
     * checks if any questions are left unanswered, 
     * updates currentQuestion if so, 
     * or routes to "result" if not.
     * @public
     */
    nextQuestion() { const unansweredQuestions = this.questions.filter(q => ! q.userAnswer)if (unansweredQuestions.length > 0) {
        this.currentQuestion = unansweredQuestions[0]
      } else {
        this.$router.replace({ name: 'result' })
      }
    }
  }
}
</script>


Copy the code

New \ SRC \ mixins \ shuffleMixin js

Scrambles the answer to the question because the remote returns regular answers. Mixins are mixins, which can be mixed into one of our pages or components to supplement the functions of the page or component for easy reuse.

const ShuffleMixin = {
    methods: {
      shuffleArray: (arr) => arr
        .map(a => [Math.random(), a])
        .sort((a, b) => a[0] - b[0])
        .map(a => a[1])
    }
  }

  export default ShuffleMixin
Copy the code

New SRC/components/Question. Vue

<template>
  <div>
    <QuestionBody :questionData="question.questionData"></QuestionBody>

    <b-card-body class="pt-0">
      <hr>
      <b-form @submit="onSubmit">
        <b-form-group
          label="Select an answer:"
          class="text-left"
        >
          <b-form-radio 
            v-for="(ans, index) of question.answers" 
            :key="index" 
            v-model="answer" 
            :value="ans"
          >
            <div v-html="ans"></div>
          </b-form-radio>
        </b-form-group>

        <b-button type="submit" class="custom-success">Submit</b-button>
      </b-form>
    </b-card-body>
  </div>
</template>

<script>
import QuestionBody from './QuestionBody'

export default {
  name: 'Question',
  props: {
    /** Question object containing questionData, possible answers, and user answer information. */
    question: {
      required: true.type: Object
    }
  },
  components: {
    QuestionBody
  },
  data() {
    return {
      answer: null
    }
  },
  methods: {
    onSubmit(evt) {
      evt.preventDefault()
      if (this.answer) {
        /** Triggered on form submit. Passes user answer.
        * @event answer-submitted
        * @type {number|string}
        * @property {string}
        */
        this.$emit('answer-submitted', this.answer)
        this.answer = null
      }
    } 
  }
}
</script>


Copy the code

New SRC/components/QuestionBody. Vue

<template>
  <div>
    <b-card-header :class="variant" class="d-flex justify-content-between border-bottom-0">
      <div>{{ questionData.category }}</div>
      <div class="text-capitalize">{{ questionData.difficulty }}</div>
    </b-card-header>
    <b-card-body>
      <b-card-text class="font-weight-bold" v-html="questionData.question"></b-card-text>
    </b-card-body>
  </div>
</template>

<script>
export default {
  name: 'QuestionBody',
  props: {
    /** Object containing question data as given by API. */
    questionData: {
      required: true.type: Object
    }
  },
  data() {
    return {
      variants: { easy: 'custom-success', medium: 'custom-warning', hard: 'custom-danger', default: 'custom-info' },
      variant: 'custom-info'
    }
  },
  methods: {
    /** Invoked on mounted().
     * Sets background color of card header based on question difficulty.
     * @public
     */
    setVariant() {
      switch (this.questionData.difficulty) {
        case 'easy':
          this.variant = this.variants.easy
          break
        case 'medium':
          this.variant = this.variants.medium
          break
        case 'hard':
          this.variant = this.variants.hard
          break
        default:
          this.variant = this.variants.default
          break}}},mounted() {
    this.setVariant()
  }
}
</script>

<docs>
Simple component displaying question category, difficulty and question text. 
Used on both Question component and Answer component.
</docs>
Copy the code

Run:

yarn run serve
Copy the code

Startup success:

If you can see this page, congratulations, the project is now successful.

2.3 This project directory structure

If you get lost, download the source code for comparison:

3 Achieve the final result display page

Modify router/index.js again

import Vue from 'vue'
import VueRouter from 'vue-router'
import MainMenu from '.. /views/MainMenu.vue'
import GameController from '.. /views/GameController.vue'
import GameOver from '.. /views/GameOver'

Vue.use(VueRouter)

const routes = [
  ...
  {
    name: 'result',
    path: '/result',
    component: GameOver
  }
]

...

Copy the code

New SRC/views/GameOver. Vue:

<template>
  <div class="h-100">
      <b-card-header class="custom-info text-white font-weight-bold">Your Score: {{ score }} / {{ maxScore }}</b-card-header>
    <Answer v-for="(question, index) of questions" :key="index" :question="question"></Answer>
  </div>
</template>

<script>
import Answer from '.. /components/Answer'

export default {
  name: 'GameOver',
  components: {
    Answer
  },
  data() {
    return {
      questions: [],
      score: 0,
      maxScore: 0
    }
  },
  methods: {
    /** Invoked on created().
     * Grabs data from $root.$data.state.
     * Empties $root.$data.state => This is done to ensure data is cleared when starting a new game.
     * Invokes setScore().
     * @public
     */
    setQuestions() {
      this.questions = this.$root.$data.state || []
      this.$root.$data.state = []
      this.setScore()
    },
    /** Computes maximum possible score (amount of questions * 10)
     * Computes achieved score (amount of correct answers * 10)
     * @public
     */
    setScore() {
      this.maxScore = this.questions.length * 10
      this.score = this.questions.filter(q => q.correct).length * 10
    }
  },
  created() {
    this.setQuestions();
  }
}
</script>


Copy the code

New SRC \ components \ Answer vue

<template>
  <div>
    <b-card no-body class="answer-card rounded-0">
      <QuestionBody :questionData="question.questionData"></QuestionBody>
      <b-card-body class="pt-0 text-left">
        <hr class="mt-0">
        <b-card-text 
          class="px-2" 
          v-html="question.questionData.correct_answer"
        >
        </b-card-text>
        <b-card-text 
          class="px-2" 
          :class="{ 'custom-success': question.correct, 'custom-danger': ! question.correct }"
          v-html="question.userAnswer"
        >
        </b-card-text>
      </b-card-body>
    </b-card>
  </div>
</template>

<script>
import QuestionBody from './QuestionBody'

export default {
  name: 'Answer',
  props: {
    /** Question object containing questionData, possible answers, and user answer information. */
    question: {
      required: true.type: Object
    }
  },
  components: {
    QuestionBody
  }
}
</script>

<style scoped>
.answer-card >>> .card-header {
  border-radius: 0;
}
</style>

Copy the code

3.1 Running projects

yarn run serve
Copy the code

3.2 Project Structure

Project summary

Thank you very much for coming here with John Bean. So far, we have completed the development of a small Vue project. In the next issue, John Bean will show you a medium-sized project.

The last

To find me in the future