ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

uniapp答题系统源码解析:从JSON数据模型到多端渲染

uniapp答题系统源码解析:从JSON数据模型到多端渲染 简介一套基于uniapp开发的答题/答卷系统项目源码适合需要快速搭建在线考试、问卷调查或知识竞赛类应用的开发者也便于熟悉HBuilderX的学员体验一次编码多端发布APP与小程序的流程。项目用vue页面配合JS逻辑与JSON配置整体结构简洁包含底部导航、列表、轮播等常见交互模块可作跨端开发参考。压缩包内含23个文件以11个vue页面组件为主另有js逻辑与配置文件、JSON页面配置、SCSS样式表及多张PNG/JPG界面素材覆盖从页面渲染到样式适配的主要环节整个压缩包仅159KB体量很轻导入HBuilderX即可查看运行效果。目前已有984人浏览学习适合用于课程设计或项目起步。通过该源码可掌握答题流程设计、基础组件拆分、uni.scss主题定制以及APP/小程序编译中的常见要点目录中分组列表、用户卡片、宫格菜单等模块也能帮助初学者快速理解uniapp的组织方式。1. 答题-答卷系统不直接写原生小程序而是选 uniapp 项目源码来改的核心理由做过微信小程序原生开发的人第一次接触答题或问卷类需求时十有八九会陷入同一个困境页面结构极其相似题目类型却千变万化一套表单逻辑要在答题、考试、问卷、报名表之间反复横跳。原生小程序里每个页面都要写独立的 wxml、wxss、js、json四个文件绑定在一起当题目组件从单选变到多选、评分、填空、图片上传时改动成本几乎翻倍。更重要的是这类项目通常不是只发微信端——公众号 H5、App、支付宝小程序都可能要而原生小程序没有办法一套代码同时覆盖这些平台。uniapp 解决的正是这个核心矛盾用 Vue 语法写一次编译到微信/支付宝/H5/App 多端。答题-答卷系统在 uniapp 里天然适合做成数据驱动渲染的结构——题目是 JSON页面是渲染器状态交给全局 store交卷后把答题记录序列化提交给后端。而当我们拿到一份现成的答题-答卷系统-小程序-uniapp-项目源码时真正要做的不是去读每一行页面代码而是先弄懂这四件事项目怎么组织、卷子数据长什么样、答题和答卷两种模式如何复用同一套渲染器、以及最终怎么过 manifest 配置和各平台审核。这才是这套源码里真正值钱的部分。2. 搭建答题-答卷系统的目录骨架数据模型和状态管理的分工是第一步2.1 页面、组件、请求、工具四层拆分而不是按功能模块堆目录拿到 uniapp 项目源码后第一件事永远是看目录结构。答题-答卷这类业务有一个显著特点管理后台的页面复杂度极高但 C 端用户实际接触的页面只有三四个——列表页、答题/填写页、结果页。所以不建议按答题模块问卷模块这种业务维度去建目录那样只会让两个几乎相同的页面各自维护一套逻辑。我一般会按四层来组织src/ pages/ # 只放路由页面 index/ # 试卷/问卷列表 exam/ # 答题/答卷共用的渲染页核心 result/ # 交卷结果页 components/ question/ # 题目渲染组件单选/多选/填空/评分/上传 custom/ # 通用业务组件倒计时条、进度条、签名板 api/ # uni.request 封装 按业务分的接口文件 store/ # Pinia / Vuex 状态 useExamStore.js utils/ # 题型映射、判分逻辑、缓存读写、格式化页面只做路由和生命周期管理组件只负责渲染和事件抛出状态全部交给 store请求层统一封装。这样当后端要从答完即交改成草稿自动保存、断点续答时改动基本集中在一个 store 文件和一个 api 文件里页面代码可以纹丝不动。2.2 试卷与题目数据模型把卷子设计成 JSON 而不是一堆数据表答题系统的后端如果用关系型数据库设计常见做法是 exam 表、question 表、option 表、answer 表各建一套。但一个合格的前端开发拿到源码时更关注是接口返回的 JSON 结构是否友好。因为 uniapp 端的题目渲染器只认 JSON后端表设计得再规范接口不好用前端照样得写转换层。我建议把卷子接口设计成下面这种嵌套结构这是答题和答卷能共用渲染器的关键前提{ examId: E20241001, title: 前端基础知识测评, type: exam, duration: 1800, questions: [ { id: Q001, type: single, stem: 以下哪个不是 Vue 3 的响应式 API, options: [ref, reactive, computed, createApp], answer: 3 }, { id: Q002, type: multi, stem: uniapp 支持哪些条件编译平台, options: [微信小程序, 支付宝小程序, H5, App], answer: [0, 1, 2, 3] } ] }字段不多但每一个都是刻意为之type 决定渲染器挂哪个组件options 和答案分离保证渲染层不感知答案duration 以秒为单位直接喂给倒计时组件。注意我在 JSON 里故意写了type: exam这就是答题和答卷的区别字段——exam 模式要判分、计分、限时survey 模式不判分、不限时、提交的是用户填写内容而不是选项。数据模型上加一个 type 字段远比为两套需求各建一张表划算。如果拿到的源码是 Vue2 的 options API 写的迁移成 Vue3 组合式 API 也只是改写法数据模型不用动。2.3 用 Pinia 管理答题状态而不是用页面 data 或全局缓存答题系统最头疼的状态有三个当前是第几题、已选了哪些答案、剩余多少秒。如果这些状态散落在页面 data 里一旦用户切后台、锁屏、小程序被销毁重建答题进度就全丢了。用 Piniauniapp 新版 Vue3 项目标配把状态收拢起来// store/useExamStore.js import { defineStore } from pinia export const useExamStore defineStore(exam, { state: () ({ paper: null, // 当前卷子完整数据 answers: {}, // 答题记录 { Q001: 2, Q002: [0, 3], Q003: ... } currentIndex: 0, // 当前第几题 remain: 0, // 剩余秒数 mode: exam // 答题模式exam / survey }), getters: { allAnswered(state) { if (!state.paper) return false return state.paper.questions.every(q state.answers[q.id] ! undefined) } }, actions: { setPaper(paper) { this.paper paper this.answers {} this.currentIndex 0 this.remain paper.duration || 0 this.mode paper.type || exam }, setAnswer(questionId, value) { this.answers[questionId] value }, // 关键写入缓存防小程序被销毁 saveCache() { uni.setStorageSync(exam_${this.paper.examId}, JSON.stringify({ answers: this.answers, currentIndex: this.currentIndex, remain: this.remain })) }, restoreCache(examId) { const cache uni.getStorageSync(exam_${examId}) if (cache) { const data JSON.parse(cache) this.answers data.answers this.currentIndex data.currentIndex this.remain data.remain return true } return false } } })答题记录用题目 id 到答案值的对象保存而不是数组这样随机抽题后答案也能对得上号。.setAnswer里我特意用方法而不是直接改 state好处是以后要加做题日志、答案校验、自动保存只需要在这一个方法里扩展。缓存键名带了 examId用户答两份卷子互不干扰。注意uni.setStorageSync是同步方法频繁调用会阻塞渲染实际项目中一般只在翻页、切后台、倒计时归零这三个时机写缓存千万别在每次 setAnswer 后都同步一次。这个坑在真机上特别明显Android 低端机用 localStorage 同步写入掉帧非常严重。3. 答题模式实现从随机抽题到交卷判分题目渲染器和计时逻辑这样写3.1 随机抽题还是固定顺序源码默认方案和两种改法大多数答题类小程序的题库和试卷是分离的——库是一张大表卷子是从库里抽出来的快照。在 uniapp 源码里抽题逻辑通常放在后端接口里前端只接收已经是定稿的试卷数据。但如果你拿到的源码是把抽题写在客户端的比如本地题库 json 放在 static 目录常见做法是// utils/paperUtils.js export function pickQuestions(questionBank, count, shuffle true) { if (shuffle) { // 洗牌后取前 count 道 const arr [...questionBank] for (let i arr.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [arr[i], arr[j]] [arr[j], arr[i]] } return arr.slice(0, count) } return questionBank.slice(0, count) }用 Fisher-Yates 洗牌而不是sort(() Math.random() - 0.5)是因为后者在 V8 引擎下的随机分布不均。注意一个问题一旦试卷定稿questions 数组的 id 和顺序就不能再动了否则后面判分、回顾、成绩单都错位。如果只是每题答案选项顺序乱序那应该洗 options 数组而不是洗题目数组如果连题目本身都要乱序洗完后立刻把完整试卷存一次缓存交卷时以缓存里的顺序为准不要重新洗一遍。3.2 题目渲染器的核心用一个组件根据 type 分发到不同的题控件这一节是整个答题系统源码里最值得读的部分。页面只有一个pages/exam/index.vue里面循环渲染题目组件题目组件内部再根据 type 分发到具体的单选、多选、填空。!-- pages/exam/index.vue 核心片段 -- template view classexam-page question-display v-for(q, index) in store.paper.questions :keyq.id :questionq :indexindex :modestore.mode changeonAnswerChange / button typeprimary :disabled!store.allAnswered clicksubmitExam {{ store.mode exam ? 交卷 : 提交答卷 }} /button /view /template script setup import { useExamStore } from /store/useExamStore const store useExamStore() function onAnswerChange(questionId, value) { store.setAnswer(questionId, value) store.saveCache() } function submitExam() { if (!store.allAnswered) { uni.showToast({ title: 还有题目未作答, icon: none }) return } // 交卷逻辑路由跳转或直接调用后端接口 } /script渲染器做成同一个页面循环渲染所有题目和常见的一题一页翻页式是不同的交互形态。整页滚动适合答卷/问卷场景用户可以回看、修改一题一页适合手机答题/考试场景防止滑动误触、统计单题耗时。源码如果是一题一页在onAnswerChange里手动调用uni.pageScrollTo或改变 currentIndex 即可切换成滚动模式不需要重写页面结构。题型分发组件里最关键的是 uni-app 的动态组件技巧!-- components/question-display.vue -- template view classquestion-wrapper view classquestion-stem{{ index 1 }}. {{ question.stem }}/view component :iscomponentMap[question.type] :valuemodelValue :optionsquestion.options update:modelValueemitValue / /view /template script setup import { computed } from vue import SingleChoice from ./single-choice.vue import MultipleChoice from ./multiple-choice.vue import FillBlank from ./fill-blank.vue import RatingScale from ./rating-scale.vue const props defineProps({ question: Object, index: Number, modelValue: [String, Number, Array] }) const emit defineEmits([update:modelValue]) const componentMap { single: SingleChoice, multi: MultipleChoice, fill: FillBlank, rating: RatingScale } function emitValue(value) { emit(update:modelValue, value) } /script使用 Vue 的component :is动态组件是这类系统最值得保持的架构决策。以后要加滑动打分题矩阵单选题只需要新建一个组件、在 componentMap 里注册一行渲染页和 store 完全不用改。单选和多选组件内部就是radio-group和checkbox-group加v-for渲染选项不再展开。注意 v-model 语法在 Vue3 里统一为modelValue update:modelValue如果源码是 Vue2 的value input改版时记得把子组件里的 props 名和 emit 事件名一起改掉。3.3 考试倒计时与交卷防抖哪些参数必须暴露、哪些逻辑要放在后台限时答题必须做倒计时。但倒计时在 uniapp 里有个第三方库如uni-countdown可引源码头部也常自己实现。自己写的优势是可控需要剩余时间写入缓存以便小程序被杀后恢复。// utils/timer.js let timer null export function startCountdown(remain, interval 1000, onTick, onComplete) { stopCountdown() let seconds remain onTick(seconds) timer setInterval(() { seconds - 1 if (seconds 0) { stopCountdown() onComplete() return } onTick(seconds) }, interval) return { current: () seconds, stop: stopCountdown } } export function stopCountdown() { if (timer) { clearInterval(timer) timer null } }把 interval 也作为参数暴露成 1000 毫秒是为了某些考试场景需要做最后 60 秒倒计时高亮调用方可以单独再起一个高频计时器。真正的倒计时精度问题在于setInterval在 App 或小程序切后台时会挂起回前台后还要用当前时间减去开始时间校准一遍而不是继续减 interval 次数。源码里如果没做校准我一般会在onShow生命周期里重新计算剩余时间onShow(() { const now Date.now() const lastTick uni.getStorageSync(exam_tick_${store.paper?.examId}) if (lastTick) { const elapsed Math.floor((now - lastTick) / 1000) store.remain Math.max(0, store.remain - elapsed) } uni.setStorageSync(exam_tick_${store.paper?.examId}, now) })注意在校准剩余时间时不能直接remain total - elapsed因为total是总考试时长而用户可能在中途进入过草稿状态。正确逻辑是记录上一次心跳时间和当时的剩余秒数回来时用差值扣减这正是上面代码在做的事。交卷按钮建议在点击后立刻置灰并给出 loading 状态防止用户连续点击两次导致同一份卷子提交两次后端幂等性不能完全依赖前端但前端至少要挡住误触。3.4 判分逻辑写前端还是写后端答题模式专属的权衡这是答题和答卷两条路线本质不同的地方。答卷模式根本没有判分概念而答题模式必然要出成绩。具体判断逻辑写在客户端还是服务端源码里两种做法都有我不止一次见过前端把答案硬编码在 JS 里、通过比对用户选择来得出分数的实现这在联调阶段糊弄一下可以上线就是灾难——所有加密答案等于明文暴露用户用开发者工具看一眼请求体就能把正确答案挖出来。正确做法是接口返回的试卷快照里不含答案字段用户提交答案后由服务端判断正误并返回成绩和每题得分。前端只做展示// api/exam.js export function submitExam(examId, answers) { // 注意这里上传的是用户的答题记录试卷本身已在后端存储 return uni.request({ url: /api/exam/submit, method: POST, data: { examId, answers } }) } // 结果展示 submitExam(store.paper.examId, store.answers).then(res { const { score, detail, duration } res.data uni.redirectTo({ url: /pages/result/result?score${score}detail${encodeURIComponent(JSON.stringify(detail))} }) })为什么还要传 duration因为后端要校验超时提交。前端倒计时归零时只是停止作答并自动调用提交但如果用户把手机时间改回去、或者切后台一段时间再回来前端本地倒计时可能不准服务端必须以收到请求的时间减去试卷下发时间来判断是否超时。唯一例外是纯本地演示的小程序没有后端服务答案必须放前端那至少要把答案包进utils/config.js而不是写死在页面里并用 symbol 混淆一下——这只是拖慢破解速度谈不上安全。4. 答卷模式实现动态表单、草稿自动保存和提交校验的三个差异点4.1 答卷和答题共用一个渲染器但校验逻辑必须分开答卷survey和答题exam在页面形态上极其相似都是展示题干、收集用户输入、提交。但两者的产品逻辑有本质差异这个差异直接决定代码组织方式。答题有标准答案题型局限在单选、多选、判断、填空答卷没有标准答案题型反而更多样——评分题、矩阵题、图片上传、签名、地址选择都很常见。答题关心每道题对错答卷关心每道题是否必填、是否有格式要求。在 store 层面加一个mode字段是最省事的分流方案。提交时根据 mode 走两条不同的验证链// utils/validator.js export function validateAnswers(questions, answers, mode) { const errors [] questions.forEach(q { const value answers[q.id] if (mode exam) { // 答题模式只校验有没有作答 if (value undefined || value null || value ) { errors.push({ questionId: q.id, msg: 请完成本题 }) } } else { // 答卷模式按每个题型的必填规则校验 const required q.required ! false if (required isEmpty(value, q.type)) { errors.push({ questionId: q.id, msg: q.errMsg || 本题为必答项 }) } // 题型专属校验比如手机号、邮箱 if (value q.validate) { const ok checkByType(q.validate, value) if (!ok) errors.push({ questionId: q.id, msg: q.validateMsg }) } } }) return errors }q.required和q.validate都是题干 JSON 里可选的扩展字段后端不用为问卷单独建表结构前端也不用写第二套校验器。这里有个容易踩的细节答题模式下选错了不算校验失败只有没选才算而答卷模式下选了什么、填了什么都算合法输入唯一要拦截的是空值和不合法格式。把这两件事放在同一个函数里用 mode 分流比复制一份函数然后改成问卷专用要干净得多。校验不通过时常见做法是滚动定位到第一道出错题同时触发表单组件的错误高亮状态。4.2 动态表单渲染的三种题型扩展评分、图片上传和手写签名问卷类型的动态表单里有三类题型在答题系统中很少见但一旦出现就需要单独写组件。第一是评分题通常展示 5 星或 10 分刻度第二是图片上传需要接入uni.chooseImage把本地图片先传到对象存储再回填 URL第三是签名板这类题型的实现依赖 canvas 触摸事件在微信小程序里和 H5 上的 canvas API 行为差异很大。评分题实现最简单只是把答案类型从布尔改成一个数字但注意分值映射必须和展示刻度保持一致后端评分阈值一旦变化前端刻度也要同步。图片上传则复杂得多核心陷阱是 uni-app 里uni.uploadFile是独立请求不走uni.request的拦截器所以 token 要手动放进 headerfunction uploadImage(questionId) { uni.chooseImage({ count: 1, success: (res) { const tempFilePath res.tempFilePaths[0] uni.uploadFile({ url: ${BASE_URL}/api/upload, filePath: tempFilePath, name: file, header: { Authorization: Bearer ${getToken()} }, success: (uploadRes) { const { url } JSON.parse(uploadRes.data) // 回填到答题记录并触发保存 onAnswerChange(questionId, url) } }) } }) }文件名、文件大小限制、上传进度条这些细节源码里往往一带而过但上线后像传了张 4K 照片导致内存暴涨、安卓端拍照后图片被旋转 90 度这类问题都集中在图片上传环节。canvas 手写签名在真机上建议做两件事触摸事件用touchstart/touchmove/touchend或微信小程序的bindtouchmove以及画完立刻uni.canvasToTempFilePath转成图片存到存储——不转换的话切个页面签名就没了。4.3 草稿自动保存与恢复断点续答最容易被忽略的边界答卷类小程序的留存率很大程度上取决于草稿功能。用户填到第 8 题接了个电话切回来发现要重填大概率直接卸载。草稿保存的策略我在前面 store 的saveCache一节的实现已经覆盖这里补充三点关键配置。第一存到uni.setStorageSync还是后端草稿接口取决于卷子长度。超过 20 题的问卷答案体积可能到几 KB本地存储没问题但换手机了草稿不跨端后端草稿要额外写防抖——用户每改一题就调一次接口后端压力很大。我一般按本地缓存实时写、后端草稿 60 秒一次的双轨方案本地保即时性后端保跨端。第二恢复草稿的时机要注意。页面onLoad时先查 URL 上有没有draft1参数有草稿才恢复没有参数直接恢复草稿会引发一个 bug——用户明明想很交一份新答卷却莫名加载了上次的填写记录。恢复后还要在页面顶部显示已恢复上次未提交的答卷 修改/放弃的提示条。第三草稿的过期策略。公司的满意度调研问卷可能一个季度做一次三个月后用户看到旧草稿是合理的但一场双十一答题抽奖结束后草稿必须立即失效。所以草稿缓存里必须带expireAt字段恢复时先判断restoreCache(examId) { const cache uni.getStorageSync(exam_${examId}) if (!cache) return false const data JSON.parse(cache) if (data.expireAt Date.now() data.expireAt) { uni.removeStorageSync(exam_${examId}) return false } // 注意恢复后对答案和缓存进行浅拷贝避免污染原始数据 this.answers { ...data.answers } this.currentIndex data.currentIndex this.remain data.remain return true }注意最后把缓存解析后的对象浅拷贝进 store而不是直接this.answers data.answers因为后续任何对页面的改动都可能反向污染下次恢复的缓存数据。这是对象引用传递的经典坑位答题源码里如果这段写错用户会出现答完题提交后下一份卷子里全是上一份的答案的诡异问题。4.4 提交前的校验与埋点用户乱填和真退出要区分开答卷提交前的校验除了格式还有一个容易被忽略的维度——用户是否在认真作答。产品经理经常要求看的指标是平均作答时长和完答率这两项都依赖前端埋点。我在提交函数里通常会加三个时间戳打开试卷时间、每题首次作答时间、每题最后修改时间。function collectTimeline(store) { const timeline { examId: store.paper.examId, submitTime: Date.now(), duration: store.paper.duration ? store.paper.duration - store.remain : Math.floor((Date.now() - store.openTime) / 1000), perQuestion: store.paper.questions.map(q ({ qid: q.id, firstSeen: store.seenAt[q.id], answered: store.answers[q.id] ! undefined })) } return timeline }这段数据跟答案一起 POST 给后端后端能算出很多有价值的报表第三题被 80% 用户反复修改说明题目有歧义第 6 题填写的平均耗时为 X说明可能需要加说明文案。埋点不用接入专业的埋点 SDK先把这个数据结构存入后端等数据量到了再看是否需要 GRPC 上报或异步队列。5. 源码落地到小程序上架manifest 配置、动态标题和几个必排的移动端坑5.1 manifest 和微信小程序设置页面 json 里必须改的五个字段一份 unlaipp 项目源码转成微信小程序最容易出问题的不是代码而是 manifest 配置和各页面 json 的编译设置。使用 HBuilderX 导入源码后第一件事是右键项目重新识别 manifest.json然后逐项核对mp-weixin配置appid 要替换成自己申请的小程序 id 而不能用源码自带的permission字段里如果调研答卷需要获取位置或相机权限要同步声明用途说明文案否则微信后台审核会驳回答卷类小程序提交时引用的隐私协议隐私弹窗里每一项收集的信息都得对应实际用到的权限接口。页面 json 里常见的两个坑一是navigationBarTitleText是写死的答题页和结果页应该用uni.setNavigationBarTitle动态设置成第 X 部分 / 已答完正在生成成绩单这正好踩中热词里的小程序动态设置标题。二是enablePullDownRefresh默认为 false不要打开——答题中用户下拉就刷新页面的体验极度糟糕万一后端在答题中途返回新试卷数据当前进度全毁。源码里如果哪一页开着下拉刷新先关掉再说。5.2 分享给好友和扫码进入的落地页必须处理的参数透传考试答题类小程序最常见的传播场景是老师发到班级群、学员点小程序卡片进来。如果分享出去的是通用首页新用户进来还要自己去找那场考试转化率极低。我建议在答题页onLoad里统一承接所有入口的参数onLoad(options) { // 从分享卡片 / 扫码 / 公众号链接带过来的试卷 id if (options.examId) { this.loadPaper(options.examId, options.draft) } else { // 没有参数回列表页 uni.redirectTo({ url: /pages/index/index }) } }onLoad(options)里的 options 在微信小程序里来源很多好友分享的query参数、wx.scanCode扫码后的路径参数、公众号菜单跳转带的参数。draft这个参数在管理端把链接发给指定员工补填的场景下很有用草稿恢复逻辑里我特意判了它。分享按钮的自定义文案和封面也要处理——uni.showShareMenu配合onShareAppMessage动态返回标题而不能默认截屏发出去不然对方收到的是一个毫无上下文、标题为答题的卡片。5.3 真机联调的三个高发问题滚动穿透、字体缩放和安卓 WebView 兼容性答题页顶部是倒计时条、中间是题目、底部有上一题/下一题按钮的布局在 H5 或 App 端最容易出现倒计时条被列表带上去的滚动穿透。解法是题目列表单独scroll-view滚动外层页面固定定位或者拿到源码后检查catchtouchmove是否在固定元素上。这个问题在真机上是强复现的半屏答题的 H5 页面尤其常见。第二个高频坑是用户在小程序设置里把字体调大导致题干和选项文字换行后布局错乱。答题系统里每个题目卡片都应该是独立测量高度不能用固定高度 overflow hidden否则字体一大选项直接截断。截断之外还有覆盖文字溢出遮住下一题的题号这类 bug 在 iOS 和 Android 上表现不一致调试时要两套设备对照。第三个坑是关于安卓端 WebView 的某些 CSS 兼容性。uniapp 编译到 H5 再嵌入 App WebView 时position: sticky的吸底按钮在部分安卓 WebView 里行为异常表现为按钮吸底两个像素抖动或者说吸底不生效。低版本 Android WebView 里同时用flex和vh单位也可能出问题。如果源码编译到 App 端出现布局异样优先检查是在微信小程序还是 App WebView 模式复现的再按条件注释掉相关 CSS 做二分定位。这层兼容调试的投入远大于功能编写答题页又是交互密集页面列在这提醒先把基础布局验证一遍再去写复杂动画。本文还有配套的精品资源点击获取
返回列表