项目演示

项目演示

项目源码

项目源码

配套讲解视频

配套讲解视频第一节

配套讲解视频第二节

教程说明

本教程适合对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 = falseVue.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>

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

新增srccomponentsAnswer.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项目,全部开发完毕,下一期,豆约翰会带大家见识一个中型的项目,咱们循序渐进,一起加油。

本系列文章首发于作者的微信公众号[豆约翰],想尝鲜的朋友,请微信搜索关注。

有什么问题也可以加我微信[tiantiancode]一起讨论。

最后

为了将来还能找到我