ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

uni-app答题小程序开发实战:跨端状态管理与自动交卷全解析

uni-app答题小程序开发实战:跨端状态管理与自动交卷全解析

简介:这是一份基于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 class="sheet-mask" @click="close"> <view class="sheet-content" @click.stop> <view class="sheet-title"> <text>答题卡</text> <text>{{ answeredCount }}/{{ totalCount }}</text> </view> <view class="sheet-grid"> <view v-for="(item, index) in questionList" :key="item.id" class="sheet-cell" :class="{ answered: userAnswers[item.id] !== undefined, current: index === currentIndex, }" @click="handleJump(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 class="timer"> <text class="timer-label">剩余时间</text> <text class="timer-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-group+radio实现,在微信小程序里样式正常,编译到 App 端后,选项圆圈尺寸偏大、排列错位,选中颜色不生效。

原因:原生表单组件在不同宿主环境里保留各自的原生样式,CSS 无法完全覆盖。我在改造这个项目时把 radio 全部换成了自绘选项组件:用一个自定义option-item,内部用view加一个圆点图标模拟选中状态,点击区域是整个选项卡片,点击后通过@click触发 store 里的setAnswer。视觉样式完全可控,选中态用边框颜色和背景色双重提示,比原生控件好看也更统一。

<!-- components/option-item.vue --> <template> <view class="option-item" :class="{ active: isActive }" @click="handleClick" > <view class="option-key">{{ option.key }}</view> <view class="option-text">{{ option.text }}</view> <view class="option-check" v-if="showCheck">✓</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% 的样式和状态不同步问题。希望这篇拆解能帮你在自己的考试类项目里少走几段弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进