简介:这是一份基于uniapp框架开发的考试答题类小程序源码,面向希望学习跨平台应用开发、了解前端答题逻辑的开发者与在校学生。项目实现了单选、多选、判断三种题型的答题界面,并支持上一题、下一题的自由切换,题目与答案均以静态数据形式预设在前端,未接入后台交互,因此更适合作为学习参考而非实际在线考试平台。压缩包为zip格式,大小约1.47MB,文件总数暂未提供明细,主要包含uniapp项目源码与配置文件,可借助HBuilderX直接导入运行与调试。目前已有2372人学习下载,热度较高。通过阅读源码,读者可以掌握uniapp的组件化开发方式、页面跳转、状态管理与数据绑定等核心用法,理解前端如何维护题目索引与答题交互逻辑,并在此基础上自行扩展用户登录、后台题库管理、分数计算与答案校验等功能,为构建更复杂的在线教育应用打下实践基础。
1. 考场之外:为什么一个答题小程序值得拆开看
去年帮一个做职业培训的朋友救火,他花两千块找人做了个答题小程序,上线三天崩了两次——一次是交卷时答案没存上,一次是排行榜刷不出来。我接手翻源码,发现是 uniapp 写的,结构乱得像个杂物间,但底子其实不差。这件事让我意识到,考试答题类小程序看着简单,真要做好,交卷、判分、防重复提交、排行榜这几块全是坑。这份 uniapp 开发的考试答题类小程序源码,正好覆盖了从题库加载、倒计时答题、自动判分到成绩展示的完整链路,适合想入门 uniapp 实战的开发者,也适合需要快速搭一套答题系统的团队做二次开发。下面我按自己拆项目的习惯,从目录结构一路讲到怎么改造成自己的题库系统。
2. 项目骨架与运行链路:从目录结构到第一道题渲染
2.1 目录结构:页面、组件、状态各归各位
拿到源码先别急着跑,花五分钟看目录,后面改起来能省两小时。这个项目的结构是典型的 uniapp 多端项目布局,核心目录如下:
├── pages/ │ ├── index/ # 首页,考试入口与历史成绩 │ ├── exam/ # 答题页,核心逻辑所在 │ ├── result/ # 成绩页,展示得分与错题 │ └── rank/ # 排行榜页 ├── components/ │ ├── question-card/ # 题目卡片组件,支持单选多选 │ ├── count-down/ # 倒计时组件 │ └── option-item/ # 选项组件,处理选中态 ├── static/ │ └── data/ # 本地题库 JSON ├── store/ │ └── exam.js # 考试状态管理 ├── utils/ │ ├── request.js # 请求封装 │ └── format.js # 时间与分数格式化 ├── App.vue ├── main.js ├── manifest.json # 多端配置 └── pages.json # 路由与窗口样式pages/exam/是整个项目的心脏,答题、计时、交卷都在这。store/exam.js管状态,我见过不少人把状态直接塞在页面 data 里,结果从答题页跳成绩页时数据丢了,又回头补,血泪经验就是状态该抽就抽。static/data/放本地题库,方便没后端时先跑通流程。
2.2 跑起来:三条命令与一个容易忽略的配置
跑这个项目不需要复杂环境,Node 版本建议 16 以上,HBuilderX 或 CLI 都行。我用 CLI 的方式,因为改起来顺手:
# 安装依赖,建议用 npm,yarn 有时会漏装 uni 的插件 npm install # 启动到微信小程序,需要先在 manifest.json 里填 appid npm run dev:mp-weixin # 如果想先在浏览器看效果,跑 H5 npm run dev:h5跑之前有个坑:manifest.json里的appid如果是空的,微信开发者工具会报错,但报错信息不明显,容易以为是代码问题。填上自己申请的测试号 appid 就行。H5 端不需要 appid,适合先看界面。
2.3 第一道题怎么渲染出来的
首页点「开始考试」后,跳转到pages/exam/exam.vue,核心逻辑在onLoad里。我把它简化成关键几行:
// pages/exam/exam.vue onLoad() { // 从 store 取题库,没有就去本地 JSON 加载 const questions = this.$store.state.exam.questions if (!questions.length) { this.loadLocalQuestions() } else { this.currentQuestion = questions[0] } }, methods: { async loadLocalQuestions() { // 本地题库用 require 引入,注意路径别写错 const data = require('@/static/data/questions.json') // 提交到 store,后续判分和错题都从这里取 this.$store.commit('exam/SET_QUESTIONS', data.list) this.currentQuestion = data.list[0] } }这里SET_QUESTIONS是 Vuex 的 mutation,把题库存进全局状态。为什么不在页面里直接存?因为交卷后成绩页要拿同一份数据算分,页面间传参容易丢,状态管理更稳。questions.json的结构一般是{ list: [{ id, type, stem, options, answer }] },type区分单选多选,answer是正确答案的索引或数组。
提示:本地题库适合演示,真上线还是得走接口,后面改造章节会讲怎么换成后端拉题。
3. 答题核心逻辑:倒计时、选项切换与交卷判分
3.1 倒计时组件:别用 setInterval 硬扛
倒计时看着简单,但用setInterval每秒减一,页面切后台再回来时间就对不上了。这个项目用的是「结束时间戳 + 定时刷新」的方式,我把它抽出来讲:
// components/count-down/count-down.vue data() { return { remain: 0, // 剩余秒数 endTime: 0 // 考试结束的绝对时间戳 } }, methods: { start(duration) { // duration 是考试总时长,单位秒 this.endTime = Date.now() + duration * 1000 this.tick() this.timer = setInterval(this.tick, 1000) }, tick() { // 每次都用结束时间减当前时间,避免累计误差 const left = Math.max(0, Math.floor((this.endTime - Date.now()) / 1000)) this.remain = left if (left <= 0) { this.stop() this.$emit('timeup') // 通知父组件自动交卷 } }, stop() { clearInterval(this.timer) } }关键在tick里每次重新算endTime - Date.now(),而不是remain--。这样即使定时器被节流,时间也不会漂。timeup事件抛给答题页,触发自动交卷。参数duration从考试配置里来,一般设 600 到 3600 秒。
3.2 选项切换:单选多选的状态处理
选项组件要处理两种模式,单选直接替换,多选要增删。核心方法:
// components/option-item/option-item.vue methods: { handleSelect(optionIndex) { if (this.type === 'single') { // 单选:直接覆盖,同时清掉其他选中 this.$emit('change', [optionIndex]) } else { // 多选:已选则移除,未选则加入 const selected = [...this.selected] const pos = selected.indexOf(optionIndex) if (pos > -1) { selected.splice(pos, 1) } else { selected.push(optionIndex) } this.$emit('change', selected) } } }selected是父组件传下来的已选索引数组,change事件把新数组抛回去。多选这里用扩展运算符复制一份再改,避免直接改 props 触发警告。type从题目数据里读,单选是single,多选是multiple。
3.3 交卷判分:一次遍历搞定分数与错题
交卷时把用户答案和题库答案比对,算出分数并收集错题。这段逻辑在store/exam.js的 action 里:
// store/exam.js actions: { submitExam({ state, commit }) { const { questions, userAnswers } = state let score = 0 const wrongList = [] questions.forEach((q, index) => { const userAns = userAnswers[index] || [] // 排序后转字符串比对,兼容多选顺序不同 const isRight = JSON.stringify([...userAns].sort()) === JSON.stringify([...q.answer].sort()) if (isRight) { score += q.score || 1 } else { wrongList.push({ ...q, userAnswer: userAns }) } }) commit('SET_RESULT', { score, wrongList }) } }userAnswers是个对象或数组,键是题目索引,值是选中项数组。比对前先排序再转字符串,这样多选答案顺序不同也能判对。q.score是每题分值,没配就默认 1 分。错题列表带上用户答案,成绩页可以展示「你选了什么、正确答案是什么」。
注意:如果题目有填空或简答,这套判分逻辑要扩展,不能直接套。
4. 数据流与状态管理:Vuex 在答题场景下的取舍
4.1 为什么这个项目用 Vuex 而不是 Pinia
uniapp 对 Pinia 的支持要看版本,老项目用 Vuex 更稳。这个项目用 Vuex 管三样东西:题库、用户答案、考试结果。结构如下:
// store/exam.js const state = { questions: [], // 当前考试题库 userAnswers: {}, // { 题目索引: [选中项] } result: null, // { score, wrongList } examConfig: { duration: 1800, // 考试时长,秒 passScore: 60 // 及格分 } }userAnswers用对象而不是数组,因为用户可能跳题作答,用索引当键更灵活。examConfig放时长和及格线,改配置不用翻页面代码。
4.2 答案暂存:切题不丢答案的关键
答题页每次切换题目,要把当前题的选中项写回 store。做法是在watch里监听当前题索引变化:
watch: { currentIndex(newVal, oldVal) { // 离开旧题时,把选中项存进 store if (oldVal !== undefined) { this.$store.commit('exam/SAVE_ANSWER', { index: oldVal, answer: this.selectedOptions }) } // 进入新题时,从 store 恢复之前的选择 this.selectedOptions = this.$store.state.exam.userAnswers[newVal] || [] } }这样用户来回切题,答案不会丢。SAVE_ANSWER就是给userAnswers对象赋值。我见过有人把答案存在页面 data 里,结果从答题页返回首页再进来,全没了,用户直接骂人。
4.3 成绩页取数:从 store 读还是从路由参数读
成绩页pages/result/result.vue有两种取数方式:从 store 读,或从路由参数读。这个项目用 store,因为错题列表数据量大,塞 URL 不现实。成绩页onLoad里:
onLoad() { const result = this.$store.state.exam.result if (!result) { // 直接刷新成绩页会丢数据,给个兜底 uni.showToast({ title: '请先参加考试', icon: 'none' }) setTimeout(() => uni.navigateBack(), 1500) return } this.score = result.score this.wrongList = result.wrongList }兜底逻辑很重要,用户如果从历史记录直接点进成绩页,store 里没数据,得给提示而不是白屏。
5. 避坑与排查:答题小程序上线前必须过的五道坎
5.1 交卷后答案没存上
现象:用户点交卷,成绩页显示 0 分,但明明答了题。原因:交卷时userAnswers还是空的,因为最后一题的答案没触发SAVE_ANSWER,或者交卷按钮直接调了submitExam但没先保存当前题。解决:交卷前强制保存当前题答案,再调判分。在交卷方法开头加一句this.saveCurrentAnswer(),确保最后一题也进 store。
5.2 倒计时归零后还能继续答题
现象:时间到了,弹了提示,但用户还能点选项。原因:timeup事件只弹了 toast,没禁用交互。解决:在 data 里加isLocked标志,timeup时设为true,选项组件的点击事件里判断if (this.isLocked) return。同时自动交卷。
5.3 排行榜数据不刷新
现象:交卷后回排行榜,自己的成绩没出现。原因:排行榜页onShow里没重新拉数据,或者拉的是缓存。解决:在onShow生命周期里重新请求排行榜接口,别只在onLoad里拉。如果用了本地缓存,交卷后清掉排行榜缓存。
5.4 多选判分把对的判成错的
现象:用户选了 A、B,正确答案是 B、A,判错。原因:比对时没排序,数组顺序不同直接===就挂了。解决:如 3.3 节所示,比对前先sort()再转字符串。注意sort()默认按字符串排,数字索引要传比较函数(a, b) => a - b。
5.5 微信小程序端题库加载失败
现象:H5 正常,微信小程序里题目出不来。原因:require('@/static/data/questions.json')在小程序端路径解析有问题,或者 JSON 文件太大超过包体积限制。解决:改用import静态引入,或者把题库放后端接口。如果必须本地,检查manifest.json里有没有配optimization分包。
6. 二次开发:把本地题库换成后端接口的完整改造
6.1 改造思路:从静态到动态的三步走
这份源码默认用本地 JSON,真要用起来得换后端。我一般分三步:先加请求层,再改 store 的加载逻辑,最后处理加载态和错误。请求层用utils/request.js封装:
// utils/request.js const BASE_URL = 'https://your-api.com' // 换成自己的接口地址 export function request(options) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Content-Type': 'application/json' }, success: (res) => { if (res.statusCode === 200) { resolve(res.data) } else { reject(res) } }, fail: reject }) }) }BASE_URL换成自己的域名,微信小程序里要在后台配合法域名,否则请求被拦。uni.request是 uniapp 跨端请求 API,H5 和小程序都能用。
6.2 改 store:从 require 到异步 action
把loadLocalQuestions换成异步 action:
// store/exam.js actions: { async fetchQuestions({ commit }, examId) { commit('SET_LOADING', true) try { const res = await request({ url: `/api/exam/${examId}/questions` }) commit('SET_QUESTIONS', res.data.list) return res.data } catch (e) { uni.showToast({ title: '题库加载失败', icon: 'none' }) throw e } finally { commit('SET_LOADING', false) } } }页面里onLoad改成await this.$store.dispatch('exam/fetchQuestions', this.examId)。加loading状态是为了在请求期间显示骨架屏或转圈,不然用户看着白屏会以为卡死。
6.3 交卷接口:判分放前端还是后端
本地判分适合演示,真考试得后端判,防止改前端代码作弊。改造时把submitExam改成调接口:
async submitExam({ state, commit }) { const res = await request({ url: '/api/exam/submit', method: 'POST', data: { examId: state.examId, answers: state.userAnswers } }) commit('SET_RESULT', res.data) }后端返回{ score, wrongList },前端只负责展示。这样答案不落前端,安全得多。改造时注意接口返回的错题结构要和成绩页对齐,字段名不一致就在 store 里做一层映射。
6.4 验证改造是否成功
改完别急着上线,按这个顺序验一遍:先跑 H5 看题库能不能拉下来,再跑微信小程序看域名有没有配错,然后模拟交卷看成绩页数据对不对,最后断网测一下错误提示。我自己的习惯是每次改完数据流,都强制走一遍「加载 → 答题 → 交卷 → 看成绩」全链路,少一步都可能漏。希望帮到你。
本文还有配套的精品资源,点击获取