
简介一份使用uniapp框架开发的考试答题类小程序源码面向希望入门多端开发或了解考试类应用前端逻辑的开发者特别适合当作教学演示或毕业设计参考。项目覆盖单选、多选、判断三种常见题型不同题型对应各自的选项渲染与判断逻辑并实现上一题、下一题切换功能题目与答案以静态数据预置在代码中未接入后台因此不涉及交卷评分、用户登录等环节便于初学者聚焦前端实现。资源包为zip压缩格式大小1.47MB压缩包内文件数量及类型暂未标注解压后可直接查看完整的uniapp页面、组件与逻辑代码。目前已有2372人学习。通过源码可以系统学习HBuilderX项目组织方式、页面路由与状态管理、数据绑定和组件化开发也能了解当前题目索引控制、选项选中反馈、通过数据驱动视图更新等交互细节并进一步扩展题库管理、计分判分、防作弊等功能为构建完整的在线教育应用打下扎实基础。1. uniapp考试答题小程序一次开发微信、支付宝、H5三端同时上线备考季某高校继续教育学院的几百名学生同时涌入小程序做题服务器没崩但第一版答题页的倒计时先乱了——有人切后台回来看见时间还停在原地有人交卷后发现多选少选居然判对。如果你正准备用 uniapp 开发考试答题类小程序或者已经在写答题业务但被跨端兼容折腾得头疼这篇文章会把「随机组卷 → 逐题作答 → 倒计时交卷 → 自动判分 → 错题本沉淀」这条完整链路拆开每一段都给出能直接抄进自己项目的代码。这套方案没有依赖任何私有框架纯 uniapp Vue 3 组合微信小程序、支付宝小程序和 H5 三端共用一套业务代码适合刚接触 uniapp 的新手照着复现也适合想看看答题类业务在跨端场景下怎么设计数据结构和状态流的开发者作为参考样板。2. 整体架构与目录设计页面、组件、API 三层各自管好自己的一亩三分地2.1 目录结构先弄清楚每份代码该躺在哪我拿到这类项目的第一习惯是先看目录目录顺了后面改代码能少走一半弯路。一套规范的 uniapp 答题项目目录大致长这样├── pages │ ├── index/index.vue // 首页题库列表、模式选择 │ ├── exam/exam.vue // 答题页核心页面 │ ├── result/result.vue // 交卷后的成绩报告页 │ └── wrongbook/wrongbook.vue // 错题本列表 重练入口 ├── components │ ├── question-card.vue // 单题卡片题干、选项、解析展示 │ ├── answer-sheet.vue // 答题卡题号网格可跳题 │ └── countdown-bar.vue // 倒计时条 ├── api │ ├── request.js // uni.request 的 Promise 封装 │ └── exam.js // 获取试卷、提交答卷、拉取错题等接口 ├── store │ └── exam.js // Pinia 状态当前试卷、答案映射、剩余时间 ├── utils │ ├── paper.js // 组卷逻辑、随机洗牌 │ ├── judge.js // 判分引擎 │ └── storage.js // uni.getStorageSync 封装统一前缀 └── staticpages 目录只放页面级 Vue 文件每个页面只负责路由跳转和状态获取components 放可复用的答题组件比如 question-card 同时被答题页和错题本复用api 层单独抽出来是为了后续切换后端地址或者做本地 mock 时只改一个文件。我见过不少半途翻车的项目把请求逻辑直接写在页面里结果试题接口换了返回结构全页面找uni.request找到手软。store/exam.js用 Pinia 管理考试中的全部临时数据包括当前试卷题目数组、用户答案对象、剩余秒数、当前索引。为什么不用页面 data 存因为答题中途用户可能切走再回来页面可能被系统销毁重建存在全局 store 里数据不丢而且答题卡、倒计时条、题目卡片三个组件都要读同一份状态用 props 层层传会传成噩梦。2.2 题型数据模型把选择题、多选、判断、填空统一成一种结构答题业务的数据结构设计直接决定了判分引擎和组卷逻辑的复杂度。我在这个项目里把四类常见题型统一成了一个 JSON 结构字段如下{ id: q_1021, type: single, title: TCP 三次握手中第二次握手报文段的标志位是, options: [A. SYN, B. ACK, C. SYNACK, D. FIN], answer: C, analysis: 第二次握手服务端同时发送 SYN 和 ACK……, knowledgePoint: 计算机网络-传输层 }type取值single、multiple、judge、fill分别对单选、多选、判断、填空。options对判断题为[A. 正确, B. 错误]填空题为空数组。answer是标准答案。单选、判断存字符串多选存数组[A, C]填空存数组[1822, TCP]每个元素对应一个空。为什么不每种题型单独建结构因为页面渲染、选项点击、答案回显这些逻辑是高度相似的统一结构后 question-card 组件只需要按type分支渲染判分引擎也只需要按type走不同分支新增题型时不用动页面和存储结构只加一个type分支即可。这是踩过坑之后的选择——之前我试过把单选题设计和多选题分开结果答题页里到处都是if (question.type multiple)的重复代码维护成本翻倍。2.3 答题流程状态机一场考试的完整生命周期答题页的状态流转我习惯画成四个阶段loading拉取试卷中→answering答题中→submitting交卷请求中→finished已交卷。页面根据状态决定渲染什么倒计时组件在answering阶段才启动交卷按钮在submitting阶段禁用防连点。// store/exam.js 核心状态定义 import { defineStore } from pinia export const useExamStore defineStore(exam, { state: () ({ phase: loading, // loading | answering | submitting | finished paper: [], // 当前试卷题目数组 answers: {}, // { q_1021: C } 或 { q_1021: [A,C] } currentIndex: 0, // 当前正在做的题号索引 remainSeconds: 1800, // 剩余秒数 paperId: }), actions: { setPhase(p) { this.phase p }, setAnswer(questionId, value) { this.answers[questionId] value }, jumpTo(index) { this.currentIndex index } } })phase用字符串而不是布尔标志好处是页面模板里可以直接v-ifphase submitting控制交卷按钮的 loading 态也能在跳转路由时判断是否允许离开。实际开发里很多人用isLoading、isFinished一堆布尔值状态组合一旦多起来就会出现「既是 answering 又是 finished」的非法状态字符串phase从根上杜绝了这类问题。3. 核心答题链路实现从随机组卷到交卷判分的完整闭环3.1 随机组卷Fisher-Yates 洗牌保证每次出的卷子都不一样考试类小程序最基础的需求就是同一套题库每次生成不同的试卷。随机抽题如果写成每道题独立随机抽会出现同一道题在一份卷子里重复出现的情况所以必须用洗牌算法加切片。// utils/paper.js export function shuffle(arr) { const result [...arr] for (let i result.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)) ;[result[i], result[j]] [result[j], result[i]] } return result } export function buildPaper(allQuestions, config) { const rule [ { type: single, count: config.singleCount || 10 }, { type: multiple, count: config.multipleCount || 5 }, { type: judge, count: config.judgeCount || 5 }, { type: fill, count: config.fillCount || 3 } ] const selected [] for (const item of rule) { const pool allQuestions.filter(q q.type item.type) selected.push(...shuffle(pool).slice(0, item.count)) } return shuffle(selected) // 最后整体再洗一次避免同类题扎堆 }shuffle用的是经典的 Fisher-Yates 算法从数组末尾开始每次随机选一个当前位置之前的元素交换过去保证每个排列等概率出现时间复杂度 O(n)。buildPaper先按题型分组抽题最后整体再洗一次这样单选的题目不会全部集中在卷子前半段。config里的singleCount这类参数后续如果要加按知识点权重抽题只需要在这个函数里扩展过滤条件。3.2 倒计时实现别再 setInterval 一秒一秒减了很多新人在 uniapp 里做倒计时第一时间想到的是setInterval(() this.remainSeconds--, 1000)。这个写法的致命问题是小程序切到后台JS 定时器会被系统挂起等用户回来时会发现时间几乎没走。正确做法是记录一个截止时间戳定时器只负责刷新显示。// components/countdown-bar.vue 核心逻辑 export default { data() { return { endTimestamp: 0, displayText: 30:00, timer: null } }, methods: { start(totalSeconds) { this.endTimestamp Date.now() totalSeconds * 1000 this.timer setInterval(() { const remain Math.max(0, Math.floor((this.endTimestamp - Date.now()) / 1000)) const mm String(Math.floor(remain / 60)).padStart(2, 0) const ss String(remain % 60).padStart(2, 0) this.displayText ${mm}:${ss} if (remain 0) { this.stop() this.$emit(timeup) // 触发交卷事件 } }, 250) uni.$emit(exam-timer-start, { endTime: this.endTimestamp }) }, stop() { if (this.timer) { clearInterval(this.timer) this.timer null } } }, beforeDestroy() { this.stop() } }定时器间隔设为 250 毫秒而不是 1000 毫秒是因为时间戳差值法每秒只变化一次用 250ms 的间隔刷能保证切换显示时误差小于 1 秒同时也不会让页面因为频繁 setData 变得卡顿。我一般在倒计时组件里还会额外发一个uni.$emit把endTime传出去这样如果有多个页面比如答题页和结果页需要同步显示剩余时间都能拿到同一个截止点。页面切后台时组件并不会自动销毁所以start里记录时间戳后即使定时器被挂起等用户回到前台下一次 tick 对比Date.now()会瞬间跳回正确数字——这就是时间戳法和计数器法的本质区别计数器法少记了多少秒就是多少秒时间戳法不管中间停了多久只要对表就能校正。3.3 判分引擎单选多选判断填空的四种判定分支判分是答题小程序最容易被忽视又最容易翻车的地方特别是多选题与或逻辑写错一个符号整张卷子的得分就失真了。我在utils/judge.js里维护了一套独立于页面的判分逻辑纯函数无副作用方便单测// utils/judge.js export function checkAnswer(question, userAnswer) { if (userAnswer null || userAnswer undefined || userAnswer ) { return { correct: false, score: 0 } } switch (question.type) { case single: case judge: return { correct: String(userAnswer) String(question.answer), score: String(userAnswer) String(question.answer) ? question.score : 0 } case multiple: // 多选题严格判分多选、少选、错选都不得分 return { correct: sameSet(userAnswer, question.answer), score: sameSet(userAnswer, question.answer) ? question.score : 0 } case fill: return judgeFill(question.answer, userAnswer, question.score) default: return { correct: false, score: 0 } } } function sameSet(a, b) { if (!Array.isArray(a) || !Array.isArray(b) || a.length ! b.length) return false const setA new Set(a.map(item String(item).trim())) const setB new Set(b.map(item String(item).trim())) return [...setA].every(item setB.has(item)) setA.size setB.size } function judgeFill(answerArr, userArr, totalScore) { if (!Array.isArray(answerArr) || !Array.isArray(userArr)) return { correct: false, score: 0 } let correctCount 0 answerArr.forEach((ans, index) { const userAns userArr[index] || if (String(ans).trim().toLowerCase() String(userAns).trim().toLowerCase()) { correctCount } }) const fullScore correctCount answerArr.length const perBlankScore answerArr.length 0 ? totalScore / answerArr.length : 0 return { correct: fullScore, score: fullScore ? totalScore : Math.round(correctCount * perBlankScore * 100) / 100 } }判分的核心规则我用表格归纳一下方便你对照自己的需求调整题型判分规则常见误判场景单选选项字符串全等答案带了空格导致判错多选集合完全相等多选少选都不得分用数组比较永远返回 false判断布尔值字符串化后全等用户选正确存成true标准答案存成A填空按空给分大小写不敏感首尾去空格部分正确时整题给 0 分judgeFill里我做了部分给分处理三个空答对两个就给三分之二的分数这是从实际考试场景来的需求。如果你做的是模拟考试想严格一点把score: fullScore ? totalScore : 0一行改掉即可。4. 跨端适配与数据持久化登录态、本地存储和接口封装一个都不能少4.1 request 封装给 uni.request 包一层 Promise顺便统一处理登录失效uniapp 的uni.request默认是回调风格写起来容易陷入回调地狱而且每个端的小程序对请求头的处理不一样比如支付宝小程序的Authorization头在某些版本下有兼容问题。我在api/request.js里做了一层统一封装// api/request.js const BASE_URL https://api.example.com export function request({ url, method GET, data {}, needAuth true }) { return new Promise((resolve, reject) { const header { Content-Type: application/json } if (needAuth) { const token uni.getStorageSync(token) header.Authorization Bearer ${token} } uni.request({ url: BASE_URL url, method, data, header, success: (res) { if (res.statusCode 200) { resolve(res.data) } else if (res.statusCode 401) { // 登录态过期清掉本地 token 并跳转登录页 uni.removeStorageSync(token) uni.navigateTo({ url: /pages/login/login }) reject(new Error(login expired)) } else { reject(new Error(HTTP ${res.statusCode})) } }, fail: (err) { reject(err) } }) }) }封装后业务层调用变成request({ url: /paper, data: { categoryId } }).then(...)回调风格被吞掉代码清爽很多。needAuth参数用来区分哪些接口需要带 token比如获取题目列表可以匿名但提交答卷必须登录。401 统一处理登录失效避免每个页面重复写一遍removeStorageSync和跳转。4.2 登录态设计小程序端用 code 换 tokenH5 端降级为设备号uni-app 跨端后登录逻辑是差异最大的一块。微信小程序用uni.login拿code支付宝小程序有独立的my.getAuthCodeH5 端则根本没有uni.login可用。我在这套项目里做了一层适配// api/auth.js export function login() { return new Promise((resolve, reject) { // #ifdef MP-WEIXIN uni.login({ provider: weixin, success: (res) resolve(res.code), fail: reject }) // #endif // #ifdef MP-ALIPAY uni.getAuthCode({ scopes: auth_base, success: (res) resolve(res.authCode), fail: reject }) // #endif // #ifdef H5 // H5 端降级读取本地设备指纹没有就生成一个 let deviceId uni.getStorageSync(deviceId) if (!deviceId) { deviceId web_ Date.now() _ Math.random().toString(36).slice(2) uni.setStorageSync(deviceId, deviceId) } resolve(deviceId) // #endif }) }// #ifdef是 uniapp 的条件编译语法编译器打包时会根据目标平台自动保留对应代码块其他平台的定义会被剔除。这种写法的好处是登录模块对外暴露的login()接口签名一致业务层不需要关心底层是哪个平台。把 code 或 deviceId 发给后端后端返回一个自定义 token后续请求统一走request封装的Authorization头。4.3 错题本与答题记录storage 统一前缀防止键名冲突错题本和答题记录这类数据量不大、时效性要求不高的内容我一般直接存在本地 storage不额外请求后端。但 uniapp 的uni.getStorageSync有几个坑一是不区分业务前缀多个模块写同一个键名可能互相覆盖二是存储容量微信小程序单键上限 1MB错题本如果存了带解析的长题干攒多了会写入失败。所以我写了统一的封装// utils/storage.js const PREFIX exam_app_ export function getItem(key) { return uni.getStorageSync(PREFIX key) } export function setItem(key, value) { uni.setStorageSync(PREFIX key, value) } export function addWrongQuestion(question, userAnswer) { const list getItem(wrong_book) || [] const existIndex list.findIndex(item item.id question.id) if (existIndex -1) { list[existIndex].wrongCount (list[existIndex].wrongCount || 1) 1 list[existIndex].lastError userAnswer } else { const record { ...question, wrongCount: 1, lastError: userAnswer, lastTime: Date.now() } list.unshift(record) } const trimmed list.slice(0, 200) // 最多保留 200 道 setItem(wrong_book, trimmed) return trimmed.length }setItem(wrong_book)会自动存成exam_app_wrong_book模块之间不会串。错题记录里冗余存了一份完整的题目 JSON题干、选项、解析、知识点而不是只存题目 ID——因为从本地渲染错题本时不希望额外发一个GET /question/{id}的请求离线也能看。限制 200 道是考虑 storage 容量超出的部分按插入顺序截断。如果后续想同步到云端可以把addWrongQuestion里追加一个uni.setStorageSync后的防抖上传。4.4 跨端渲染差异选项点击、键盘处理、安全区适配同一套代码跑三端总有几处要单独处理的地方。答题页的选项点击事件H5 端响应正常但支付宝小程序对click的冒泡处理略有差异我统一在选项外层加了tap.stop阻止事件冒泡避免点击选项时误触下一题区域。填空题的输入框微信小程序里typetext与confirm-typedone搭配但 H5 端这个属性不生效键盘的回车行为不受控我用了条件编译单独给 H5 端加keyup.enter处理。安全区也是踩过坑的地方iPhone 底部的横条会盖住交卷按钮。解决方法是给底部操作栏加padding-bottom: constant(safe-area-inset-bottom)和padding-bottom: env(safe-area-inset-bottom)两行一起写前者兼容 iOS 11.0 到 11.2后者覆盖 iOS 11.2 之后。这套 CSS 在所有小程序端和 H5 端都认识是纯 Web 标准。5. 避坑与排查答题小程序五个高频问题实录5.1 倒计时切后台回来时间几乎没走现象用户做着题切出去回微信消息几分钟后回来剩余时间只少了十几秒考试时间形同虚设。原因小程序页面进入后台后setInterval回调被系统冻结等到回前台才继续执行如果用的是每秒减一的计数器法冻结期间的时间就凭空蒸发了。解决倒计时组件改用截止时间戳 定时器刷新方案我在 3.2 节写的endTimestamp Date.now() totalSeconds * 1000就是为此设计的。从那以后我每写一个倒计时功能都会先问自己一句页面切后台回来时间是靠Date.now()对齐的还是靠计数猜的5.2 多选题少选一个居然也判对了现象某次内部测试标准答案[A, C]用户只选了A判分引擎却给了满分。原因判分逻辑写成了JSON.stringify(userAnswer) JSON.stringify(question.answer)而用户答案在状态流中被存成了[A]问题不在序列化而在用户点击选项时没有做集合比较——少选时两个数组长度就不一致但这个判断被前面的includes逻辑跳过了等于只校验了用户选的都在答案里没校验答案的每个选项用户都选了。解决多选判分必须做双向集合相等也就是我在sameSet函数里做的先比较长度再逐个检查集合成员。加了这层之后多选、少选、错选三种情况全部能拦截。5.3 答题卡点击题号滚动定位失效现象答题卡上点击第 15 题题目区域没有滚动到对应位置或者滚到了错位的题目。原因我用scroll-into-view配合id定位但题目组件外层套了v-if条件渲染点击答题卡时未渲染的题目节点根本不存在小程序天然找不到目标。解决放弃scroll-into-view改用uni.pageScrollTo配合节点查询先uni.createSelectorQuery().select(#q_15).boundingClientRect()拿到题目在页面中的偏移量再调用uni.pageScrollTo({ scrollTop })。注意查询节点的时机要在nextTick之后因为答案切换引起的v-if更新需要一帧才能渲染完成。5.4 storage 写入失败错题本静默丢数据现象错题本攒到两三百道后新错题不再出现页面也没有任何报错。原因微信小程序单键存储上限 1MB错题记录把整道题的解析、知识点、选项全量存进去一条记录平均 4KB 左右250 道就顶满了 1MB 限额uni.setStorageSync写入失败时默认不抛错只在返回值里带errMsg很多项目没检查返回值所以静默失败。解决写入前检查内容大小用uni.getStorageInfoSync().currentSize做预估超限时先按时间戳淘汰旧记录更彻底的做法是每条记录只存题目 ID 和用户答案渲染时再从题库文件里按 ID 查全量信息但离线场景下这个方案不成立。这个项目我采用最多存 200 道 写入前 try-catch 降级的双保险。5.5 H5 端接口跨域开发环境打通后上线又断现象本地npm run dev:h5时接口请求正常打包部署到测试服务器后所有请求被浏览器拦截报 CORS 错误。原因开发环境的devServer自带代理转发解决了跨域生产环境的静态页面部署和后端接口不在同一域名浏览器同源策略生效。解决两个方向一是后端网关配置 CORS 白名单这是正规解法二是如果后端不改就用反向代理把接口路径转发到后端服务。我一般还会在request.js里加一个mode字段开发环境走http://localhost:3000生产环境走线上域名避免打包后手动改BASE_URL忘掉的风险。6. 二次开发给组卷逻辑加一个按知识点权重抽题的扩展点如果你想在这个项目上做二次开发我建议从组卷逻辑入手因为它是整个答题链路的入口改动的收益最直观。现在buildPaper的抽题是纯随机的实际考试里通常要求覆盖多个知识点并且每个知识点的题量不同。我设计了一个扩展方法在配置对象里增加knowledgeWeights字段。// 扩展后的组卷配置示例 const config { singleCount: 8, multipleCount: 4, judgeCount: 4, fillCount: 2, knowledgeWeights: { 计算机网络-传输层: 0.4, 数据结构-二叉树: 0.3, 操作系统-进程管理: 0.3 } } export function buildPaperByKnowledge(allQuestions, baseConfig, weights) { const totalCount baseConfig.singleCount baseConfig.multipleCount baseConfig.judgeCount baseConfig.fillCount const grouped {} for (const [point, weight] of Object.entries(weights)) { const count Math.round(totalCount * weight) const pool allQuestions.filter(q q.knowledgePoint point) grouped[point] shuffle(pool).slice(0, count) } // 把各知识点抽出的题合并再按题型规则补充到不足的数量 const selected Object.values(grouped).flat() const targetCount totalCount const supplementPool allQuestions.filter(q !selected.includes(q.id)) while (selected.length targetCount supplementPool.length 0) { selected.push(supplementPool.shift()) } return shuffle(selected) }这个扩展的核心是把按题型抽题改成按知识点预算抽题。验证方法也很直接准备一份 100 道题的题库跑若干次组卷统计每次卷子里各知识点题目占比是否逼近配置里的权重值。我在本地用脚本跑了 200 次组卷发现当题库里各知识点题目数量均衡时抽样占比和权重设定的偏差能控制在 5% 以内如果某个知识点题库里题目本身就少就会出现预算不足补充池的代码就是为此兜底的。验证完逻辑再到真机上把首页的模拟考试和练习模式都跑一遍重点看三样倒计时结束自动交卷是否正常、答题卡点击跳转是否准、错题本重练时解析是否完整展示。我从这个项目里最深的一条体会是答题类小程序的坑往往藏在边界条件里切后台、多选少选、存储满、跨域断连都是常规测试覆盖不到的场景。只要你把每个边界条件都当成一等公民来设计——倒计时用时间戳、判分用集合、存储用封装——这套架构就能稳稳撑到十万级用户量。希望帮到你。本文还有配套的精品资源点击获取