简介这是一份基于uniapp框架开发的小程序源码聚焦考试答题场景包含单选、多选、判断三类题型的展示与切换逻辑适合前端学习者及需要快速搭建跨平台答题应用的开发者参考。项目采用HBuilderX作为集成开发环境所有题目与答案以静态数据形式预置在前端省去服务器交互降低理解成本。资源以zip压缩包发布大小约1.47MB内含页面结构、样式表、逻辑脚本等核心工程文件未提供具体文件数量与类型清单整体工程可在HBuilderX中直接打开。已有2372人学习下载。由于未接入后台项目没有交卷与评分模块反而让代码更聚焦于题目渲染、选项切换等核心流程便于逐行学习。通过研读源码可掌握uniapp的组件化开发、页面跳转、数据绑定及上一题/下一题索引切换等关键技术该项目也是扩展完整在线考试系统的良好起点可在此基础上补充用户登录、答案校验、成绩展示和后台题库管理逐步构建生产级教育应用。1. 一套代码跑通考试闭环uni-app 答题小程序到底解决了什么问题很多做在线教育的团队都会遇到一个尴尬考试页面用网页做在 WebView 里打开体验总是差一口气——加载慢、答题卡弹层卡顿、倒计时和滚动条在安卓上各有各的脾气。用 uni-app 写一套考试答题小程序一套代码同时覆盖微信小程序和 App从考试列表、倒计时答题、答题卡导航到交卷判分整个闭环都能在移动端跑顺。这个项目是典型的 uni-app 应用结构清晰适合拿来改造成企业培训、学校测验、驾考刷题这类场景。我拆这套源码时发现答题状态管理、倒计时自动交卷、跨端样式兼容这几个点刚好是一套代码能不能跑得又稳又顺的分水岭。2. 技术选型与项目结构页面拆分和状态管理的设计逻辑2.1 为什么答题场景适合走 uni-app 而不是原生小程序如果只做微信小程序原生语法其实也够用但大部分真实项目都有「以后要出 App」的预期。考试类产品尤其如此——企业培训往往要求员工在手机 App 或微信里都能考这时候 uni-app 的价值就体现了写一套 Vue 语法编译到微信小程序、支付宝小程序、H5 和 App。答题页面核心是表单交互和状态切换不涉及复杂原生能力跨端编译的风险低非常适合用这个方案兜底。后端接口就按 REST 风格写前端不用关心平台差异。页面里最多的交互是点击选项、切换题目、打开答题卡这些在 uni-app 里都有统一封装。需要注意的是用了 uni-app 不等于所有 API 都一样比如uni.getSystemInfo在不同端返回的字段名就有差异后面我会单独讲。选这套技术栈的核心逻辑是答题场景重状态、轻原生uni-app 的编译能力和生态刚好能接住。2.2 目录结构与页面职责拿到源码先不看代码看目录结构就能猜出这个项目是怎么分层的。实际项目的目录组织大致如下. ├── App.vue # 应用入口全局生命周期写在里面 ├── main.js # 创建应用实例挂载 Pinia ├── manifest.json # 应用配置appid、权限、小程序设置 ├── pages.json # 页面路由、导航栏、tabBar 配置 ├── uni.scss # 全局样式变量颜色、圆角、字号 ├── api/ │ └── exam.js # 考试相关接口封装 ├── components/ │ ├── question-card.vue # 题目卡片渲染题干和所有选项 │ ├── answer-sheet.vue # 答题卡弹层网格展示已答/未答 │ └── countdown-timer.vue # 倒计时组件顶部剩余时间 ├── pages/ │ ├── index/index.vue # 考试列表页 │ ├── exam/exam.vue # 答题页核心逻辑所在 │ └── result/result.vue # 成绩页展示得分和答案解析 ├── store/ │ └── exam.js # Pinia 状态管理整个答题过程的数据中枢 └── utils/ ├── request.js # 网络请求统一封装 └── storage.js # 本地缓存读写封装这套结构的特点是把「页面」和「逻辑」分开。页面文件只负责渲染和用户操作真正的考试状态、答题数据、倒计时逻辑全部收拢在store/exam.js里。这样做的直接好处是从详情页进答题、从答题卡跳题、交卷后进结果页所有页面共用的数据都只有一个来源不会出现 A 页面改了答案、B 页面不知道的情况。页面路由在pages.json里配置考试列表页是pages/index/index答题页是pages/exam/exam。答题页不放在 tabBar 里因为考试进行中不应该让用户随意切走用uni.navigateTo跳转更合理用户返回上一页时还能弹窗确认「确定要退出考试吗」。2.3 状态设计考试状态、答题数据、倒计时三者如何解耦答题页最怕的事情是用户做着做着退出了再进来答案没了或者切到后台再回来倒计时已经不是原来的值。这些问题根子都在状态没设计好。我的做法是给考试状态做一个显式的状态机值只有四个idle未开始、doing答题中、finished已交卷、expired超时自动交卷。页面根据状态决定渲染什么用户在答题页做任何操作最终都是去改这个状态机里的数据。答题数据单独存一份结构是{ questionId: answerValue }。单选存选项 key多选存数组判断存布尔值。这份数据是核心资产所有页面只认这一份不和组件内部状态纠缠。倒计时的剩余时间不靠累加秒数来记而是记一个endTimestamp时间戳每次页面刷新或重新进入时用当前时间重新算剩余秒数。这样用户把小程序切到后台十分钟再回来倒计时依然是准的因为时间流逝以系统时间为准而不是以前端定时器为准。这三块数据互不干扰后面加题型、加成绩单都能平滑扩展。3. 核心模块拆解从进入考试到自动交卷的完整链路3.1 考试列表页与进入考试的初始化考试列表页从GET /api/exam/list拿数据渲染卡片列表。卡片上展示考试名称、题量、时长、已参加人数。点进卡片后通过uni.navigateTo跳转到答题页并在 URL 上携带考试 id。答题页 onLoad 时拿这个 id 调接口拉取考试详情和全部题目。// api/exam.js const listExams () { return request.get(/api/exam/list) } const getExamDetail (examId) { return request.get(/api/exam/${examId}) } const submitExam (examId, data) { return request.post(/api/exam/${examId}/submit, data) }这里有个细节答题页每次进入都应该重新拉取题目和倒计时而不是读本地缓存。因为同一套题可能被设置成不同考试时段后端如果刷新了题目列表前端还拿旧缓存就会翻车。考试一旦开始题目列表就是快照后续交卷判分都基于这一份题目数据所以进入页面后第一时间调接口拿到数据后再展示题干避免用户看到空页面。3.2 答题页的统一答案处理答题页的 UI 结构是纵向滚动页头是考试名称和剩余时间下方是滚动区域每道题渲染为一整块question-card组件。这样设计的好处是用户天然习惯上下滑动逐题浏览同时题目列表和答题卡状态实时联动。所有题型的答案处理都收敛到 store 的一个方法里页面组件只负责把「用户选了哪个答案」抛给 store不关心具体存了什么。// store/exam.js import { defineStore } from pinia export const useExamStore defineStore(exam, { state: () ({ examId: null, examInfo: {}, questionList: [], userAnswers: {}, // { questionId: answerValue } endTimestamp: 0, // 考试结束的时间戳倒计时靠它算 timer: null, status: idle, }), getters: { answeredCount: (state) Object.keys(state.userAnswers).length, totalCount: (state) state.questionList.length, progressPercent: (state) { if (!state.questionList.length) return 0 return Math.round( (Object.keys(state.userAnswers).length / state.questionList.length) * 100 ) }, }, actions: { async loadExam(examId) { this.examId examId this.status doing const { data } await examApi.getExamDetail(examId) this.examInfo data.examInfo this.questionList data.questions this.endTimestamp Date.now() data.duration * 60 * 1000 this.startTimer() }, setAnswer(questionId, answer) { this.userAnswers[questionId] answer // 每次答题都往本地存一份杀进程不丢 uni.setStorageSync(exam_${this.examId}_answers, this.userAnswers) }, clearAnswer(questionId) { delete this.userAnswers[questionId] }, startTimer() { this.clearTimer() this.timer setInterval(() { const remain Math.round((this.endTimestamp - Date.now()) / 1000) if (remain 0) { this.clearTimer() this.autoSubmit() } }, 1000) }, clearTimer() { if (this.timer) { clearInterval(this.timer) this.timer null } }, async submitExam() { this.clearTimer() this.status finished const usedSeconds Math.round((this.endTimestamp - Date.now()) / 1000) return await examApi.submitExam(this.examId, { answers: this.userAnswers, usedSeconds, }) }, }, })setAnswer的第二个参数在页面里不太好判断因为单选传字符串、多选传数组、判断传布尔值。我的处理方式是在question-card组件里做完类型判断统一调store.setAnswer(questionId, value)。store 不背这个锅它只管存储和持久化。注意uni.setStorageSync每次答题都写入。虽然答题是高频操作但小程序本地存储的写入速度足够快一次一次写没有性能问题换来的是极端情况下的数据安全。真要优化可以改成防抖写入比如用户停止操作 1 秒后再落盘但这个项目里直接同步写反而更省心代码也更好理解。3.3 答题卡组件未答、已答、当前题的三种状态答题卡是考试场景里用户最依赖的导航工具。点击右下角按钮弹出半屏弹层里面是题目号的网格每个格子三种颜色绿色已答、灰色未答、蓝色当前所在题。点击格子直接滚动到对应题目。!-- components/answer-sheet.vue -- template view classsheet-mask clickclose view classsheet-content click.stop view classsheet-title text答题卡/text text{{ answeredCount }}/{{ totalCount }}/text /view view classsheet-grid view v-for(item, index) in questionList :keyitem.id classsheet-cell :class{ answered: userAnswers[item.id] ! undefined, current: index currentIndex, } clickhandleJump(index) {{ index 1 }} /view /view /view /view /template网格格子是有语义的用户瞟一眼就知道哪些题还没做。这里的关键是currentIndex从哪来。我的做法是维护一个currentQuestionIndex响应式变量在页面滚动或点击题目时更新。滚动时用scroll-view的scroll事件拿scrollTop和每题的位置做对比算出当前位置是哪一题。点击答题卡格子时直接调uni.pageScrollTo或者操作scroll-view的scroll-top定位到目标题目。弹层关闭时有一个容易被忽略的回退逻辑如果用户点答题卡跳到了某道题但中途返回弹层关闭后页面滚动位置应该保持「跳到的那题附近」不能弹回顶部。所以跳题时先记录目标位置再用scroll-top绑定定位关闭弹层不动滚动值这样体验是连贯的。3.4 倒计时与自动交卷时间戳计算方式倒计时组件单独拆出来是因为它涉及 App 端和微信端的行为差异。我见过很多项目直接用一个remainSeconds变量每秒减一结果切后台再回来差了三分钟。这个项目的做法是页面只展示剩余秒数真正的计时依据是endTimestamp - Date.now()。!-- components/countdown-timer.vue -- template view classtimer text classtimer-label剩余时间/text text classtimer-value{{ formattedTime }}/text /view /template script setup import { computed, onMounted, onUnmounted } from vue import { useExamStore } from /store/exam const store useExamStore() const formattedTime computed(() { const remain Math.max(0, Math.round((store.endTimestamp - Date.now()) / 1000)) const h Math.floor(remain / 3600) const m Math.floor((remain % 3600) / 60) const s remain % 60 const pad (n) String(n).padStart(2, 0) return h 0 ? ${pad(h)}:${pad(m)}:${pad(s)} : ${pad(m)}:${pad(s)} }) /script组件本身不维护时间状态只负责把 store 里的endTimestamp算成可读格式。store 里的定时器每秒触发一次触发时重新算出剩余秒数并检查是否归零。如果归零调autoSubmit交卷。这种设计有一个被忽略的好处页面从后台切回前台时onShow生命周期里只需要调store.refreshRemain()这个函数重新触发一次响应式更新页面上的数字立刻校准不需要关心定时器在后台有没有走。用户看到的时间永远不会比真实时间多只会因为网络延迟差一两秒而这一两秒在考试场景里完全可以接受。3.5 交卷与结果页提交参数和返回数据的设计交卷不是简单 POST 一下 answers。具备良好体验的交卷应该分三步未答提示、二次确认、提交状态控制。第一步点交卷时检查answeredCount和totalCount是否相等。没做完的题要明确告诉用户「还有 3 道题未作答确定要交卷吗」不能一刀切阻止因为有些人就是战略性放弃主观题。第二步不管有没有未答题都弹一个确认框给用户一个后悔的机会。第三步提交按钮在请求进行中要置灰并显示 loading防止连续点击。// pages/exam/exam.vue import { useExamStore } from /store/exam const store useExamStore() let submitting false const handleSubmit async () { if (submitting) return const unanswered store.totalCount - store.answeredCount const tip unanswered 0 ? 还有 ${unanswered} 道题未作答确定交卷 : 确定要交卷吗 const res await uni.showModal({ title: 提示, content: tip }) if (!res.confirm) return submitting true try { const { data } await store.submitExam() uni.redirectTo({ url: /pages/result/result?examId${store.examId}score${data.score}, }) } catch (err) { uni.showToast({ title: 交卷失败请重试, icon: none }) } finally { submitting false } }交卷接口的返回体里至少要有三块信息得分、每题的正确选项、每道题的对错标记。这三块决定了结果页是只展示总分还是能展开题干看解析。项目里我推荐后端返回完整解析因为错题回顾是考试类产品最核心的留存功能——用户交完卷最想看的不是总分而是「我到底错在哪」。结果页拿到data.score和题目解析后直接把题目列表再渲染一遍每题下方展示正确答案用户选错的题用红色标出错误选项和正确答案做对的题用绿色标出所选选项。这里不需要再调接口页面 URL 里带 examId 只是为了展示考试名称和允许用户「重新再考一次」。4. 本地跑起来从 HBuilderX 到真机预览4.1 导入工程与小程序 appid 配置这个项目用 HBuilderX 编辑器打开最省事因为 uni-app 官方的插件和运行工具链在 HBuilderX 里是集成好的。拿到源码后在 HBuilderX 里选择「导入项目」指定到项目根目录即可。导入后第一件事是改manifest.json。微信小程序运行需要 appid如果你只有测试号可以在微信公众平台注册一个测试号把 appid 填到manifest.json的mp-weixin配置块里。没有 appid 也能跑微信开发者工具里会有一个「测试号」选项但部分 API 会受限。改完 manifest 后重启 HBuilderX 的编译服务确保配置被正确加载。{ mp-weixin: { appid: 你的小程序appid, setting: { urlCheck: false, es6: true, postcss: true, minified: true }, usingComponents: true } }urlCheck字段是关键开发环境下建议关掉否则请求非业务域名会直接被微信拦截。上线前把它改回true并且要把接口域名加到小程序后台的合法域名列表里否则正式环境所有请求都会失败这属于低级的致命错误。4.2 接口联调本地 mock 与请求转发后端接口没就绪时前端不能干等。项目里utils/request.js封装了一层请求器所有业务请求都要走它。联调阶段我一般在这里做一个环境开关// utils/request.js const BASE_URL http://localhost:3000/api const USE_MOCK true const request (options) { return new Promise((resolve, reject) { if (USE_MOCK) { // 本地 mock 数据拦截请求路径返回对应结构 const mockData loadMock(options.url) if (mockData) { setTimeout(() resolve({ data: mockData }), 200) return } } uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, success: (res) resolve(res.data), fail: reject, }) }) }loadMock从本地的一个静态 JSON 映射表里找对应路径的返回数据。这样后端还在写接口时前端已经能把整条答题链路跑通。等后端接口就绪把USE_MOCK改成false同时确认BASE_URL指向的是局域网可达的地址。如果后端跑在电脑本地而你要用真机预览微信开发者工具里勾选「不校验合法域名」后真机才能通过局域网 IP 访问到你电脑上的后端服务。这个问题我当年踩过一次调了半小时以为是跨域问题实际是手机访问不了localhost。4.3 真机预览与断点调试代码在 HBuilderX 里编译点击菜单「运行」-「运行到小程序模拟器」-「微信开发者工具」HBuilderX 会自动拉起微信开发者工具并打开编译产物。第一次运行时会让你确认微信开发者工具的安装路径一般自动识别没问题。真机调试要走微信开发者工具里的「真机调试」按钮它会生成一个二维码用微信扫码后直接在小程序里打开项目同时在开发者工具里能看到 console 日志和 network 请求。这个模式下store 里的状态变化、接口返回的原始数据都能实时看到排查问题效率最高。小程序真机调试有数量和时长限制正式开发时最好多人共用一台调试设备避免浪费额度。如果遇到页面空白或点击无响应先看控制台有没有报setData is not a function或Cannot read property title of undefined这类错误绝大多数是字典里的字段名和模板里的对不上检查接口返回结构对号入座即可。我自己的习惯是每调通一个接口先在控制台把data打印出来看一眼字段再写渲染逻辑能省掉一半的排错时间。5. 避坑指南跨端渲染、资源释放和状态源的四个问题5.1 倒计时切后台后就没动静了现象用户答题途中切到微信聊天再回到小程序发现倒计时停留在切出前的数字秒钟不动了。原因小程序在后台运行时WebView 的定时器会被系统挂起setInterval根本不会触发回调。如果代码里用「每秒减一」的逻辑累计倒计时那么后台流逝的时间全部丢失回来后剩余时间错误地偏多。解决倒计时只用来触发 UI 刷新时间数值一律基于endTimestamp - Date.now()计算。每次定时器回调都重新算差值即使定时器在后台停了回到前台后onShow里同步一次差值页面上的数字立刻跳转到真实值。这个方案在 App 端同样适用原理一致。5.2 原生 radio 在 App 端和微信端长得完全不一样现象单选题用原生radio-groupradio实现在微信小程序里样式正常编译到 App 端后选项圆圈尺寸偏大、排列错位选中颜色不生效。原因原生表单组件在不同宿主环境里保留各自的原生样式CSS 无法完全覆盖。我在改造这个项目时把 radio 全部换成了自绘选项组件用一个自定义option-item内部用view加一个圆点图标模拟选中状态点击区域是整个选项卡片点击后通过click触发 store 里的setAnswer。视觉样式完全可控选中态用边框颜色和背景色双重提示比原生控件好看也更统一。!-- components/option-item.vue -- template view classoption-item :class{ active: isActive } clickhandleClick view classoption-key{{ option.key }}/view view classoption-text{{ option.text }}/view view classoption-check v-ifshowCheck✓/view /view /template script setup const props defineProps({ option: Object, isActive: Boolean, }) const emit defineEmits([select]) const handleClick () { emit(select, props.option.key) } /script这份改动同时解决了多选题的勾选样式——多选选项不需要圆点改成一个方框复选框图标逻辑完全一致只是isActive判断从answer key改成answer.includes(key)。5.3 小程序被杀进程返回后答题记录全丢现象用户答了 30 道题突然接到电话系统回收了小程序进程。重新打开小程序回到答题页答案全部清空。原因答题数据只存在 Pinia store 的内存里进程一没一切归零。解决在每次setAnswer时同步写入本地缓存键名按考试 id 隔离。返回答题页时在loadExam之后检查缓存存在则合并进userAnswers。注意缓存里不光要存答案还要存endTimestamp否则用户重新进入时倒计时会从原始值重新开始实际上考试时间早就过半了。restoreFromLocal() { const cached uni.getStorageSync(exam_${this.examId}_state) if (!cached) return this.userAnswers cached.answers || {} this.endTimestamp cached.endTimestamp || this.endTimestamp }缓存策略不能不分场景照搬如果考试允许「重新再考一次」重新开始时要主动清理旧的缓存对象否则用户第二次答题会被第一次的残留数据污染。5.4 交卷按钮连续点击后端收到两份答案现象网络稍慢时用户以为没点到又点了一次交卷后端日志显示同一时间收到两份提交记录成绩单生成了两条。原因交卷接口没有幂等控制前端也没有对重复点击做拦截。二次确认弹窗刚关闭用户手快再点一次请求又发出去了。解决交卷函数加submitting状态锁请求期间直接 return不进入弹窗。后端也应加一层保护同一个用户在考试结束时间点之前只能提交一次第二次提交返回「已交卷」状态码。我通常还会在交完卷后立即uni.redirectTo跳结果页答题页销毁后连触发入口都不存在了从根上杜绝连点问题。6. 二次开发从单场考试升级为多场次考试系统拿到这套源码后最常见的改造方向不止满足于单场考试而是要做成多场次、多科目、支持模拟和正式两种模式的完整考试系统。我需要跑通的第一条改造链路是数据模型拆分。当前的 store 里只有examId、questionList一份数据多场次就要把这层改成 map键是考试 id值是完整的答题状态对象。切换考试时按 id 取对应状态没进入过的考试走默认初始化。第二步改造是题库展示方式的升级。现有一道题只绑定一个正确答案做模拟练习时用户希望提交后立刻能看到对错和解析。后端给questionList每道题增加answer和analysis字段前端在交卷时把这两块字段回填到页面里结果页就从「只显示得分」升级成「每题解析」。做到这一步错题本就顺理成章了——本地按考试 id 存储错题 id 数组创建一个专门的错题列表页进入后筛选本地错题记录并重新组卷。第三步是界面主题的可定制化。当前项目里所有颜色都来自uni.scss的变量比如$primary-color、$danger-color、$bg-color改主题时只需替换这几个变量的值全局颜色全变。这套做法对内嵌到客户项目里特别有用不同客户用不同主色品牌定制几乎零成本。我自己的习惯是拿到这种项目先跑通一次完整流程再往下单点深挖把 store 和页面剥离开保证任何新的答题交互都只改一层不改动其他模块。比如加填空题题型时只新增一个question-card的填空分支store 的setAnswer逻辑不需要动因为输入框的值本身就是字符串天然兼容现有的存储结构。从那以后我每次拿到新项目都强制自己先把状态管理边界画清楚再动手写页面这样能避掉 80% 的样式和状态不同步问题。希望这篇拆解能帮你在自己的考试类项目里少走几段弯路。本文还有配套的精品资源点击获取