☰
小型JavaScript答题系统实战:三种题型判分与随机抽题
2026/10/14 3:35:47 网站建设 项目流程

JavaScript练习题这个东西,我其实做过好几个版本了。从最早的简单网页弹窗版,到后来用 Vue 写的完整答题应用,前后踩了不少坑。最近又帮一个朋友重构了他老师布置的“课堂测验”项目,需求挺典型——三种题型(简答、判断、选择)混合在一起,随机出题、即时判分、有答案解析。做完之后我把代码整理了一下,发现这套东西其实非常适合作为前端入门的综合练习,因为涉及数组操作、DOM 操作、事件处理、字符串判断、算法设计,基本把一个初中级前端开发者该有的基本功都串起来了。

这篇文章就用一个完整的实战案例,把三种题型的实现思路、代码结构、判分逻辑、排错经验一次说清楚。不管你是刚学 JavaScript 不久,想找个项目练手;还是已经在写业务代码,想看看纯前端答题系统的设计思路,这篇文章应该都能给你一些可以参考的东西。

1. 整体设计思路与数据结构选型

拿到需求第一件事不是写代码,而是想清楚题目和答案到底用什么样的数据结构存。这一步设计好了,后面渲染、判分、解析全都会顺畅很多;设计得别扭,写起来总觉得哪里都别扭,改来改去更烦。

1.1 三类题型的核心需求解析

先说判断题这种最简单的,题干就是一句话,答案就是两个布尔值之一。他要求直接明明白白:要么对,要么错;第二是选择题,又要分单选和多选,单选答案是一个索引坐标或者字符串;多选答案就是一组索引或者一组字符串,而且多选题判分还有个细节——用户选了多余项怎么处理、漏选了怎么处理,这直接决定判分逻辑写起来多复杂;第三是简答题,简答题最麻烦的地方不在渲染,在判分。用户输入的是自由文本,你不能非要他跟标准答案一字不差,那么试想一下用户如果多打了空格或者结尾忘了句号,你直接判错是不是很冤?所以前期就得定好判分策略,比如忽略首尾空格、忽略大小写、按关键词匹配还是按相似度匹配,这些都需要在数据结构里留好对应的字段。

数据驱动的想法贯穿整个东西,“数据驱动”不是个高大上的术语,其实就是你换题或者改答案的时候,只动 JavaScript 里的那个数组,不用去碰 HTML 结构。这是答题系统也好、问卷系统也好都通用的设计理念——把数据和处理数据代码分开。

1.2 题目数据结构的 JSON 设计

我自己常用的题型标识习惯用的是字符串:judge 对应判断题,choice 对应选择题,short 对应简答题。type 字段就放在每道题对象的第一个位置,后面根据题型附加不同字段。

判断题的对象大致长这样:

{ type: 'judge', question: 'JavaScript 是一门编译型语言。', answer: false, explanation: 'JS 是解释型语言,由引擎逐行解释执行。' }

选择题要复杂一点,单选就一个 options 数组,一个 answer 的下标:

{ type: 'choice', question: '以下哪个关键字用于声明常量?', options: ['var', 'let', 'const', 'static'], answer: 2, single: true, explanation: 'const 用于声明常量,声明后不可重新赋值。' }

多选的时候 answer 改成数组,single 置为 false:

{ type: 'choice', question: '以下哪些是 JavaScript 的基本数据类型?', options: ['String', 'Number', 'Boolean', 'Object'], answer: [0, 1, 2], single: false, explanation: 'Object 是引用类型,不是基本类型。' }

简答题除了题干和参考答案,我一般加一个 keywords 数组,用来做关键词匹配判分:

{ type: 'short', question: '请简述 var 和 let 的区别。', reference: 'var 存在变量提升,let 存在块级作用域且不存在变量提升。', keywords: ['变量提升', '块级作用域'], explanation: '核心答出变量提升和块级作用域两个关键点即可。' }

另外强烈建议每道题都加一个 explanation 字段,不管哪种题型都加。答完题弹出来“为什么对、为什么错”,这个体验对学习者太重要了,没有解析的练习题基本等于白做。

