項目演示php
項目源碼css
配套講解視頻第一節html
配套講解視頻第二節vue
本教程適合對Vue基礎知識有一點了解,但不懂得綜合運用,還不曾使用Vue從頭開發過一個小型App的讀者。本教程不對全部的Vue知識點進行講解,而是手把手一步步從0到1,作出一個完整的小項目。目前網上的教程不是隻有零散的知識點講解;就是拋出一個開源的大項目,初級讀者下載下來後,運行起來都很費勁,更談不上理解這個項目是如何一步步開發出來的了。本教程試圖彌補這個空白。ios
若是你尚未安裝 VueCLI,請執行下面的命令安裝或是升級:git
npm install --global @vue/cli
在命令行中輸入如下命令建立 Vue 項目:ajax
vue create vue-quiz
Vue CLI v4.3.1 ? Please pick a preset: > default (babel, eslint) Manually select features
default:默認勾選 babel、eslint,回車以後直接進入裝包vue-router
manually:自定義勾選特性配置,選擇完畢以後,纔會進入裝包npm
選擇第 1 種 default.json
安裝結束,命令提示你項目建立成功,按照命令行的提示在終端中分別輸入:
# 進入你的項目目錄 cd vue-quiz # 啓動開發服務 npm run serve
啓動成功,命令行中輸出項目的 http 訪問地址。 打開瀏覽器,輸入其中任何一個地址進行訪問
若是能看到該頁面,恭喜你,項目建立成功了。
項目建立好之後,下面咱們來了解一下初始目錄結構:
默認生成的目錄結構不知足咱們的開發需求,因此須要作一些自定義改動。
這裏主要處理下面的內容:
刪除默認示例文件:
修改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等待動畫文件,請參考項目源碼。
yarn run serve
修改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
新增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
啓動成功:
若是能看到該頁面,恭喜你,項目到此成功了。
若是你走丟,請下載源碼進行對比:
再次修改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>
yarn run serve
很感謝您和豆約翰走到了這裏,至此咱們一個小型的Vue項目,所有開發完畢,下一期,豆約翰會帶你們見識一箇中型的項目,我們按部就班,一塊兒加油。
本系列文章首發於做者的微信公衆號[豆約翰],想嚐鮮的朋友,請微信搜索關注。
有什麼問題也能夠加我微信[tiantiancode]一塊兒討論。
爲了未來還能找到我