从0开始,手把手教你用Vue开发一个答题App

vue

本教程适合对Vue基础知识有一点了解,但不懂得综合运用,还未曾使用Vue从头开发过一个小型App的读者。本教程不对所有的Vue知识点进行讲解,而是手把手一步步从0到1,做出一个完整的小项目。目前网上的教程不是只有零散的知识点讲解;就是抛出一个开源的大项目,初级读者下载下来后,运行起来都很费劲,更谈不上理解这个项目是如何一步步开发出来的了。本教程试图弥补这个空白。

项目演示

项目演示

项目源码

项目源码

配套讲解视频

配套讲解视频第一节

配套讲解视频第二节

微信小程序版

微信小程序版实战教程

教程说明

本教程适合对Vue基础知识有一点了解,但不懂得综合运用,还未曾使用Vue从头开发过一个小型App的读者。本教程不对所有的Vue知识点进行讲解,而是手把手一步步从0到1,做出一个完整的小项目。目前网上的教程不是只有零散的知识点讲解;就是抛出一个开源的大项目,初级读者下载下来后,运行起来都很费劲,更谈不上理解这个项目是如何一步步开发出来的了。本教程试图弥补这个空白。

1. 项目初始化

1.1使用 Vue CLI 创建项目

如果你还没有安装 VueCLI,请执行下面的命令安装或是升级:

npm install --global @vue/cli

在命令行中输入以下命令创建 Vue 项目:

vue create vue-quiz

Vue CLI v4.3.1

? Please pick a preset:

> default (babel, eslint)

Manually select features

default:默认勾选 babel、eslint,回车之后直接进入装包

manually:自定义勾选特性配置,选择完毕之后,才会进入装包

选择第 1 种 default.

安装结束,命令提示你项目创建成功,按照命令行的提示在终端中分别输入:

# 进入你的项目目录

cd vue-quiz

# 启动开发服务

npm run serve

启动成功,命令行中输出项目的 http 访问地址。 打开浏览器,输入其中任何一个地址进行访问

如果能看到该页面,恭喜你,项目创建成功了。

1.2 初始目录结构

项目创建好以后,下面我们来了解一下初始目录结构:

1.3 调整初始目录结构,实现游戏设置页面

默认生成的目录结构不满足我们的开发需求,所以需要做一些自定义改动。

这里主要处理下面的内容:

  • 删除初始化的默认文件
  • 新增调整我们需要的目录结构

删除默认示例文件:

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

修改package.json,添加项目依赖:

 "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"

},

然后运行yarn install,安装依赖。

修改项目入口文件main.js,引入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\')

定义一个state对象来共享答题数据(答题页面和结果页面共享)

const state = { questions: [] }

src目录下新增eventBus.js消息总线,用来在组件间传递消息,代码如下:

import Vue from \'vue\'

const EventBus = new Vue()

export default EventBus

修改App.vue,css样式略,请参考源码。

<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>

新增components/Navbar.vue,定义导航部分。

<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>

src目录下新增router/index.js,定义首页路由。

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

src下新增views/MainMenu.vue,MainMenu主要包含GameForm组件。

<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>

新增src/components/GameForm.vue,实现游戏初始设置。

<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>

GameForm组件,主要通过axios发起获取全部题目分类请求:

axios.get(\'https://opentdb.com/api_category.php\')

新增src/components/LoadingIcon.vue,在异步请求数据未返回时,渲染等待图标。

<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>

新增src/assets/ajax-loader.gif等待动画文件,请参考项目源码。

1.4 运行项目

yarn run serve

2. 答题页面开发

2.1 修改路由

修改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

2.2 答题页面

新增views/GameController.vue

本页面是本项目最重要的模块,展示问题,和处理用户提交的答案,简单解析一下:

1.fetchQuestions函数通过请求远程接口获得问题列表。

2.setQuestions保存远程回应的问题列表到本地数组。

3.onAnswerSubmit处理用户提交的选项,调用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>

新增\src\mixins\shuffleMixin.js

打乱问题答案,因为远程返回的答案有规律。mixins是混入的意思,可以混入到我们的某个页面或组件中,补充页面或组件功能,便于复用。

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

新增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>

新增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>

运行:

yarn run serve

启动成功:

如果能看到该页面,恭喜你,项目到此成功了。

2.3 至此项目目录结构

如果你走丢,请下载源码进行对比:

3 实现最终结果展示页面

再次修改router/index.js

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

}

]

...

新增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>

新增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>

3.1 运行项目

yarn run serve

3.2 项目结构

项目总结

很感谢您和豆约翰走到了这里,至此我们一个小型的Vue项目,全部开发完毕,下一期,豆约翰会带大家见识一个中型的项目,咱们循序渐进,一起加油。

最后

为了将来还能找到我

以上是 从0开始,手把手教你用Vue开发一个答题App 的全部内容, 来源链接: utcz.com/z/374712.html

回到顶部