1.3 为什么选择原生 JavaScript 而非框架

现在写项目很多人第一反应就是上 React 或者 Vue,但这个问题我个人建议新手先用原生 JavaScript 实现一遍。原因很直接:你要用框架,组件拆分、数据流、响应式这些概念会分散你对核心逻辑的注意力。而答题系统本身的业务逻辑并不复杂,用原生 DOM 操作完全可以搞定,同时还能让你扎扎实实练一遍 createElement、addEventListener、数组遍历,这些基本功以后再学框架只会更轻松。

而且原生方案有个隐形优势:直接双击 HTML 文件就能跑,不需要依赖安装、不需要构建工具。作为一个课堂作业或者练习项目,给朋友用的时候复制一个文件过去就完事了,省掉一整套工程化的麻烦。我自己做这个版本没有引入任何第三方库,一个 HTML 文件包含全部结构样式脚本,代码量大概四百多行,足够清晰。

2. 核心功能模块拆分与判分逻辑详解

题目数据组织好了,接下来就是怎么把数据变成界面、再把用户操作变成分数。

2.1 题目渲染模块:动态生成与事件绑定

渲染题目的时候,我采取的是整块重绘的方式——每次切换到下一题,就重新生成当前题目的 DOM 结构,填进题目容器里。这样代码写起来最简单,也不用考虑复杂的更新逻辑。

判断题的渲染最容易,两个按钮,一个“正确”一个“错误”,点击的时候把按钮的选中状态处理一下:

