專案演示
專案原始碼
教程說明
本教程適合對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專案,全部開發完畢,下一期,豆約翰會帶大家見識一箇中型的專案,我們們循序漸進,一起加油。
最後
為了將來還能找到我