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