function renderJudge(question) { const html = ` <div class="question-text">${question.question}</div> <div class="options"> <button class="option-btn">function checkChoice(question, userAnswer) { if (question.single) { return userAnswer === question.answer; } if (!Array.isArray(userAnswer) || !Array.isArray(question.answer)) { return false; } if (userAnswer.length !== question.answer.length) { return false; } return question.answer.every(item => userAnswer.includes(item)); }

这里有个小设计细节值得说一下:多选题我特意用数量判断做了一层短路——长度对不上就直接判错,代码层面少一次全遍历。你可能会问,如果用户选对了数量但内容不对呢?那 every 遍历自然会返回 false,安全得很。

2.4 简答题智能判分方案(关键词匹配)

简答题的判分是整套系统里最有意思的部分。最简单的方案是字符串全等比较,但用户输入“let 和 var 的不同点”和标准答案“var 存在变量提升,let 存在块级作用域且不存在变量提升”肯定不可能完全相等。这时候就要做宽松匹配。

我从第一版就采用关键词匹配方案,每个简答题设 2 到 3 个关键词,命中全部关键词就算得分,部分命中可以按比例得分。具体的判定逻辑是这样的:

function checkShort(question, userAnswer) { const text = userAnswer.trim().toLowerCase(); const keywords = question.keywords.map(k => k.toLowerCase()); let hitCount = 0; keywords.forEach(keyword => { if (text.includes(keyword)) { hitCount++; } }); const ratio = hitCount / keywords.length; if (ratio === 1) return { score: 10, perfect: true }; if (ratio >= 0.5) return { score: 5, perfect: false }; return { score: 0, perfect: false }; }

做关键词匹配时有个小坑,就是用户简答的内容可能整段包含关键词,比如把题目本身重复了一遍,这种匹配可能误判为正确。所以设计关键词时要注意选那些参考答案里最核心、最不容易在题干里出现的词。还有一个经验是关键词长度不要太短,两个字的词经常会出现无关命中,最好用四个字以上的专业术语,比如“变量提升”和“块级作用域”,命中以后基本就是真懂了。

另外大小写问题也不容忽视。JS 这门语言本身对大小写敏感,但用户回答意图和大小写无关,所以我做比较时一律先 toLowerCase() 再匹配。这个细节虽然代码里就一行,但对判分正确率影响不小。

2.5 随机抽题算法与防止重复

题库里放三十道题,每次只考十道,这就是一个典型的随机抽样问题。我之前见过很多新手直接用 Math.random() 暴力抽,抽完也不去重,结果同一道题出现两次,特别尴尬。标准做法是 Fisher-Yates 洗牌算法打乱题库顺序,然后直接取前 N 道。

function shuffleArray(arr) { const result = [...arr]; for (let i = result.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result; } const examQuestions = shuffleArray(allQuestions).slice(0, 10);

洗牌算法为什么比多次随机抽更靠谱,原理上是这样的:每次从剩余数组中随机选一个元素放到最后,依次操作,能保证每个排列出现的概率均匀分布,不会出现“后面几个位置抽不到”的偏差。这个算法的代码就五、六行,但含金量很高,建议写项目的时候认真吃透它。

3. 完整实现过程与核心代码逐段说明

理论部分说得再多也不如直接看完整代码来得实在。这一节我把整个实现过程按顺序过一遍,每个模块干什么、为什么这么写都标注清楚。

3.1 页面结构与基础样式

HTML 结构我采取区块化的布局写法,从上到下依次是:进度区、题目区、操作区、结果区。每个区块一个 id,方便 JavaScript 精确控制。

<div id="app"> <div id="progress">第 1 / 10 题</div> <div id="question-area"> <div id="question-type"></div> <div id="question-text"></div> <div id="options"></div> </div> <div id="action-area"> <button id="prev-btn">上一题</button> <button id="next-btn">下一题</button> <button id="submit-btn" style="display:none">提交试卷</button> </div> <div id="result-area" style="display:none"></div> </div>

样式上我喜欢用一种清爽的主色调,按钮的状态区分一定要明显:选中状态的选项用蓝色底白字,未选中状态用白底黑字边框,这样用户一眼就能看清自己选了什么。这块审美可以直接照抄成熟答题产品的风格,没有版权问题,自己看着也舒服。

3.2 题库数据与全局状态管理

题库用一个数组定义,为了篇幅这里放几道代表题,实际项目里你往里面填几十道就完事了。全局状态我用一个对象统一管理,包括当前索引、用户答案记录、题目列表等。结构如下:

const allQuestions = [ // 判断、选择、简答各放一道示例 // 完整题库运行时可扩展 ]; let state = { currentIndex: 0, questionList: [], userAnswers: [], scores: [], submitted: false };

data 里存的是用户对每道题的作答。判断题存布尔值,选择题存索引或索引数组,简答题存文本,看起来不统一,但实际使用时只要根据当前题的 type 做对应处理就好,不必强求一个统一结构。

全局状态对象的好处在于,切题、提交、复盘都能从这个对象里拿数据。比如用户做完第一题切到第二题再切回来,第一题的答案早就存在 state.userAnswers[0] 里了,界面重新渲染时会先检查有没有缓存的答案,有就直接回显出来。这个回显逻辑是保持良好体验的关键点。

3.3 题型渲染主函数

渲染主函数根据题目 type 分流处理:

function renderQuestion() { const q = state.questionList[state.currentIndex]; const typeMap = { judge: renderJudge, choice: renderChoice, short: renderShort }; document.getElementById('question-type').textContent = getTypeName(q.type); document.getElementById('question-text').textContent = q.question; typeMap[q.type](q); restoreAnswer(q); updateProgress(); }

我当初写这个函数的时候用了一个策略映射,结构很清晰,比一堆 if-else 分支直观得多。typeMap 把题型字符串直接映射到处理函数,以后就算想扩展第三种、第四种题型,也只需要增加一个函数和一行映射,不用大改主函数。

回显逻辑单独抽成一个函数,原因是渲染和回显是两个关注点——渲染关心“展示什么交互组件”,回显关心“用户之前选了什么”。分开写,逻辑各自清晰,后期维护也容易定位。

3.4 事件委托与答案收集

这里要讲一个很多初学者容易忽略的优化点——事件绑定。如果你直接给 10 道选择题的 40 个选项按钮一个一个绑 click 事件,那没问题;但如果你还考虑题库扩展到 100 题的情况,重复绑定会造成大量无效监听器。所以我用事件委托的方式,统一在 options 容器上监听点击,再根据事件的 target 判断是否点击的是选项按钮:

document.getElementById('options').addEventListener('click', function(e) { const btn = e.target.closest('.option-btn'); if (!btn) return; const q = state.questionList[state.currentIndex]; if (q.type === 'judge') { // 单选逻辑 } else if (q.type === 'choice') { // 单/多选切换逻辑 } });

事件委托的好处有两个,一个是代码总量更少,不用为每个按钮单独绑监听器;另一个是就算后面动态新建了按钮,只要它带 option-btn 类,点击事件也自动生效,不用再绑一次。这种思想在 React、Vue 里也经常变相体现,理解了对后面学框架很有帮助。

答案存储统一封装一个方法,每次用户作答就同步写入 state.userAnswers:

function saveAnswer(index, answer) { state.userAnswers[index] = answer; }

这里把“渲染”和“存答案”分开,之间通过 state 通信,相当于一个极简的发布订阅模式,虽然没用到框架,但设计思想上已经在向框架靠拢了。

3.5 交卷与成绩计算

做完最后一题后点击“交卷”,系统汇总所有题目的判分。这里动了个小脑筋——总分不是固定 100 分,而是根据实际抽出的题目类型占比计算。我在初始化的时候统计每类题型数量,然后分配分值:判断题每题多少分,选择题每题多少分,简答题每题多少分,加起来正好 100。

function calcTotalScore() { let totalScore = 0; state.questionList.forEach((q, i) => { const userAns = state.userAnswers[i]; if (userAns === undefined || userAns === null || userAns === '') { state.scores[i] = 0; return; } if (q.type === 'judge') { state.scores[i] = checkJudge(q, userAns) ? judgeScore : 0; } else if (q.type === 'choice') { state.scores[i] = checkChoice(q, userAns) ? choiceScore : 0; } else if (q.type === 'short') { const shortResult = checkShort(q, userAns); state.scores[i] = shortResult.score; } totalScore += state.scores[i]; }); return totalScore; }

有一种情况要额外处理——用户空着题就交卷。我的处理方案是空题计 0 分并在复盘时标记为“未作答”,而不是按错误处理。这样区别开来,用户复盘时能一眼看出自己是因为没答而丢分还是答错而丢分,学习效果更有参考价值。

3.6 完整代码仓库结构

整个项目所有代码都在一个 HTML 文件里,按 CSS、HTML、JavaScript 三段排列。实际开发中可能觉得全塞在 HTML 里丑,但作为练习题或考试系统,单个 HTML 文件是最便携的交付形式——任何电脑只要浏览器就能打开,不存在跨设备跑不起来的问题。如果以后要部署到服务器上,再把 CSS 和 JS 拆成独立文件也不迟。

4. 常见问题排查与避坑实录

写这个项目的过程中我踩了不少坑,有的坑第一次遇到时排查了很久才发现原因,这里把几个最有价值的记录一下,希望能帮你少走一些弯路。

4.1 多选题判分逻辑的经典错误

我最开始写的多选题判分逻辑是:先判断用户选中数量和答案数量是否相等,再遍历用户选中的每一项是否在正确答案里。表面看没问题,但实际有个致命 bug——如果用户选了 [0, 1],正确答案是 [0, 2],那第一步数量相等通过,第二步遍历用户项发现 1 不在答案里,返回 false,这确实没问题;但如果用户选了 [1, 0],遍历也能通过,因为 0 和 1 都在答案里。这里顺序不影响结果,逻辑其实是对的。

真正的问题出在另一种写法:直接用数组的 indexOf 判断存在性,却忘记处理重复项的情况。比如多选题有两个正确答案 [0, 1],用户如果不小心重复点击同一个选项导致选择数组变成 [0, 0],indexOf 判断两次都等于 0 会判定通过,但用户实际上漏选了 1。所以后来我改成了 every 加 includes 的组合,并且引入长度判断,两个条件都满足才算对,这样重复选项的情况就会被长度条件拦截。

4.2 页面重绘导致简答题输入丢失

这个问题我在 2.1 节提到过,这里再展开说。原始版本里,用户从第一题切到第二题,再切回第一题,第一题 textarea 里的内容就没了,原因很简单——切题时 DOM 被替换,textarea 的输入内容没有被持久化保存。修复方案是先监听 input 事件,在重绘前把 textarea 的当前值存进 state.userAnswers,渲染完成后再把存储值回填进新建的 textarea。简单说就是“先存后取”。这个问题虽然解决起来很直接,但暴露了一个核心思维:界面可以被反复销毁和重建,但数据必须单独存在,这就是数据驱动思想的又一个例证。

4.3 随机抽题产生的重复与漏题

如果你用最简单的方式抽题——循环 Math.random() 生成下标,然后从题库取值,你就会发现生成 10 道题可能碰上重复的。原因没什么玄机,就是随机范围的每次独立抽取,抽到同一个下标的概率客观存在。洗牌算法的引入可以彻底解决:先对整个题库数组做一次随机排序,然后切一刀取前 N 个元素,抽取过程天然不存在重复问题,而且每道题被抽中的概率均等。这里有个用户感知的细节:如果你每次都重新洗牌,用户刷新页面题目顺序会变,这是正常的;如果需要固定顺序,可以把洗牌后的数组存到本地存储里,下次直接读取。

4.4 判断题按钮状态与答案状态不同步

判断题使用两个按钮表示对错,按钮点击后要立即显示选中效果。我踩过的坑是:用户点击“正确”后,又点击“错误”,然后再点“正确”,这时候监听器里的逻辑如果只添加选中类名、不处理取消,就会导致两个按钮同时高亮,视觉效果和实际存储答案完全不一致。修复办法很简单,每次点击时先把同组按钮的选中类名全部清空,再给当前项添加,同时更新 state。这类成组互斥按钮的状态管理,在原生 JavaScript 里要多写两三行逻辑,但是不能省,因为用户交互最基础的原则就是“界面表达必须和真实状态一致”。

4.5 交卷后继续作答的状态锁死

交卷完成后,用户可能还会点击选项按钮,这时候如果不做拦截,分数已经算完了,用户操作又改变了界面状态,导致成绩和界面不匹配,看起来很不专业。我的处理方式是在提交后设置一个 submitted 标记,渲染时和事件处理时都判断这个标记,如果已经交卷就直接 return。后续如果有复盘功能,也不允许修改答案,只展示标准答案和用户答案的对比。这个小细节虽然不影响核心逻辑,但对用户体验影响很大。

4.6 本地存储扩展与浏览器兼容性

如果想把答题记录保持到用户关闭浏览器之后,可以用 localStorage。数据量不大,一套题目下来也就几 K 的空间,完全够用。需要注意的关键点是存储格式——localStorage 只能存字符串,所以存入前 JSON.stringify,读取后 JSON.parse。这个扩展我建议有心的人加上,因为这样可以支持“未完成答题退出后重新进入继续答”的功能,体验会提升一大截。浏览器兼容性方面,目前主流浏览器都支持 localStorage,没有历史包袱,可以放心使用。

5. 从可用到好用:进阶优化方向

基础版本做完只能叫“能用”,离真正好用的答题系统还有几步路要走。这一节分享几个很实用的扩展点和设计建议,你可以按需取舍。

5.1 答题统计面板与知识点分析

做完所有题目后,展示一个简单的统计面板:总分、正确题数、错误题数、正确率、每类题型的得分率。这些数据都从 state.scores 中汇总即可,不需要额外存储。知识点维度可以做得更进一步——给每道题打一个标签,比如“作用域”“闭包”“异步”,然后统计各标签的得分率,这样用户能明确知道自己薄弱的知识点集中在哪一块。很多学习类产品都有类似设计,放在这个练习系统里分配两三行代码的成本就能实现,但价值提升非常明显。

5.2 倒计时功能实现要点

限时测试是考试场景的常见需求。实现一个倒计时本质是 setInterval 每秒更新剩余时间,到 0 自动交卷。实现要点有两个,一个是避免用户在倒计时过程中刷新页面导致计时器丢失,可以用指定逻辑在初始化时读取初始剩余时间存到 sessionStorage,刷新后从存储恢复,实现比较基础的“暂停大法”;另一个是注意清除计时器,交卷后必须 clearInterval,不然页面还会默默计时,浪费资源。

5.3 题目随机顺序与选项随机顺序的区分

抽题随机已经很常见了,但选项顺序随机也是防作弊的有效手段。实现方案是把选项数组也做一次 Fisher-Yates 洗牌,同时把 answer 中记录的索引同步变换。比如原题答案是第 3 项,洗牌之后第 3 项可能到了第 1 个位置,那么 answer 就要跟着改成 1。这个同步操作稍复杂,但值得做,因为对多选题来说,选项顺序固定会导致用户记住位置而不是知识点,属于一种“伪掌握”。不过需要留意的是,做了选项乱序以后,如果还想加“试题解析”,里面引用选项内容时要用新顺序的文本,不能用原顺序下标,不然解析会驴唇不对马嘴。

5.4 移动端适配与触屏交互

答题系统大概率会有人在手机上打开。移动端适配最简单的方案是 viewport meta 标签加响应式布局,按钮尺寸调大,选项间距拉开,确保手指点击不误触。触屏交互和鼠标点击在事件层面基本一致,click 事件在移动端同样生效,不需要额外处理 touch 事件。如果需要更细腻的体验,可以监听 touchstart 来减少 300ms 延迟,但现在新浏览器基本都消除了这个延迟,问题不大。

5.5 错误回顾与答案解析的交互设计

交卷后展示所有题目的用户答案、正确答案和解析,有三种布局可选:全部平铺、分题型折叠、按对错分组。我试过多种方案,推荐按对错分组,因为错误题是学习重点,应该优先曝光。每道题的正确解析固定在题目下方展示,用户不用再点一次“查看解析”,减少操作链路。复盘模式的交互和作答模式不同,答题区的按钮要全部禁用掉,避免用户误点。

6. 测试与经验复盘

6.1 我的自测方法与边界用例

写完这个功能后我习惯做的第一轮自测不是直接答题,而是打开浏览器控制台,手动调用判分函数,传入各种边界用例。比如多选题传入空数组、传入重复项、传入超长数组;判断题传入 null、undefined;简答题传空字符串、只传空格、传大小写混合文本。这种方式能快速验证判分函数的健壮性,比在界面上操作快很多。

之后还有一轮 UI 走查——走一遍完整答题流程,重点检查切题回显、交卷按钮显隐、空题交卷这三个高风险环节。建议你也建立这种“先测逻辑,再测交互”的测试习惯,能帮你在项目早期就消灭掉大部分隐蔽问题。

6.2 从一次真实联调中总结的经验

有一次我拿这套答题系统给朋友用作模拟测验,测试到一个知识点不太熟的同学后,反馈最有价值的信息是——解析里只写“答案是 A”根本没用,他需要的是“为什么是 A”。后来我给所有题目都补写了详细解析,并且判断题和选择题的解析里会解释干扰项错在哪里。这个改动直接带来的效果是,错了题以后不用再去翻教材,当下就能理解原因。做学习类工具最容易犯的错误就是只关注“对错”而不关注“解释”,这个教训分享一下,希望对你有用。

6.3 扩展性预留:从模拟测验到正式测验

这套代码的扩展性好到什么程度呢,改成正式考试系统也不费劲。考试与练习的区别主要在于:考试需要记录考生信息、需要服务端校验答案、需要防作弊。前端部分只需把判分逻辑从纯本地改为请求后端接口,把答案提交方式从本地存储改为网络请求,剩下的大部分代码都可以复用。如果你打算后期接后端,建议现在就把题目数据从 JavaScript 数组改成远程 JSON 接口返回,这样前后端彻底解耦,后端换什么技术栈都不影响前端功能。

我从一开始就把题库、判分、渲染这三层分开设计,所以后续扩展一直是“加模块”而非“改逻辑”,这算是我这个项目整体架构里最满意的一点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询