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


    项目演示

    项目演示

    项目源码

    项目源码

    配套讲解视频

    配套讲解视频第一节
    配套讲解视频第二节

    微信小程序版

    微信小程序版实战教程

    教程说明

    本教程适合对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 访问地址。 打开浏览器,输入其中任何一个地址进行访问

    image-20200707121732592

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

    1.2 初始目录结构

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

    image-20200707122944401

    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,定义导航部分。

    image-20200707125506858

    <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,实现游戏初始设置。

    image-20200707125814786

    <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
    

    image-20200707130702456

    2. 答题页面开发

    image-20200708083506391

    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

    image-20200708083544511

    <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
    

    启动成功:

    image-20200708083506391

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

    2.3 至此项目目录结构

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

    image-20200708084009828

    3 实现最终结果展示页面

    image-20200708084853745

    再次修改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 项目结构

    image-20200708085222248

    项目总结

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

    最后

    为了将来还能找到我

  • 相关阅读:
    SSRS应用理解与实现
    idea2022.1乱码问题
    yum安装mysql8
    curl fsSL
    IntelliJ IDEA 快捷键大全
    MYSQLcheck管理
    人机验证reCAPTCHA v3使用完备说明
    图像的梯度
    复数的理解
    傅里叶变换的理解
  • 原文地址:https://www.cnblogs.com/songboriceboy/p/13265777.html
Copyright © 2020-2023  润新知