这段时间帮人做了一套带课程测验性质的小系统,需求不算复杂:题目展示、选项作答、倒计时交卷、分数统计,再把错题整理出来。技术选型上没犹豫,Bootstrap 加 Vue 这个组合足够稳妥。Bootstrap 负责栅格布局、卡片、进度条和各类表单样式,Vue 负责题目流转、答案收集、计时和判分逻辑。实际动手做下来,整个前端核心代码不到一千行,部署之后跑得很稳。如果你正好想给自己的课程、培训甚至个人知识测验做一个在线测试页面,这篇内容就是按“抄作业标准”写的。
这套东西最终实现的功能清单如下:从题库随机抽题、支持单选、多选、判断三类题型、答题过程中可以随时上下切题、倒计时结束自动交卷、交卷后展示得分、正确率和错题解析、刷新页面还能保留答题进度。下面我按设计的先后顺序,把每一个环节的思考过程、代码写法、踩过的坑都拆开讲清楚。
1. 从需求到方案:在线测试系统的设计思路
1.1 明确“基本功能”到底包含什么
“在线测试基本功能”这句话听起来简单,真到动手时会发现边界很模糊。我整理需求时,把“基本功能”拆成了四层:题目层、答题层、判分层、结果层。
题目层负责题库的读取和随机抽取,答题层负责把题目渲染出来、收集用户选择,判分层负责把用户答案和标准答案对比,结果层负责把分数、正确率、错题解析展示给用户。四层之外还有两个隐性问题:一个是倒计时如何处理,一个是页面刷新后答题进度怎么不丢。这两点在初版需求里没有提,但实际使用中几乎必然会遇到。
我的建议是,先按这个四层模型把项目结构搭出来,不要一上来就写页面。数据模型明确之后,剩下的事情都是往框架里填东西。
1.2 技术选型:为什么是 Bootstrap 加 Vue
有人会问:现在 UI 框架那么多,Element Plus、Ant Design Vue 不香吗?香,但也要看场景。如果这是一套内部测验工具,不需要组件库级别的复杂度,Bootstrap 加原生 Vue 反而是投入产出比最高的选择。
Bootstrap 的好处在于生态成熟、栅格系统稳定,预置的按钮、进度条、卡片、模态框都是现成的,而且对响应式布局的支持非常省心。Vue 则负责把数据和 DOM 绑定起来,省掉了一堆手动操作 DOM 的脏活。两者的分工是:Bootstrap 管外观和布局,Vue 管数据和交互。
这种组合的学习成本也低。Bootstrap 基本不需要“学习”,会查文档就能用;Vue 的模板语法也足够直观。如果团队里有后端同事想参与前端,这套组合不会造成认知负担。我实际做完后对比过,用这套组合写在线测试,代码量大概是用原生 JavaScript 做同样功能的三分之一。
1.3 数据模型先行
很多人写前端项目习惯先画页面,我恰恰相反,先定义数据。数据模型定了,页面上要展示什么、绑定什么事件,全部跟着走。
题库数据我设计成这样的结构:
const questionBank = [ { id: 1, type: 'single', // single 单选 / multiple 多选 / judge 判断 stem: 'Vue 3 组合式 API 中,用于创建响应式数据的是?', options: [ { key: 'A', text: 'data()' }, { key: 'B', text: 'ref 和 reactive' }, { key: 'C', text: 'computed' }, { key: 'D', text: 'methods' } ], answer: ['B'], analysis: '组合式 API 通过 ref 和 reactive 创建响应式状态。' } ];注意几个设计细节:
答案字段用数组而不是字符串,这样单选和多选可以统一处理。判断题本质上也是单选,只是选项固定为“正确/错误”,所以我没有单独设计类型,而是把判断题的 options 直接写死。每个选项用 key 字段而不是数组索引来标识,这在大事小事上都能避免对错位的尴尬。
答题记录的数据结构是:
const answerMap = { 1: ['B'], 2: ['A', 'C'] };键是题目 id,值是用户选择的选项 key 数组。这个结构的好处是存 localStorage 特别方便,序列化之后就是一个普通的 JSON 字符串。
2. 项目搭建与运行环境准备
2.1 初始化项目结构
考虑到可维护性,我没有直接用单 HTML 文件塞全部代码,而是用 Vite 初始化了一个 Vue 3 项目,目录结构如下:
online-test/ ├── index.html ├── package.json ├── vite.config.js └── src/ ├── main.js ├── App.vue ├── data/ │ └── questions.js ├── utils/ │ └── storage.js └── components/ ├── QuestionCard.vue ├── AnswerSheet.vue ├── TimerBar.vue └── ResultPanel.vueVite 的启动速度比 Webpack 快得多,反馈及时,调试体验很好。如果你的环境里没有 Node.js,直接用一个 HTML 文件引入 Vue 和 Bootstrap 的 CDN 也可以跑起来。项目结构简化后是:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>在线测试</title> <!-- Bootstrap 5 CSS --> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" /> </head> <body> <div id="app"> <!-- Vue 模板内容 --> </div> <!-- Vue 3 CDN --> <script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script> <script src="src/main.js" type="module"></script> </body> </html>两种方式我都试过,结论是:练手和演示用 CDN 方式最快,一行安装命令都不用;正式交付或者后续要加后端接口,用 Vite 项目更合理。后面的代码示例全部基于组件化写法,但核心逻辑在单文件模式下也能直接复用。
2.2 页面骨架与布局设计
在线测试的页面布局,我参考了考试场景的习惯:
顶部右侧是倒计时,中间是系统标题,左侧是答题卡区域,右侧是题目区域,底部是上一题、下一题和交卷按钮。这个结构对 Desktop 和 Mobile 都友好。
实际布局代码:
<div class="container-fluid py-4"> <div class="row"> <!-- 答题卡区域 --> <aside class="col-lg-3 col-md-4 mb-3"> <div class="card sticky-lg-top"> <div class="card-header bg-white"> 答题卡 </div> <div class="card-body"> <div class="d-flex flex-wrap gap-1"> <button v-for="(item, index) in questions" :key="item.id" class="btn btn-sm" :class="answerBtnClass(index)" @click="jumpTo(index)" > {{ index + 1 }} </button> </div> <button class="btn btn-success w-100 mt-3" @click="handleSubmit"> 交 卷 </button> </div> </div> </aside> <!-- 题目区域 --> <main class="col-lg-9 col-md-8"> <QuestionCard :question="questions[currentIndex]" :selected="answerMap[questions[currentIndex].id] || []" @update="handleAnswerUpdate" /> </main> </div> </div>sticky-lg-top 是 Bootstrap 提供的吸顶效果,答题卡在滚动时会自动吸附在顶部,对长题单非常实用。答题卡上每个按钮的颜色状态,我用一个方法动态计算:已作答是蓝色浅底,未作答是灰色。这种方法比写死样式灵活得多。
2.3 引入 Bootstrap 后要注意的样式冲突
Bootstrap 默认会给按钮、表格、表单控件套一层样式,这本身是好事,但和 Vue 的组件类名可能会冲突。我踩过一个具体问题:给组件根元素加了 class="card",正好 Bootstrap 卡片样式生效了,自己定义的 .card 里的 padding 却也被连带覆盖,两边样式搅在一起。
后面形成了一条约定:组件内自定义样式类名加上 app- 前缀,比如 app-question-card,避免和 Bootstrap 的通用类名打架。样式隔离不是 Vue 独有的问题,用 Bootstrap 时尤其要想清楚哪些是框架负责的、哪些是自己负责的。
3. 核心功能实现:答题交互与数据流
3.1 题目渲染与选项绑定的实现
题目区域是整个系统的核心界面,我用一个独立的 QuestionCard 组件来渲染。这个组件的 props 只有两个:传入的题目数据 question 和当前该题的已选答案 selected。事件也只有一个:update,父组件监听后更新 answerMap。
模板里的关键部分:
<div class="card app-question-card"> <div class="card-body"> <h5 class="card-title mb-4"> <span class="badge bg-primary me-2">{{ currentTypeText }}</span> {{ question.stem }} </h5> <div class="list-group"> <label v-for="opt in question.options" :key="opt.key" class="list-group-item d-flex align-items-center gap-2 app-option-item" :class="{ active: selected.includes(opt.key) }" > <input v-if="question.type === 'single'" type="radio" class="form-check-input" name="answer" :value="opt.key" :checked="selected.includes(opt.key)" @change="selectOption(question.type, opt.key)" /> <input v-else type="checkbox" class="form-check-input" :value="opt.key" :checked="selected.includes(opt.key)" @change="toggleOption(opt.key)" /> <span class="app-option-text">{{ opt.key }}. {{ opt.text }}</span> </label> </div> </div> </div>这里有一个容易写错的地方:单选用 radio,多选用 checkbox,但选中态不能只依赖 input 的 checked 属性。因为 Vue 的响应式数据变化时,DOM 的选中状态必须通过绑定来同步。我在 label 上加了 :class="{ active: selected.includes(opt.key) }",这样用户点击整个选项区域都能看到明显的选中反馈,而不是只能靠一个小圆点判断。
触发的选择方法如下:
const selectOption = (type, key) => { const qid = props.question.id; const current = [...props.selected]; if (type === 'single' || type === 'judge') { // 单选直接覆盖 emit('update', qid, [key]); } else { // 多选切换逻辑 const idx = current.indexOf(key); if (idx > -1) { current.splice(idx, 1); } else { current.push(key); } emit('update', qid, current); } };注意多选切换时,我使用数组浅拷贝 [...props.selected] 来生成新数组,而不是直接修改 props。Vue 的单项数据流要求子组件不能直接变更 props,这个习惯从一开始就要养成。
3.2 单选、多选、判断题的统一与差异处理
三种题型统一数据结构之后,差异就只剩交互方式不同。最简单的方式是判断 question.type,在模板里区分渲染 radio 和 checkbox。这里不要为三种题型写三个组件,额外维护成本不划算。
判断题我这样定义题库:
{ id: 5, type: 'judge', stem: 'Vue 3 的 Teleport 组件可以将内容传送到指定 DOM 节点。', options: [ { key: 'A', text: '正确' }, { key: 'B', text: '错误' } ], answer: ['A'], analysis: 'Teleport 是 Vue 3 内置组件,用于把插槽内容渲染到页面的其他位置。' }type 为 judge 时,选项渲染和单选完全一致,所以完全复用单选逻辑。后期如果增加“填空”题型,再单独写一个 textarea 分支即可,结构上不会因此乱掉。
接口代码和渲染逻辑分离是我从项目最开始就坚持的。题库数据里 type 是字符串,界面里的“单选”“多选”“判断”是另一套展示文案,两者通过字典映射:
const typeTextMap = { single: '单选题', multiple: '多选题', judge: '判断题' };这样题库数据不会被 UI 文案污染,后端接口返回时也更容易对接。
3.3 答题导航与进度反馈
在线测试场景里,用户最关心的两个信息是“做了几题”和“还剩几题”。答题卡正好可以同时承担这两个功能。我维护了一个 computed 属性来计算已答题目数:
const answeredCount = computed(() => { return Object.keys(answerMap).length; }); const progressPercent = computed(() => { return Math.round((answeredCount.value / questions.length) * 100); });进度条直接丢给 Bootstrap 的 progress 组件,视觉上直观,用户可以一眼看到剩余情况。
切题逻辑方面,我用 currentIndex 控制当前题目索引:
const goPrev = () => { if (currentIndex.value > 0) currentIndex.value--; }; const goNext = () => { if (currentIndex.value < questions.length - 1) currentIndex.value++; }; const jumpTo = (index) => { currentIndex.value = index; };跳题逻辑简单,但有一个细节要处理:点击答题卡数字按钮后,页面应该滚动回顶部,尤其是移动端长页面时特别明显。我用 window.scrollTo(0, 0) 解决,倒计时自动交卷后也会触发一次。
3.4 倒计时与自动交卷机制
倒计时是整个系统里最容易写出 bug 的部分。最初的版本我写了这么一段:
const remainSeconds = ref(duration); const timer = setInterval(() => { remainSeconds.value--; if (remainSeconds.value <= 0) { clearInterval(timer); doSubmit(); } }, 1000);表面看没问题,实际用起来有偏差。浏览器标签页切到后台再切回来时,setInterval 会被降频甚至暂停,导致倒计时实际比真实时间慢。解决方法是记住一个结束时间戳,每次刷新剩余时间时用当前时间减结束时间:
const endTime = Date.now() + duration * 1000; const timer = setInterval(() => { const remain = Math.round((endTime - Date.now()) / 1000); if (remain <= 0) { clearInterval(timer); doSubmit(); return; } remainSeconds.value = remain; }, 250);间隔 250ms 比 1000ms 更平滑,而且计算出来的剩余时间不会像累减那样有机会累积漂移。如果要处理页面刷新恢复倒计时,则需要把 endTime 也存进 localStorage,这一点我放在后面的排查部分专门展开。
倒计时显示格式可以直接用 Bootstrap 的 Badge 样式,接近临界值(比如最后 60 秒)时切换成红色的按需样式:
<span class="badge" :class="remainSeconds <= 60 ? 'text-bg-danger' : 'text-bg-dark'" > {{ formatTime(remainSeconds) }} </span>3.5 交卷确认与防误触
误触交卷按钮几乎是必现问题,我见过一位测试同事连点两次直接把卷子交了。防误触的最简单方案是弹一个确认对话框,Bootstrap 的 Modal 在这里排上用场。
做法是:把交卷按钮的 click 指向一个 showConfirmModal 的变量,Modal 确认后才真正执行判分逻辑:
<div class="modal fade" id="submitConfirmModal" tabindex="-1"> <div class="modal-dialog modal-dialog-centered"> <div class="modal-content"> <div class="modal-body"> 确认交卷吗?还剩 {{ answeredCount }} / {{ questions.length }} 题已作答。 </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary">const showConfirm = () => { const modalEl = document.getElementById('submitConfirmModal'); const modal = new bootstrap.Modal(modalEl); modal.show(); };4. 评分逻辑与结果统计
4.1 答案比对与得分计算
判分逻辑是纯函数,这是整套代码里最容易测试、最容易维护的部分。我把判分逻辑放在 utils 目录下,和页面组件完全解耦。
核心实现:
export function calculateScore(questions, answerMap) { let correctCount = 0; let totalScore = 0; const wrongIds = []; questions.forEach((q, index) => { const userAnswer = answerMap[q.id] || []; const sortedUser = [...userAnswer].sort(); const sortedCorrect = [...q.answer].sort(); const isCorrect = sortedUser.length === sortedCorrect.length && sortedUser.every((item, i) => item === sortedCorrect[i]); if (isCorrect) { correctCount++; totalScore += 100 / questions.length; } else { wrongIds.push(q.id); } }); return { correctCount, totalScore: Math.round(totalScore), wrongIds, accuracy: Math.round((correctCount / questions.length) * 100) }; }多选答案比对时,我先把两个数组排序再做完全匹配。因为用户选择的顺序可能和标准答案顺序不同,比如标准答案是 ['B', 'C'],用户选了 ['C', 'B'],如果不排序会漏判。
总分直接按 100 分等比分配到每题上,这是在线测试最常用的计分方式。如果题目难度不同,可以给每道题增加一个 score 字段,在累加时使用 q.score。
判分结果里的 wrongIds,用于错题回顾时快速过滤题目。这样不用再遍历全量题库重新比对一遍答案,性能上有优势。
4.2 结果面板与错题回顾
交卷后页面动态切换到 ResultPanel 组件,使用 v-if 控制显示状态。结果面板我分成三块:
顶部是成绩核心指标,用 Bootstrap 的 row-cols 和 text 样式展示总分、正确题数、正确率三个数字。中间是错题列表,逐题展示题干、用户答案、正确答案、解析。底部是“重新再测”按钮。
错题展示区如果直接平铺所有错题,长题单时会显得很长。我单独维护一个 ref 变量 showWrongDetail,默认展开前两道错题,后面的用一个“展开全部错题”按钮控制:
<div v-for="wid in wrongIds" :key="wid"> <!-- 错题卡片 --> </div>每次进入结果页,我会判断是否还有错题需要展示。全对时的体验也值得设计一下:展示一个 success 风格的图标区和提示文案,而不是让用户看到一堆统计数字。
回到“重新再测”按钮,它在内部执行对 answerMap 的清空,重新调用随机抽题逻辑,再重置倒计时:
const resetExam = () => { currentIndex.value = 0; answerMap.value = {}; questions.value = shuffle(questionBank).slice(0, 20); remainSeconds.value = 600; showResult.value = false; window.scrollTo(0, 0); };这样一套动作完成后,页面自动回到第一题,状态干净利落。我在实际测试中发现,如果重置时忘记清空 answerMap,下一轮测试的答题卡状态会残留,所以清空数据这一步要放在最前面。
5. 界面美化与响应式适配
5.1 用 Bootstrap 的栅格系统做响应式布局
在线测试的基本功能做出来不难,但要让它在手机上也好用,需要花点心思。Bootstrap 栅格系统是我这次布局响应的主力。
桌面端页面宽度大,答题卡在左侧,题目和操作区在右侧,左右分栏比例为 3:9。平板和手机端宽度变小,再保持两栏就会拥挤,所以我把答题卡移动到题目的上方,题目列表变成全宽。
实现方式是 Bootstrap 的 col-lg-3 col-md-4 col-12:
<div class="row"> <div class="col-lg-3 col-md-4 col-12">答题卡</div> <div class="col-lg-9 col-md-8 col-12">题目区</div> </div>Bootstrap 会自动按断点切换布局,移动端下答题卡和题目区纵向堆叠。对移动端用户来说,到题目底部的“上一题/下一题”按钮比较容易选,比迷你答题卡方便得多。
5.2 移动端交互细节优化
移动端的选项点击区域必须足够大,Bootstrap list-group-item 默认的 padding 已经不小,我在手机上又额外加了一点:
@media (max-width: 768px) { .app-option-item { padding: 12px 16px; } }还有一个容易忽略的问题是“下一题”按钮在移动端的触达性。我使用 Bootstrap 的固定底部导航,只在移动端显示,包含“上一题”“下一题”两个按钮。桌面端仍然放在题目下方,因为桌面用鼠标点击,位置并不敏感。
5.3 状态反馈与提示细节
测试过程中用户需要的状态反馈不多,但都很关键:已选中的选项要有明显标记,未作答的题目标记要明显可见,倒计时低于阈值时要变色提示。Bootstrap 已经给了很好的视觉基础,我用两处自定义样式把体验补完整。
第一处是答题卡上的按钮状态:已作答的按钮变成蓝色背景,未作答的是浅灰色,当前题再加一个外圈边框。视觉上的区分度让用户不用打开题目就能定位遗漏。
第二处是切题时的平滑过渡。Vue 的 transition 组件包裹题目卡片,让切换时有一个轻微的横向滑动效果,减少突兀感。这行代码花不了几分钟,但对整体质感的提升值回票价。
6. 常见问题与排查技巧实录
6.1 图标和样式丢失,页面样式错乱
这个问题几乎每个人都会遇到。Bootstrap 引入方式不同,导致部分组件依赖的 JS 没有默认加载。比如 Modal、Dropdown 这类交互组件依赖 bootstrap.bundle.min.js,CDN 引入时只加载 css 是不够的。
一个更隐蔽的问题出在资源加载顺序上。Bootstrap 的 JS 要求在 DOM 渲染之后再执行,如果提前加载可能找不到目标 DOM。我的经验是:把 CDN 的 script 标签放在 body 底部,或者在 Vue 的 onMounted 生命周期里再创建 Modal 实例,这样 DOM 已经渲染完毕,不会报找不到元素的错误。
6.2 组件内部状态和父组件数据不同步
v-model 绑定的双向数据流在某些场景下会失灵。最常见的原因是子组件里没有按 Vue 的约定 emit 事件,或者把 props 直接赋值给了一个内部 ref。后者我写错过一次:在 QuestionCard 组件的 created 生命周期里把 props.selected 直接拷贝到了 data 里,结果点击选项时组件内数组变了,父组件的 answerMap 完全没有感知。
正确做法是:
const emit = defineEmits(['update']); const props = defineProps({ question: Object, selected: Array }); const selectOption = (type, key) => { if (type === 'single' || type === 'judge') { emit('update', props.question.id, [key]); return; } const current = [...props.selected]; // ... 处理多选 emit('update', props.question.id, current); };不修改 props 本身,而是通过事件把新数据传给父组件。这套模式是 Vue 组件通信的基础,严格遵守就能避免同步问题。
6.3 刷新页面后答题数据丢失
单页应用的数据保存在运行内存里,刷新页面就没了。用户答了一半的题,因为误触刷新按钮,所有进度归零,这是最降低信任感的问题。好在 localStorage 可以轻松解决。
我封装了 storage.js:
const KEY = 'online_test_state'; export function saveState(state) { localStorage.setItem(KEY, JSON.stringify(state)); } export function loadState() { try { return JSON.parse(localStorage.getItem(KEY)) || null; } catch (e) { return null; } } export function clearState() { localStorage.removeItem(KEY); }保存的内容包括:当前题目索引、answerMap、剩余时间、题目列表。保存的时机可以放在每次切换题目时和每次更新答案时,这样代码改动很小。刷新后进入页面时先尝试读取 localStorage,读到完整状态就直接恢复。
这里有一个坑:localStorage 不是所有数据都适合存,题目列表如果是完整题库,JSON 序列化之后很容易超过 5MB 的配额。好在在线测试的题目数量一般不超过几十道,序列化后最多几十 KB,完全够用。如果题库特别大,建议只存题目 id 列表,然后按 id 重建题目对象,而不是把整个题目内容存进 localStorage。
另一个细节是,localStorage 存的数据是所有用户共享一个 key。如果多人共用同一台设备,或者同一页面有不同用户切换身份,就要在 key 里加入用户标识。我用的是读取 URL 参数里的用户名拼进 key:
const userId = new URLSearchParams(window.location.search).get('user') || 'default'; const KEY = `online_test_state_${userId}`;这个小改动不算复杂,却能让多用户场景下的数据不会串。
6.4 倒计时在页面刷新后变得不准
刷新恢复倒计时时,如果只保存剩余秒数,等到用户刷新页面,切到后台,过一段时间回来,倒计时可能已经超时了。解决方式前面提到了:保存 endTime 而不是剩余秒数。
function saveExamState() { saveState({ questions: questions.value, answerMap: answerMap.value, currentIndex: currentIndex.value, endTime: endTime.value, duration: duration }); }恢复时重新计算:
function restoreFromStorage() { const saved = loadState(); if (!saved) return; questions.value = saved.questions; answerMap.value = saved.answerMap; currentIndex.value = saved.currentIndex; const remain = Math.round((saved.endTime - Date.now()) / 1000); if (remain <= 0) { doSubmit(); return; } remainSeconds.value = remain; endTime.value = saved.endTime; }恢复后如果发现倒计时已经走完,就直接执行交卷。这套逻辑保证了即使把浏览器挂着好久,倒计时依然准确。
6.5 多选题的答案顺序问题
判分逻辑里如果直接比较数组是否相等,用户选择顺序不同就会被判错。我遇到的问题具体是:标准答案是 CB,用户按选项顺序选了 B、C,两个数组相等判断直接返回 false。
排序比对法解决的不只是这个场景。由于用户可能在多选里选了多个选项再取消,数组元素的顺序会被增删操作打乱。如果记录答案时始终按原顺序 push,会自动保持一致。但如果从 localStorage 恢复的历史数据里数组顺序被打乱过,就只有排序比对能兜底。
这段代码我放在判分工具函数里,单独做了单元测试,确保任何顺序都能正确判定:
const normalize = (arr) => [...arr].sort(); const isSameAnswer = (a, b) => { const na = normalize(a); const nb = normalize(b); return na.length === nb.length && na.every((v, i) => v === nb[i]); };6.6 答题卡按钮跳转的边界场景
答题卡上每个按钮的点击事件绑定 index,如果题库数据经过随机抽题后出现重复,按钮跳转会产生错位问题。我用题目 id 作为循环的 key,不用数组索引。这个习惯能避免一大类数组渲染问题。
实际中还有一个更隐蔽的边界场景:当前题是最后一题时,点击“下一题”按钮没有任何反应,用户可能以为系统卡住了。我的处理是:在最后一题时把“下一题”按钮文案变成“交卷”,点击后直接弹出交卷确认 Modal,用户不需要先回到顶部找交卷按钮。
这种小细节用 Bootstrap 的类名切换就行:
<button class="btn btn-primary" @click="currentIndex === questions.length - 1 ? showConfirm() : goNext()" > {{ currentIndex === questions.length - 1 ? '交卷' : '下一题' }} </button>做完整套功能,我最大的体会是:在线测试系统的难点从来不在实现某个单一功能,而在把这些功能串成一个连贯流畅的答题体验。Bootstrap 加 Vue 的组合让这个过程变得可控,Bootstrap 把视觉和布局问题压缩到了最小,Vue 则让数据流转变得清晰。你如果也在做类似的东西,从数据模型开始想清楚,再逐层搭界面和逻辑,整个过程体感是顺的。最后再提醒一句:这套组合的代码量虽少,但性能上限不在框架,而在题库数据的组织和 localStorage 的使用习惯上,动手之前先把这两件事规划好,后面能省掉大量返工。