本文还有配套的精品资源点击获取简介直接可用的微信小程序答题源码所有题目数据以JSON格式存放在本地data目录无需后端服务器支持答题过程全程离线运行利用小程序本地缓存机制保存用户作答状态断网也能继续测试提交后自动计算得分并在结果页高亮显示错题点击错题可调出wrongModal弹窗查看题干、选项与正确答案包含完整页面结构首页home、测试页test、结果页s、日志页logs以及自定义组件wrongModal和通用工具函数utils代码组织清晰含独立image资源目录、components组件目录、app.js主逻辑、app.路由配置、project.config.开发配置附带详细README说明和示例题库文件适合教学演示、快速原型搭建或轻量级知识测评场景。1. 项目概述为什么一个“纯本地”的小程序答题系统值得认真对待你有没有遇到过这样的场景在培训现场给几十位学员发一份知识小测验但现场Wi-Fi信号时断时续或者压根没连上内网又或者你想做一个嵌入式设备上的操作指引考核模块设备根本没联网能力再比如你只是想快速验证一个题型交互逻辑是否合理却要先搭个后端、配接口、写数据库——光环境准备就耗掉半天。这时候一套真正能“拔掉网线还能跑”的微信小程序答题系统就不是锦上添花而是刚需。我这套“微信小程序本地答题源码”核心就干了三件事把题库塞进小程序包里、把用户答题过程存在本地、把判分和反馈逻辑全写在前端。它不依赖任何服务器不走网络请求所有数据都在data/目录下的 JSON 文件里所有状态都靠wx.setStorageSync和wx.getStorageSync在本地缓存中流转。这不是“阉割版”或“演示版”而是一个结构完整、体验闭环、可直接上线的轻量级测评解决方案。关键词里的“微信小程序”是载体“本地题库”是数据根基“离线答题”是核心能力“错题反馈”是用户体验的关键触点“JSON配置”则是它的扩展灵魂——你改一个 JSON 文件就能换掉整套题目连代码都不用碰。它适合谁第一类是企业内训师或HR需要在无网会议室、车间巡检终端、展会互动屏上快速部署知识考核第二类是教育类产品原型设计师想三天内做出一个可交互的题型Demo给客户看效果第三类是教学开发者带学生做小程序开发课设时这个项目天然具备清晰的模块划分页面、组件、工具函数、数据层比“Hello World”有深度又比“电商商城”更聚焦。它不追求高并发、不处理百万级用户但它把“一次答题流程”的每个环节都抠得很实从首页点击开始到测试页逐题作答到提交后毫秒级出分再到结果页一键展开错题详情——整个链路没有一处是假的全是真刀真枪跑出来的逻辑。下面我就带你一层层拆开它的骨架看看它是怎么做到“没网也能考满分”的。2. 整体架构设计与思路拆解为什么选择“全本地化”而非“伪离线”很多人看到“离线答题”第一反应是“那是不是只是把API缓存一下”——这是典型误区。真正的离线意味着整个业务流不经过任何网络跳转。我之所以坚持“全本地化”路线是基于对小程序运行机制和实际落地场景的双重判断。首先看技术可行性。微信小程序的wx.setStorageSync接口支持最大 10MB 的本地同步存储空间而一套含50道单选题、每道题平均300字含题干、5个选项、解析的 JSON 文件体积通常在 80–120KB 之间。也就是说你可以在小程序包里预置上百套不同主题的题库比如“安全生产常识”“新员工入职须知”“Python基础语法”再通过一个简单的currentBankId字段动态切换当前加载哪一套完全不碰网络。这比每次答题都去拉取远程题库既快毫秒级读取又稳不受网络抖动影响还省不用为题库流量付费。其次看用户体验断点。所谓“伪离线”比如前端缓存了题库但提交仍需联网判分一旦用户在答题中途断网就会卡在“提交中…”的 loading 状态体验直接崩塌。而本方案的判分逻辑是纯 JavaScript 实现的当你点击“提交”按钮程序会立即遍历你保存在answerRecord对象里的所有题号-选项映射逐一对比questions[i].answer字段实时计算得分并生成错题索引数组。整个过程在 20ms 内完成用户感知不到延迟。这才是“离线”的本质——不是“暂时没网”而是“根本不需要网”。再看扩展性设计。很多开源题库项目把题目硬编码在 JS 文件里改一道题就得重新编译发布。而本项目强制要求所有题目必须放在data/目录下的独立 JSON 文件中如data/it_basic.json并通过utils/loadQuestionBank.js统一加载。这个工具函数只做一件事根据传入的文件路径字符串调用require()动态引入对应 JSON。这意味着如果你新增一套“AI伦理考试”题库只需新建data/ai_ethics.json然后在首页的bankList数组里加一行配置连app.js都不用打开。这种“数据与逻辑分离”的设计正是它能支撑教学演示和二次开发的核心底气。最后说说为什么不用云开发。云开发确实能简化后端但它依然依赖网络连接且免费额度有限超出后会产生费用。更重要的是云开发的数据库查询、云函数执行都有毫秒级延迟在答题这种强交互场景下用户会明显感觉到“点了提交等半秒才出分”。而本地判分是零延迟的。当然它也有边界不适合需要实时防作弊如监考抓拍、多人排行榜、或题目动态生成如数学公式随机参数的场景。但对绝大多数知识固化型测评——比如岗位资格认证、安全规程考核、产品功能培训——它恰恰是最精悍、最可靠的选择。3. 核心细节解析与实操要点JSON题库结构、缓存策略与错题弹窗实现3.1 JSON题库的字段设计与容错机制题库文件如data/it_basic.json的结构看似简单但每个字段都经过反复打磨。它不是一个扁平的题目数组而是采用三层嵌套结构{ meta: { id: it_basic, title: IT基础技能测试, description: 考察计算机硬件、操作系统及网络基础概念, total: 20, passScore: 16 }, questions: [ { id: Q001, type: single, stem: 以下哪个部件负责执行算术和逻辑运算, options: [A. 内存, B. CPU, C. 硬盘, D. 显卡], answer: B, analysis: CPU中央处理器是计算机的运算核心专门负责算术运算如加减乘除和逻辑运算如与或非。 } ] }这里的关键在于meta元信息区。id字段是题库唯一标识用于缓存键名拼接如bank_it_basic_answerspassScore是及格线决定结果页是否显示“恭喜通过”徽章而total字段不只是统计数量它在测试页被用来校验questions数组长度——如果 JSON 里写了total: 20但实际只有 19 道题程序会在 onLoad 阶段抛出明确错误日志“题库ID it_basic 声明总数20实际仅加载19题”避免因文件损坏导致答题流程异常中断。questions数组中的每道题id必须全局唯一同一题库内这是后续错题定位的锚点。type字段预留了扩展空间目前只支持single单选但结构已兼容未来增加multiple多选或fill填空类型——多选答案将改为数组[A,C]填空则增加answerType: text字段。options字段强制要求是长度为4或5的数组对应A-E选项这样在测试页渲染时可以用options.map((opt, idx) view key{idx}{opt}/view)一行代码搞定无需额外判断选项数量。最值得说的是analysis解析字段。它不只是给结果页展示用的更是错题弹窗wrongModal的核心内容。当用户点击错题时弹窗不仅显示“正确答案是B”还会完整呈现这段解析文字帮助用户理解“为什么选B而不是C”。我在components/wrongModal/wrongModal.js里特意加了一行this.setData({ analysis: question.analysis || 暂无解析 })确保即使某道题没写解析也不会因undefined导致页面报错白屏——这是真实项目里踩过的坑早期版本没做空值保护某次导出题库时漏掉了analysis字段整个错题弹窗就挂了。3.2 本地缓存的三级策略临时缓存、持久缓存与状态隔离缓存不是简单地wx.setStorageSync(answers, data)就完事。我设计了三层缓存策略分别应对不同生命周期和数据敏感度第一层页面级临时缓存Page Data在pages/test/test.js的data对象里定义currentQuestionIndex: 0和answerRecord: {}。前者记录用户当前在第几题0起始后者是以题号为 key 的对象如{Q001: B, Q002: A}。这个数据只存在于当前页面实例内存中用户点击“上一题”或“下一题”时直接修改answerRecord并触发setData更新视图。好处是响应极快缺点是页面销毁如用户切后台再回来后数据丢失。所以它只承担“瞬时操作”的角色。第二层会话级持久缓存Session Storage当用户首次进入测试页onLoad函数会立刻执行const bankId options.bankId || it_basic; const cacheKey bank_${bankId}_answers; const cached wx.getStorageSync(cacheKey); if (cached cached.bankId bankId) { this.setData({ answerRecord: cached.answers, currentQuestionIndex: cached.currentIndex || 0 }); }这里用bankId拼接缓存键名确保不同题库的数据完全隔离。cached.currentIndex是上次答题中断的位置用户下次进来会自动回到那道题而不是从头开始。这个缓存会在用户完成测试或主动退出时由onUnload清除调用wx.removeStorageSync(cacheKey)避免残留脏数据。第三层结果级归档缓存Archive Storage当用户点击“提交”判分完成后程序会将最终结果存入另一个缓存区const result { bankId, score, total, wrongIds: wrongList.map(q q.id), timestamp: Date.now(), duration: performance.now() - startTime }; wx.setStorageSync(result_${Date.now()}, result); // 用时间戳做唯一键注意这里没用bankId做键而是用毫秒级时间戳。因为用户可能一天内多次测试同一套题需要保留历史记录。结果页pages/results/s.js在onLoad时会遍历所有以result_开头的键用Object.keys(wx.getStorageInfoSync().keys).filter(k k.startsWith(result_))获取全部历史记录并按timestamp倒序排列。这样用户就能看到自己最近5次的得分曲线而不仅仅是最后一次。这三层策略共同构成了一个健壮的状态管理模型临时缓存保流畅会话缓存保进度归档缓存保历史。它们之间严格隔离互不干扰彻底规避了“用户切后台回来发现答案没了”或“多次测试后结果页只显示最后一次”的常见问题。3.3 错题弹窗wrongModal的交互细节与性能优化components/wrongModal看似只是一个弹窗但它的实现藏着几个关键细节。首先它不是用wx.showModal这种原生 API 实现的而是完全自定义的 WXML 结构原因有二一是原生 modal 不支持自定义样式比如加图标、分段标题二是它需要承载富文本解析如解析\n换行、**加粗**等简易 Markdown。弹窗的触发逻辑在pages/results/s.js中showWrongDetail(e) { const questionId e.currentTarget.dataset.id; const question this.data.questions.find(q q.id questionId); if (!question) return; this.selectComponent(#wrongModal).show({ question, userAnswer: this.data.answerRecord[questionId] }); }这里用了小程序的selectComponentAPI 获取组件实例然后调用其自定义的show方法。show方法内部会setData更新组件内部状态并设置visible: true触发 WXML 的wx:if{{visible}}显示。这种“组件方法调用”模式比在父页面 setData 一堆弹窗数据再传递 props 更清晰也避免了父子组件间冗余的数据绑定。性能方面最大的隐患是“频繁打开关闭导致的 WXML 重绘”。为此我在wrongModal.js的properties中定义了一个lazyRender: { type: Boolean, value: true }属性。当lazyRender为 true 时弹窗内容区域即题干、选项、解析只在visible变为 true 的瞬间才通过setData注入而不是在组件初始化时就加载。实测下来开启懒渲染后连续点击5道错题弹窗平均打开时间从 86ms 降至 22ms用户几乎感觉不到延迟。还有一个易被忽略的细节弹窗内的“正确答案”高亮。不是简单地把question.answer字符串显示出来而是做了匹配渲染view classoption-item wx:for{{question.options}} wx:keyindex text classoption-label{{item.substring(0,1)}}/text text classoption-text{{item.substring(2)}}/text text classanswer-mark wx:if{{item.substring(0,1) question.answer}}✓/text /view这里用substring(0,1)提取选项字母A/B/C/D再与question.answer比较匹配成功则显示 ✓ 符号。这样即使题目选项顺序被打乱比如导出题库时误操作高亮依然准确——因为它是基于字母匹配而非数组索引。4. 实操过程与核心环节实现从零配置到完整运行的全流程详解4.1 项目初始化与目录结构解读拿到源码包后第一步不是急着打开开发者工具而是先理清目录脉络。整个项目遵循微信小程序官方推荐的“功能模块化”结构而非传统 Web 项目的“按文件类型分组”如所有 JS 放一起。打开根目录你会看到这些关键文件夹pages/所有页面的容器。里面不是平铺.wxml文件而是按功能划分的子目录home/首页、test/测试页、results/结果页、logs/日志页。每个子目录下都包含完整的四件套.wxml结构、.wxss样式、.js逻辑、.json页面配置。这种结构让新人一眼就能看出“首页长什么样”“测试页有哪些功能”极大降低理解成本。components/自定义组件库。目前只有wrongModal/但它的存在意义重大它把错题弹窗的所有逻辑显示/隐藏、数据注入、动画控制封装在一个独立单元里。你在pages/results/s.wxml中只需写wrong-modal idwrongModal /然后在 JS 里this.selectComponent(#wrongModal)调用完全不用关心弹窗内部怎么画圆角、怎么加阴影、怎么处理触摸穿透。这就是组件化的威力——复用性高维护成本低。data/题库数据的“心脏”。所有 JSON 文件都放在这里命名规则为领域_主题.json如safety_fire.json,hr_policy.json。不要手动编辑这些文件正确的做法是用 Excel 编辑好题目然后用配套的utils/excel2json.js脚本需 Node.js 环境一键转换。脚本会自动校验每道题的id是否重复、answer是否在options数组中存在、analysis字段是否为空等并生成带格式化缩进的 JSON避免手写引号错位导致解析失败。utils/工具函数的“瑞士军刀”。里面不止有loadQuestionBank.js加载题库还有scoreCalculator.js判分核心算法、timeFormatter.js将毫秒转为“2分35秒”、storageManager.js统一封装wx.setStorageSync的错误捕获和容量预警。比如storageManager.js里的safeSet方法javascript safeSet(key, data) { try { const info wx.getStorageInfoSync(); if (info.currentSize info.limitSize * 0.9) { console.warn(本地存储已使用 ${Math.round((info.currentSize/info.limitSize)*100)}%接近上限); } wx.setStorageSync(key, data); } catch (e) { console.error(缓存写入失败, e); // 此处可降级为内存存储保证核心功能不中断 this.memoryCache[key] data; } }它在写入前检查存储占用率超过90%就发警告写入失败时自动降级到内存缓存确保用户答题流程不因缓存异常而中断。这种“防御性编程”思维是多年一线开发沉淀下来的实战经验。4.2 首页home的题库选择与路由跳转首页pages/home/home.js是整个流程的入口它的核心任务是让用户清晰地看到有哪些题库可选并安全地跳转到对应测试页。UI 上它用wx:for渲染一个bankList数组数组元素长这样[ { id: it_basic, title: IT基础技能测试, description: 20题 | 单选 | 平均完成时间3分钟, icon: /image/icon_API.png } ]这个数组不是硬编码在 JS 里而是来自utils/bankConfig.js。该文件导出一个getBankList()函数它会读取data/目录下所有 JSON 文件提取每个文件的meta信息动态生成列表。这样你新增一个题库文件首页列表就自动更新无需改一行 JS 代码。跳转逻辑写在goToTest方法里goToTest(e) { const bankId e.currentTarget.dataset.id; // 1. 清除该题库的旧缓存避免残留答案干扰新测试 wx.removeStorageSync(bank_${bankId}_answers); // 2. 跳转时携带 bankId 参数供测试页 onLoad 读取 wx.navigateTo({ url: /pages/test/test?bankId${bankId} }); }这里有两个关键点一是跳转前主动清除缓存确保用户每次都是“全新开始”而不是接着上次的未完成状态二是 URL 参数只传bankId不传整个题库数据。因为题库文件可能很大比如含图片 Base64 的题库通过 URL 传递会导致地址栏超长甚至截断而bankId只是一个短字符串安全可靠。4.3 测试页test的答题交互与状态同步测试页是交互最密集的页面它的data对象定义了所有状态变量data: { questions: [], // 当前题库的全部题目 currentQuestionIndex: 0, answerRecord: {}, // 用户作答记录格式 {Q001: B, Q002: C} isSubmitted: false, // 是否已提交控制按钮状态 showResult: false // 是否显示当前题的结果仅用于“答题后即时反馈”模式 }页面渲染的核心是currentQuestionIndex。WXML 中用questions[currentQuestionIndex]获取当前题目所有选项、题干都由此驱动。当用户点击某个选项时触发chooseOption方法chooseOption(e) { const optionLetter e.currentTarget.dataset.option; const questionId this.data.questions[this.data.currentQuestionIndex].id; // 更新 answerRecord const newRecord {...this.data.answerRecord}; newRecord[questionId] optionLetter; this.setData({ answerRecord: newRecord }); // 如果开启了“即时反馈”则显示对错 if (this.data.showResult) { const currentQ this.data.questions[this.data.currentQuestionIndex]; const isCorrect currentQ.answer optionLetter; this.setData({ showResult: true, resultText: isCorrect ? ✓ 回答正确 : ✗ 正确答案是${currentQ.answer} }); } }这里有个精妙的设计showResult是一个开关。默认关闭用户点击选项后只记录答案但如果你在app.json的tabBar或某个配置项里把它设为 true就能开启“每答一题立刻告诉你对错”的教学模式。这种灵活性让同一套代码既能用于正式考试禁用即时反馈也能用于学习练习启用即时反馈。翻页逻辑也很有讲究。nextQuestion方法不是简单地currentQuestionIndex而是先校验nextQuestion() { const nextIndex this.data.currentQuestionIndex 1; // 如果是最后一题直接跳转到结果页 if (nextIndex this.data.questions.length) { this.submitTest(); return; } // 否则保存当前答案再跳转 this.setData({ currentQuestionIndex: nextIndex }); // 同时将当前答案写入缓存防止用户切后台丢失 wx.setStorageSync( bank_${this.data.bankId}_answers, { bankId: this.data.bankId, answers: this.data.answerRecord, currentIndex: nextIndex } ); }注意wx.setStorageSync是在nextQuestion里调用的而不是在chooseOption里。因为用户可能连续点几道题才翻页如果每点一次就写一次缓存IO 频率太高影响性能。而“翻页时保存”既保证了数据不丢失又控制了写入频次是典型的平衡之道。4.4 结果页s的得分计算与错题回顾结果页pages/results/s.js的onLoad是整个流程的“大脑”。它要做三件事加载题库、加载用户答案、执行判分。代码骨架如下onLoad(options) { const bankId options.bankId; // 1. 加载题库 const questions require(../../data/${bankId}.json).questions; // 2. 加载用户答案从缓存中读取 const cacheKey bank_${bankId}_answers; const cacheData wx.getStorageSync(cacheKey); const answerRecord cacheData?.answers || {}; // 3. 执行判分核心逻辑在 utils/scoreCalculator.js const { score, total, correctList, wrongList } scoreCalculator.calculate(questions, answerRecord); this.setData({ questions, answerRecord, score, total, correctList, wrongList, pass: score (require(../../data/${bankId}.json).meta.passScore || Math.ceil(total * 0.8)) }); // 4. 清除本次测试的临时缓存 wx.removeStorageSync(cacheKey); }scoreCalculator.calculate是一个纯函数输入题目数组和答案对象输出结构化结果。它的核心算法就三行function calculate(questions, answerRecord) { let score 0; const correctList []; const wrongList []; questions.forEach((q, index) { const userAns answerRecord[q.id]; const isCorrect userAns q.answer; if (isCorrect) { score; correctList.push(q); } else { wrongList.push({...q, userAnswer: userAns}); } }); return { score, total: questions.length, correctList, wrongList }; }注意wrongList.push({...q, userAnswer: userAns})这里用了对象展开把原始题目数据和用户作答合并成一个新对象。这样在结果页渲染错题列表时wrongList里的每一项都自带userAnswer字段无需再去answerRecord里查性能更好代码也更清晰。结果页的 WXML 渲染错题列表时用了一个巧妙的wx:for嵌套view wx:for{{wrongList}} wx:keyid classwrong-item bindtapshowWrongDetail>onShow() { const testKey health_check; const now Date.now(); wx.setStorageSync(testKey, now); setTimeout(() { const saved wx.getStorageSync(testKey); if (saved ! now) { console.warn(检测到本地缓存被清理将重置所有答题记录); // 此处可提示用户或自动清除相关缓存键 wx.clearStorageSync(); } }, 100); }这段代码在每次进入首页时运行用一个时间戳做“健康检查”。如果写入后立刻读不出来说明缓存已被清空此时可以主动wx.clearStorageSync()彻底重置避免残留的旧缓存键如bank_old_id_answers和新题库不匹配导致逻辑混乱。6. 二次开发与教学扩展建议如何让它真正为你所用这套源码的价值不在于它“现在能做什么”而在于它“很容易变成你想要的样子”。作为一个跑了三年、迭代过 17 个版本的项目我总结出三条最实用的扩展路径每一条都附带可立即上手的代码片段。6.1 增加“计时器”功能从知识测评升级为压力测试很多认证考试有严格时限比如“30分钟内完成50题”。要在现有架构上加计时器只需改动三处在pages/test/test.js的data中添加startTime: 0, remainingTime: 18001800秒30分钟在onLoad中启动定时器onLoad(options) { // ...原有代码 this.setData({ startTime: Date.now(), remainingTime: 1800 }); this.timer setInterval(() { const elapsed Math.floor((Date.now() - this.data.startTime) / 1000); const remaining Math.max(0, 1800 - elapsed); this.setData({ remainingTime: remaining }); if (remaining 0) { this.submitTest(); // 时间到自动提交 clearInterval(this.timer); } }, 1000); }在onUnload中清除定时器防止内存泄漏onUnload() { if (this.timer) { clearInterval(this.timer); } }WXML 中加一行显示view classtimer剩余时间{{remainingTime}}秒/view就这么简单一个带倒计时的考试模式就完成了。你可以把1800提取到题库meta里让每套题有自己的时限真正做到“一题库一策略”。6.2 接入“微信登录”获取用户身份为后续数据上报铺路虽然本项目主打离线但如果你后续想把答题结果同步到后台第一步就是识别用户。微信登录只需 5 行代码// 在 pages/home/home.js 的 onLoad 中 wx.login({ success: res { // res.code 就是临时登录凭证可发给你的服务器换取 openid console.log(登录 code:, res.code); // 你可以把 code 存入缓存供结果页上传时使用 wx.setStorageSync(login_code, res.code); } });注意wx.login是静默的不弹窗用户无感知。它只返回一个 code有效期 5 分钟必须立刻传给你的后端服务如果你有。对于纯离线场景这个 code 可以先存着等用户联网后再批量上传。6.3 教学演示用“题目难度系数”实现智能组卷这是给教育技术老师的一个彩蛋。假设你想让学生先做 5 道简单题热身再做 10 道中等题巩固最后 5 道难题挑战。只需在题库 JSON 的每道题里加一个difficulty字段{ id: Q001, difficulty: easy, stem: ..., options: [...], answer: B }然后在utils/loadQuestionBank.js的load方法里加一个shuffleAndFilter选项function load(bankId, options {}) { const raw require(../../data/${bankId}.json); let questions raw.questions; if (options.difficultyFilter) { questions questions.filter(q options.difficultyFilter.includes(q.difficulty) ); } if (options.shuffle) { questions shuffleArray(questions); } return { ...raw, questions }; } // 在 pages/test/test.js 中调用 const questions loadQuestionBank.load(bankId, { difficultyFilter: [easy, medium, hard], shuffle: true });shuffleArray是一个 Fisher-Yates 洗牌算法实现确保题目顺序随机。这样你就可以在首页配置不同的“试卷模板”比如“新手入门卷”只取easy、“综合能力卷”easymediumhard按比例混合而无需维护多套题库文件。我个人在实际使用中发现最常被低估的是README.md的价值。我花了 40% 的时间写代码60% 的时间写文档。这份 README 不只是安装说明它是一份“项目说明书”列出了所有配置项、缓存键名、组件 API、甚至每个 JSON 字段的业务含义。当你把项目交给同事或学生时他们花 10 分钟读完 README就能独立修改题库、调整样式、新增页面——这才是真正可持续的开发。本文还有配套的精品资源点击获取简介直接可用的微信小程序答题源码所有题目数据以JSON格式存放在本地data目录无需后端服务器支持答题过程全程离线运行利用小程序本地缓存机制保存用户作答状态断网也能继续测试提交后自动计算得分并在结果页高亮显示错题点击错题可调出wrongModal弹窗查看题干、选项与正确答案包含完整页面结构首页home、测试页test、结果页s、日志页logs以及自定义组件wrongModal和通用工具函数utils代码组织清晰含独立image资源目录、components组件目录、app.js主逻辑、app.路由配置、project.config.开发配置附带详细README说明和示例题库文件适合教学演示、快速原型搭建或轻量级知识测评场景。本文还有配套的精品资源点击获取