微信小程序计算机考研刷题平台源码拆解:题库设计与刷题流程实现
做微信小程序的计算机考研刷题平台我前后折腾过不止一个版本。最早用纯网页做后来做成小程序才真正被一批备考的同学天天打开。今天要拆解的这套源码核心就三块题库怎么存、刷题流程怎么做交互、用户数据怎么同步。三件事理顺了整个平台就立住了。这个项目适合正在准备数据结构、操作系统、计算机网络这些科目的考生用也适合想拿微信小程序练手的开发者参考毕竟小程序这套技术栈在工具类产品里非常通用。很多同学拿到源码第一件事就是急着跑页面结果卡在登录、数据结构对不上、列表加载不出来这些地方。这篇文章我把整套实现思路和踩过的坑一起讲清楚你可以直接照着做也可以拿源码当底子往上加功能。下面我会从整体架构一直拆到具体代码尽量把每一个“为什么这么做”都说透。1. 先想清楚这个刷题平台应该怎么拆1.1 为什么是微信小程序而不是网页或 App计算机考研刷题是个典型的碎片化高频场景。通勤路上刷十道选择题睡前过两个知识点有些人还在图书馆排队时顺手做几题。小程序不需要安装、扫码就能打开用完就关这个使用成本比 App 低太多。对开发者来说小程序的 WXML 语法和 HTML 很像CSS 布局熟悉的话可以直接上手部署和分发也都有现成链路个人开发者完全可以独立维护。还有一点非常现实小程序发布体验版之后可以直接发给同学和备考群用不用下载安装包也不用配测试环境。收集反馈的路径极短这对一个学习工具类项目太重要了。我自己迭代的第一版和第二版就是靠体验版微信链接在十几个同学那里跑了两轮拿到了不少真实使用数据比如哪个章节的错题率异常高、哪个页面退出率明显这些都是后来优化题库和交互的重要依据。1.2 目标用户与功能清单这个平台面向的是一年四季都在备考计算机考研的同学主流科目是数据结构、计算机组成原理、操作系统、计算机网络有些学校还会考数据库。所以刷题平台第一层分类必须是学科而不是上来就混着刷。功能上我把需求分为三档必做学科浏览、章节刷题、随机刷题、单选/多选/判断题作答、即时判分、显示答案解析进阶错题本、收藏夹、本地做题记录、学习进度统计扩展模拟试卷、知识点标签、搜索、每日一题、排行榜这套源码里“必做”和“进阶”都已经实现了“扩展”部分留了接口。比如知识点标签字段题目数据里提前留好后续加“按知识点刷题”功能的时候直接过滤就能用不用推翻重来。做源码项目最怕的就是需求一变就要改数据结构所以在前期设计里多留几个扩展字段后面会非常省事。1.3 数据流向与整体架构小程序刷题平台的数据流说白了就是“题库进出 用户进度”。题库数据推荐用静态 JSON 文件管理用户自己的做题记录、错题、收藏则要走服务端或者云开发数据库。整体架构是经典的三层页面层负责展示和交互数据层负责题目和用户数据逻辑层负责判卷、抽题、统计。这里要特别提醒一点微信小程序的逻辑层和渲染层是分离的setData 传输有开销。所以写代码时脑子里要有一条清晰的线哪些逻辑放在 Page 里哪些逻辑抽出来放到公共工具文件里。我见过不少项目把所有函数都堆在页面里后期改一改逻辑就要翻几百行代码非常痛苦。正确做法是像 getQuestionsBySubject、shuffle、buildPaper 这类纯函数全部放到 utils 里统一管理页面里只处理视图交互和状态流转。2. 题库结构设计整个平台的地基2.1 题目 JSON 到底怎么设计题库是刷题平台里最重要的资产。数据结构设计不好后面做学科分类、随机抽题、错题推荐都会很痛苦。我建议把题目统一放进一个 questions.json 文件每条题目是一个对象{ id: ds_101001, subject: data_structure, chapter: 3, type: single, difficulty: 2, question: 在长度为 n 的顺序表中删除第 i 个元素时需要移动元素的次数是, options: [n-i, n-i1, n-i-1, i], answerIndex: 0, analysis: 删除第 i 个元素后面的 n-i 个元素需要前移一位。 }有几个字段当初设计时是有讲究的id 要能看出归属我习惯用“学科缩写 编号”。后面做错题上报、日志定位、调试问题都非常方便。你可以直接搜 id 前缀判断是哪一科。subject 用英文枚举值避免中文键在不同端之间产生编码问题。界面上显示的中文科目名统一通过一个映射表做转换。type 字段决定页面渲染的是单选、多选还是判断题这是整个页面交互逻辑的分支点。difficulty 用 1-5 表示。后面如果想扩展“只刷难题”“专项突破”这类功能直接用难度过滤就行。answerIndex 存的是正确选项在 options 里的索引而不是选项字母。原因很简单题目顺序一旦被打乱字母会变索引不会变。analysis 是解析哪怕题目再简单也建议写。考研刷题最有价值的路径就是“做题 - 看解析 - 记知识点”解析是学习闭环里最核心的一环。判断题的 options 就不是四个了直接放两个{ id: os_302018, subject: operating_system, chapter: 2, type: judge, question: 临界区是指进程中用于实现互斥的那段代码区域。, options: [正确, 错误], answerIndex: 0, analysis: 临界区是访问临界资源的那段代码互斥操作由进入区和退出区实现。 }设计判断题时有一种做法是把“正确/错误”复用成两个 options这样前端渲染统一走一套选项组件不需要为判断题单独做一套 UI省了很多代码。2.2 科目和章节怎么管理科目表我建议单独拎出来不要和题目混在一个数组里。这样以后想支持新增科目、调整章节顺序都不用去改题目数据。我用的结构是这样的{ subjects: [ { key: data_structure, name: 数据结构, chapters: [绪论, 线性表, 栈和队列, 串, 树与二叉树, 图, 查找, 排序] }, { key: operating_system, name: 操作系统, chapters: [进程管理, 内存管理, 文件管理, 设备管理] } ] }页面上的目录就靠这个表生成。点科目进入章节列表点章节进入题目列表。这里有一个很容易忽略的体验细节章节顺序必须贴合主流教材的目录顺序。数据结构就按严蔚敏教材的章节排操作系统按汤子瀛的章节排。用户复习时是按这个顺序过的小程序目录和教材对得上使用体验会好很多用户不需要花脑力去映射“平台里的第六章”到底是教材里的哪一章。2.3 随机出题和组卷逻辑怎么实现随机刷题不能每次都在整个 questions 数组上做全局打乱数据量大时会有明显的性能问题。我的做法是先按学科 filter 出题目列表再用 Fisher-Yates 洗牌算法取前 N 条function shuffle(arr) { 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; }组卷的逻辑稍微复杂一点核心是按章节比例取题。比如一套数据结构模拟卷我配置成线性表占 20%、树与二叉树占 20%、图占 15%、查找占 15%、排序占 15%剩下的均匀分配到其他章节。比例配置可以单独放在 config 对象里代码中按比例对章节的题池取题再把取出来的题目合并、洗牌。这就是一套可复用的组卷引擎后续你想做“易错题组卷”“弱项章节组卷”只是换一下比例配置而已。2.4 从题目到知识点的关联我在题目 JSON 里还留了一个可选字段{ id: ds_101032, subject: data_structure, chapter: 6, tags: [图的遍历, DFS, 时间复杂度], type: single, question: DFS 遍历一个具有 n 个顶点、e 条边的图时间复杂度是, options: [O(n), O(e), O(ne), O(n*e)], answerIndex: 2, analysis: DFS 需要访问每个顶点并遍历每条边因此时间复杂度为 O(ne)。 }tags 字段其实就是给题目打标签。有了标签后续做知识点专项训练、错题归纳分析、学习报告都顺理成章。比如你可以统计“图和树的遍历”这类标签下的错题率帮用户看出自己的薄弱点。千万别小看这个字段它是平台从“纯刷题工具”升级成“学习分析工具”的关键。3. 页面实现与刷题核心流程实战3.1 首页与目录设计首页是整个小程序的门面我建议不要堆太多东西。进入小程序后用户最关心的就是“我想刷数据结构的线性表”所以首页直接把科目罗列出来每个科目卡片上显示科目名和做题进度条就够用了。做题进度条的数据从哪里来本地缓存里存了 answerRecords通过统计每个科目下已答过题目的数量和总题目数的比例就是一个进度条。这里要克制住“把排行榜、公告、每日一题全堆在首页”的冲动用户是来刷题的不是来逛商城的。首页每多一个元素用户触达核心功能的路径就长一步。3.2 列表页的“加载更多”到底怎么写很多拿到源码的同学第一眼关注的是首页和刷题页但真正决定使用体验的其实是列表页的“加载更多”逻辑。这里的题目列表不是一次性加载全部题目的而是分页加载每页 10 条用户往上滑触底之后继续请求下一页。核心是三个状态位page、hasMore、loading。每次请求开始前判断 loading 是不是 true是就直接 return避免重复请求。请求结束后 page 加一再判断返回条数是否小于 pageSize如果是就把 hasMore 置为 false停止继续加载。onReachBottom() { if (this.data.loading || !this.data.hasMore) return; this.setData({ loading: true }); const nextPage this.data.page 1; // 这里传入页码从题目数组中切片返回该页数据 const list getQuestionsByPage(nextPage, 10); this.setData({ questionList: this.data.questionList.concat(list), page: nextPage, hasMore: list.length 10, loading: false }); }这里有一个典型的坑onReachBottom 触发时如果用户下滑速度很快会在 loading 还没结束的时候又触发第二次。所以 loading 判断必须是同步判断不能放在 setData 的 callback 里否则很容易出现连续追加两页的情况。我见过不少新手在这里写异步判断结果列表无限追加用户还以为是 Bug。更稳妥的做法是在业务层维护一个 isRequesting 的布尔值请求开始时置 true请求完成后在 finally 里置 false。小程序里虽然没有原生的 finally 概念但你可以保证 setData 的回调里同步修改状态或者在封装请求时用 Promise 的 finally 来收尾。3.3 刷题页的核心状态机刷题页是用户停留时间最长的地方这个页面的状态设计直接决定平台口碑。我定义了五个核心字段currentIndex当前是第几题currentQuestion当前题目对象selectedOption用户选中的选项索引单选是数字多选是数组submitted当前题是否已提交wrongQuestions本次作答中做错的题目 id 列表用户每做一道题都要经历“待作答 - 已作答 - 看解析/下一题”的流转。代码层面我把选项点击封装成一个方法单选时点击选项直接记录选中的索引多选时则是 toggle 进一个数组handleOptionTap(e) { if (this.data.submitted) return; const { index } e.currentTarget.dataset; const current this.data.currentQuestion; if (current.type single || current.type judge) { this.setData({ selectedOption: [index] }); } else if (current.type multiple) { let selected this.data.selectedOption.slice(); const pos selected.indexOf(index); if (pos -1) { selected.splice(pos, 1); } else { selected.push(index); } this.setData({ selectedOption: selected }); } }提交判分的时候单选和判断题直接比较 selectedOption[0] 和 answerIndex多选题必须排序后再比较。不排序的话用户选了 A、B答案是 B、A程序比对不上就会误判。这道排序逻辑是我早期被用户反馈“多选答案明明对了却判错”之后才补上的submitAnswer() { const current this.data.currentQuestion; const userAnswer this.data.selectedOption.slice().sort().join(); const rightAnswer current.answerIndex.slice().sort().join(); const isCorrect userAnswer rightAnswer; if (!isCorrect) { // 写入错题本记录当前题目 id this.addWrongQuestion(current.id); } // 提交后进入已作答状态展示答案和解析 this.setData({ submitted: true, isCorrect }); }3.4 解析展示与错题本、收藏夹的实现答案解析我建议放在一个折叠面板里默认收起用户点一下“查看答案和解析”才展开。这样有两个好处做对的同学不会在每道题上都被冗余信息打扰做错的同学在展开解析时注意力会更集中学习效果反而更好。错题本的数据结构核心是“题 id 错误次数 最近错误时间”不是单纯存一个题目对象。为什么因为当你想做“易错题 Top10”时没有错误次数字段就得遍历所有错题记录来统计效率低有错误次数就能直接按次数排序。同时要记住错题本里的条目应该定期和云端同步但同步之前先用本地存储顶住用户离线也能看错题。收藏夹相对简单存题目 id 和收藏时间即可页面渲染时再根据 id 去题目库里查完整数据。做题记录的本地存储我的做法是每次提交答案都写一条记录const record { questionId: current.id, subject: current.subject, answer: this.data.selectedOption, correct: isCorrect, timestamp: Date.now() }; let records wx.getStorageSync(answerRecords) || []; records.push(record); wx.setStorageSync(answerRecords, records);进度统计就是在这个 records 数组上做 reduce。比如统计总答题数、正确率、各学科完成率都从这里面算。这里要特别提醒本地记录会越积越多建议每次用户进入小程序时把 records 同步到云数据库然后删除本地 30 天以前的旧记录。否则老用户本地存储会被撑满小程序表现会越来越卡。3.5 页面跳转与参数传递从章节列表点到刷题页的时候需要把当前科目和章节传过去。我建议用 URL 参数的方式wx.navigateTo({ url: /pages/quiz/quiz?subjectdata_structurechapter3 });在刷题页的 onLoad 里接收参数然后基于这两个字段从题库里取题目。这里有一个建议不要在 URL 里传完整题目对象URL 长度有限制而且导致页面一刷新就报错。只传定位信息让刷题页自己从题库数组里找题目是更稳的做法。4. 登录、云开发与数据联动4.1 wx.login 与 openid 的获取登录这块要先分清楚两件事wx.login 拿的是临时 code不是用户身份需要拿 code 去后端或者云开发换 openid这才是用户在系统里的唯一标识。2022 年后微信已经不再允许通过 getUserProfile 直接获得头像昵称对于刷题平台来说其实也不需要那么完整的用户信息。直接用 openid 作为用户表的 user_id 就足够了。用户可能连头像都没传一样能正常刷题、记录进度。使用云开发时云函数里写这样一段就能换到 openid// cloudfunctions/login/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) exports.main async (event, context) { const wxContext cloud.getWXContext() return { openid: wxContext.OPENID, appid: wxContext.APPID, unionid: wxContext.UNIONID } }拿到 openid 后在页面上存一份到 globalData 并写入本地缓存。后面所有用户维度的数据操作都以 openid 作为 key。注意云函数的环境 ID 要配置正确很多同学第一次跑云函数报错十有八九是环境 ID 没对上。4.2 云开发数据库的集合设计如果只是静态刷题题目数据放前端 JSON 就够了。但错题同步、跨设备同步进度这些需求必须引入云开发数据库。我常用的集合就四个集合名主要字段用途usersopenid, create_time, nickname用户基础信息questionssubject, chapter, type, options, answerIndex题目数据可以从前端 JSON 导入answer_recordsuser_id, question_id, answer, is_correct, timestamp做题记录wrong_booksuser_id, question_id, wrong_count, last_time错题本云开发数据库的优势是免费额度对个人项目完全够用不需要自己搭服务器。但要注意权限设置比如 answer_records 集合不能默认对所有用户开放读写要在“权限设置”里选择“仅创建者可读写”再配合云函数做数据校验避免用户通过小程序端直接篡改记录。这里推荐把所有写操作都走云函数前端只通过 wx.cloud.callFunction 调用权限控制更安全。4.3 静态 JSON 与云数据库怎么分工我的核心建议是“题库静态化、记录动态化”。题库数据几乎不会变动就算更新也是发新版本时整体替换。放前端 JSON 文件加载快、不依赖网络这是题库内容最好的归宿。用户做题记录这类高频写入的数据则一律走云数据库保证用户换手机、重新登录后数据不丢。这个分工在并发不高、题目几百道的情况下性能和成本都处于一个很舒服的位置。如果题目数量过万打包就不现实了那再考虑把 questions 也迁进云数据库并给 subject 和 chapter 建索引。现阶段没必要过度设计。还有一点如果后续要做每日一题这种运营功能每日题目内容也建议放云数据库这样不用为了改一道题发一版小程序。5. 实操中容易踩的坑和排查思路5.1 自定义导航栏高度适配刷题平台里不少页面是自定义顶栏顶部会顶到手机状态栏下面必须动态计算状态栏和胶囊按钮的高度否则在带刘海的机型上是会重叠的。这段逻辑我之前写错过好几次后来固定成了通用方法const menuButton wx.getMenuButtonBoundingClientRect() const statusBarHeight wx.getSystemInfoSync().statusBarHeight const navBarHeight (menuButton.top - statusBarHeight) * 2 menuButton.height胶囊按钮的 top 和 statusBarHeight 的差值就是导航栏的 paddingTop这是目前最可靠的算法。你在项目里适配所有机型时直接把这个方法放到 utils 里所有页面统一调用。注意这里不要写死在 css 里不同机型状态栏高度完全不同。5.2 wx:key 缺失导致列表渲染告警刷题页面经常用 block wx:for 渲染选项如果不写 wx:key小程序会在控制台打出一堆 warning数据量大时性能也会下降。每次循环的元素尽量用唯一字段不要用 index。因为题目乱序、选项重排时用 index 会导致组件状态错乱。选项的 key 你直接用选项文本字符串也没问题只要保证唯一即可view wx:for{{currentQuestion.options}} wx:key*this classoption-item>