用Vue 3从零打造问答组件:上一题下一题、自动保存与答题卡
发布时间:2026/9/25 5:39:31来源:尧图网络
拿 Vue 做一个“上一题/下一题”问答效果听起来是个入门级需求但真正落地时你会发现自己把 Vue 的响应式、计算属性、组件通信、表单状态甚至键盘事件全碰了一遍。我是在做内部培训考试系统时第一次写这个组件的当时以为无非是 currentIndex 和 currentIndex--结果第一版上线就被同事吐槽“为什么切回来我选的答案没了”所以这篇把可用的问答组件从零到一拆开讲包括题目数据结构、切换逻辑、自动保存、答题卡、结果统计和常见坑适合刚学完 Vue 基础想动手做综合组件的同学也给要在老项目里快速塞答题模块的人做个参照。文章以 Vue 3 组合式 API 为主如果你还在维护 Vue 2 项目核心思路同样适用差别只在写法。1. 先拆需求上一题下一题背后藏了哪些知识点1.1 问答组件最常出现的场景问答组件的应用场景比你想象中广。最常见的自然是线上考试和培训认证答题、切题、交卷、判分一条龙其次是调查问卷用户逐题填写可能中途想改前面填过的内容还有内容平台的知识测试、面试前的自测题、错题本重练等等。无论哪个场景交互模型高度一致一组题目一次只展示一道用户可以在题目之间来回切换最终提交。区别只在于题目类型、计分规则和提交后的行为。所以拿到需求先别急着写按钮把两件事问清楚题目数据是静态写死还是后端接口返回答案是组件本地存还是需要实时同步给父页面这两个问题直接决定你后续代码的结构。很多问答组件写着写着乱掉不是切换逻辑难而是题目数据从哪来、用户答案往哪存从第一步就没想清楚。1.2 组合式 API 还是选项式 APIVue 3 出来以后新写的组件我基本都用组合式 API准确说是script setup写法。问答组件的逻辑无非围绕两个核心状态题目列表和当前题号。用组合式 API你可以把这块逻辑抽成一个useQuiz函数以后不同页面想复用一行引入。选项式 API 在 Vue 2 老项目里依然没问题data、methods、computed分层清楚写这种小组件也完全 hold 得住。但如果是新项目没必要逆着生态走。团队协作时也不要一个项目里一会选项式一会组合式混着写会让后来人非常痛苦。选择哪种写法更多看团队现状老项目保持统一新项目直接组合式。问答组件的核心是状态管理不是生命周期也不是指令所以不管选哪套 API只要数据模型清晰写出来都差不到哪去。1.3 题目的数据模型怎么设计最省心我见过不少同学一上来就把题目写成二维数组questions[0][1]这种结构看着短后续扩展全是坑。一个题目应该是一个对象字段明确自解释。推荐从下面这个结构起步const questions ref([ { id: 1, stem: Vue 3 中组合式 API 的入口函数是什么, options: [created, setup, mounted, render], answer: B, userAnswer: }, { id: 2, stem: 以下哪个不是 Vue 3 新增的特性, options: [Teleport, Suspense, Fragment, 过滤器 filter], answer: D, userAnswer: } ])id必须稳定唯一不要用数组索引代替。原因很简单一旦题目做了筛选、乱序或者从接口重新加载索引会变你可能会把答案记到另一道题上。options用数组保存选项的值不要直接用数字下标而用 A、B、C、D 这样的稳定编码。用户答案userAnswer初始为空字符串而不是null这样判断是否已答时直接!q.userAnswer或者q.userAnswer 都足够直观。对于多选题目userAnswer应该是一个数组这个差异我放在实战坑里专门讲。这样的结构还有一个好处提交时后端只需要questionId和userAnswer前端展示和接口传输天然分离。你甚至可以把这个对象直接 JSON 序列化存到本地用户刷新页面后还能恢复答题状态。2. 手写一个最简可用的问答组件2.1 搭一个能跑的基础单文件组件先别管花活把最基础的交互跑起来。下面的组件用 Vue 3script setup实现包含题目渲染、选项选择、上一题/下一题切换和进度条template div classquiz-wrapper div classquiz-progress div classquiz-progress-bar :style{ width: progressPercent % }/div /div p classquiz-position第 {{ currentIndex 1 }} / {{ questions.length }} 题/p h3 classquiz-stem{{ currentQuestion.stem }}/h3 ul classquiz-options li v-for(opt, i) in currentQuestion.options :keyi label input typeradio :namequestion_ currentQuestion.id :valueString.fromCharCode(65 i) v-modelcurrentQuestion.userAnswer / span{{ String.fromCharCode(65 i) }}. {{ opt }}/span /label /li /ul div classquiz-actions button classbtn-prev :disabledisFirst clickprev上一题/button button v-if!isLast classbtn-next clicknext下一题/button button v-else classbtn-submit clicksubmit提交/button /div /div /template script setup import { ref, computed } from vue const questions ref([ { id: 1, stem: Vue 3 中组合式 API 的入口函数是什么, options: [created, setup, mounted, render], answer: B, userAnswer: }, { id: 2, stem: 以下哪个不是 Vue 3 新增的特性, options: [Teleport, Suspense, Fragment, 过滤器 filter], answer: D, userAnswer: } ]) const currentIndex ref(0) const currentQuestion computed(() questions.value[currentIndex.value]) const isFirst computed(() currentIndex.value 0) const isLast computed(() currentIndex.value questions.value.length - 1) const progressPercent computed(() questions.value.length ? Math.round(((currentIndex.value 1) / questions.value.length) * 100) : 0 ) function prev() { if (currentIndex.value 0) currentIndex.value-- } function next() { if (currentIndex.value questions.value.length - 1) currentIndex.value } function submit() { // 提交逻辑在后续章节补全 } /script style scoped .quiz-progress { height: 8px; background: #eee; border-radius: 4px; overflow: hidden; margin-bottom: 16px; } .quiz-progress-bar { height: 100%; background: #4f8cff; transition: width 0.2s ease; } .quiz-options { list-style: none; padding: 0; } .quiz-options li { margin-bottom: 8px; } .quiz-actions { margin-top: 24px; display: flex; gap: 12px; } /stylecurrentQuestion必须用computed不要每次在模板里写questions[currentIndex]。computed 的核心价值是只有currentIndex或questions变化时才重新求值其他时候模板直接拿缓存结果性能好代码也干净。isFirst和isLast也是派生状态没必要在 data 里手动维护。这里有一个小细节radio 的name属性用了question_ currentQuestion.id。如果只有一个问卷实例固定写answer也能跑但页面上同时存在两个问答组件或者一题一页的路由缓存被复用radio 的 name 冲突会导致 A 组件的选项控制 B 组件的选中状态。用题目 id 动态生成 name是零成本的防御。2.2 上一题/下一题切换的核心逻辑很多初学者喜欢这样写function next() { currentIndex.value } function prev() { currentIndex.value-- }前几题跑着很正常到最后一题再点“下一题”页面上currentQuestion变成undefined模板开始报错。问题不在点击按钮那一刻而在你没给越界做防护。按钮加:disabled只是体验方法内判断才是安全底线。我在做键盘事件时也有体会用户不一定通过按钮触发动作键盘、滚动、组件内部主动调用都可能绕过按钮所以prev和next必须自己处理边界。function prev() { if (currentIndex.value 0) currentIndex.value-- } function next() { if (currentIndex.value questions.value.length - 1) currentIndex.value }这样写还有一个好处方法可以被安全地复用。比如后面要做键盘左右键翻题、答题卡跳题、自动播放下一题这些入口都调用同一个next边界逻辑不会到处都是。等到题目数据从接口异步加载时还需要额外判断questions.value.length 0否则isLast这个 computed 会变成true用户看到“提交”按钮但根本没有题这个问题我在后面排查部分细说。2.3 进度和已答题数用 computed 派生只显示“第 2 / 5 题”太干一般产品都会要一个进度条和已答题统计。进度条宽度是百分比计算方式是(当前索引 1) / 总题数 * 100。这里最容易踩的坑是忘记“当前索引从 0 开始”第一题的时候currentIndex是 0但进度不应该是 0%。const answeredCount computed(() questions.value.filter(q q.userAnswer ! ).length ) const progressPercent computed(() questions.value.length ? Math.round(((currentIndex.value 1) / questions.value.length) * 100) : 0 )模板里可以在题号边上加一句“已答 3 / 5 题”p classquiz-position 第 {{ currentIndex 1 }} / {{ questions.length }} 题已答 {{ answeredCount }} 题 /pansweredCount也是派生状态。注意这里的过滤条件是q.userAnswer ! 如果将来多选用数组就要改成q.userAnswer.length 0。所以数据模型统一派生逻辑就统一这是我一直强调先设计数据的原因。3. 让交互更像成熟产品自动保存、切换保护与键盘操作3.1 答案自动保存的关键别用临时变量基础版里radio 的v-model已经实现了“切走再切回来答案还在”。原因是userAnswer挂在questions数组的对象上Vue 响应式系统完整保存了状态切题只是改变了当前展示哪个对象并没有销毁数据。很多人会下意识再写一个currentAnswer ref()在切换时把它同步回数组这是多余操作而且容易漏。如果不想用v-model也可以手动控制input typeradio :checkedcurrentQuestion.userAnswer letter changecurrentQuestion.userAnswer letter /但不管是v-model还是手动绑定核心原则都一样答案直接写进questions数组里视图只是它的投影。只要questions是响应式数据切题、刷新、路由回来答案都在。真正需要watch的场景是“答案变化后要触发其他副作用”比如清除提示、实时保存草稿、更新答题卡样式而不是为了把临时变量写回数组。3.2 下一题按钮的通过条件与提示有些业务要求“必须答完当前题才能进下一题”比如模拟考试、闯关测练。这时候可以给组件加一个mustAnswer的 prop并在next里拦截const props defineProps({ mustAnswer: { type: Boolean, default: false } }) const tip ref() function next() { if (props.mustAnswer !currentQuestion.value.userAnswer) { tip.value 请先选择答案再进入下一题 return } tip.value if (currentIndex.value questions.value.length - 1) currentIndex.value }模板里按钮旁边显示提示span v-iftip classquiz-tip{{ tip }}/span如果你用的是 Element Plus 这类组件库可以把tip.value ...换成ElMessage.warning(...)但注意每次点击“下一题”都会弹一次提示容易烦到用户。页面内联提示更轻量也不会打断操作。另外选项一旦变化应该把提示清掉。最简单的方法是在watch里监听当前题目的userAnswerwatch( () currentQuestion.value?.userAnswer, () { tip.value } )这样做的好处是用户切换题目时上一次题目留下的提示也不会残留。3.3 键盘左右键翻题到底要不要做键盘翻题是很多“看起来很有用但做起来需要克制”的功能。如果要加核心是处理两个问题过滤可输入元素、及时移除监听。import { onMounted, onUnmounted } from vue function handleKeydown(e) { if (e.target.closest(input, textarea, select, [contenteditabletrue])) return if (e.key ArrowLeft) prev() if (e.key ArrowRight) next() } onMounted(() window.addEventListener(keydown, handleKeydown)) onUnmounted(() window.removeEventListener(keydown, handleKeydown))第一个问题用户在输入框里按左右方向键是为了移动光标这时候翻题会让用户瞬间跳走体验极差。所以要用e.target.closest过滤掉表单元素。如果题目里有富文本编辑器contenteditable也要过滤。另外radio 在部分浏览器里左右方向键会改变选中项如果你把左右键同时用来翻题会和系统行为冲突所以只在页面没有可聚焦控件时启用是相对安全的做法。第二个问题removeEventListener必须在onUnmounted里调用而且传入的函数引用要和添加时一致。否则组件销毁后监听器还在用户按一下左键控制台直接报错页面跳转还莫名奇妙。实测中很多“页面切走后翻题功能还在”的 bug都是这一句漏了。4. 结果统计、答题卡与重新练习4.1 提交后正确率统计最后一题显示“提交”按钮点击后要做判分。判分逻辑放在组件内部核心是一个isCorrect函数const submitted ref(false) function isCorrect(q) { if (Array.isArray(q.answer)) { return ( q.userAnswer.length q.answer.length q.answer.every(a q.userAnswer.includes(a)) ) } return q.userAnswer q.answer } const score computed(() questions.value.filter(isCorrect).length) const scorePercent computed(() questions.value.length ? Math.round((score.value / questions.value.length) * 100) : 0 ) function submit() { submitted.value true }模板里加一个结果区域div v-ifsubmitted classquiz-result 本次答对 {{ score }} / {{ questions.length }} 题正确率 {{ scorePercent }}% /div多选的判分注意答案比较和顺序无关。用户先选 A 再选 C答案数组是[A, C]还是[C, A]都应该判对。所以不能用数组的或转成字符串比较而是长度相等、且答案里的每一个元素都被用户选中。如果题目里存在“选错一个就全错”的规则这个函数也满足。提交后最好把选项禁用防止用户再改。给input加:disabledsubmitted是最直接的方案。如果你想让用户在提交后看到每题对错可以再给每道题加一个result字段但注意不要污染原始数据结构。4.2 答题卡快速跳题题目一多没有答题卡就很难定位哪题没做。答题卡本质是一个题号网格点击题号跳转到对应题目div classanswer-sheet button v-for(q, index) in questions :keyq.id :class{ answered: q.userAnswer ! , current: index currentIndex } clickgoTo(index) {{ index 1 }} /button /div跳转函数function goTo(index) { if (index 0 index questions.value.length) { currentIndex.value index } window.scrollTo({ top: 0, behavior: smooth }) }这里有个 UI 细节答题卡按钮的answered样式用底色区分已答和未答current用边框或高亮区分当前题。千万别只用颜色来标识色弱用户会看不出来配合“边框加粗”或者“圆点标记”更稳妥。答题卡的位置一般放在页面顶部或侧边题目多的时候滚动到顶部这一下很关键不然用户点了题号还要自己往上翻才能看到题目。4.3 重置练习状态的细节“再练一次”是练习类场景的标配需求。重置不仅仅是把currentIndex改回 0还得把用户答案全部清空、关闭提交态function reset() { questions.value.forEach(q { q.userAnswer }) currentIndex.value 0 submitted.value false }如果题目支持多选userAnswer是数组重置时要写成q.userAnswer []而不是q.userAnswer.splice(0)。直接换引用更简单也避免残留监听或脏数据。如果只想重练错题可以先过滤const wrongQuestions questions.value.filter(q !isCorrect(q)) questions.value wrongQuestions.map(q ({ ...q, userAnswer: }))但这里要注意过滤后题号会被重新编号答题卡上的序号和原卷不一致。产品上如果要求“重练错题时保持原题号不变”那数据结构就要保留一个order字段不能用数组索引当题号。5. 组件化与工程化从单页面到真实项目5.1 scoped 样式和多题型适配Vue 单文件组件里写style scoped是默认操作它能防止组件样式泄漏到全局。原理是编译后给组件内元素加上带 hash 的 data 属性样式选择器也会被改写成带这个属性的形式。别小看这一步没有 scoped 的.quiz-options很可能会把页面上其他模块的列表样式带跑。如果用了第三方 UI 库比如 Element Plus 的el-radio他们的组件内部元素不会自动加上你的 data 属性外部样式进不去。这时候要用深度选择器:deep(.el-radio__label) { font-weight: 600; }多题型适配是问答组件的自然扩展方向。只需在题目数据里加一个type字段模板用v-if、v-else-if分流template v-ifcurrentQuestion.type single input typeradio :valueletter v-modelcurrentQuestion.userAnswer / /template template v-else-ifcurrentQuestion.type multiple input typecheckbox :valueletter v-modelcurrentQuestion.userAnswer / /template template v-else-ifcurrentQuestion.type judge labelinput typeradio valueT v-modelcurrentQuestion.userAnswer / 正确/label labelinput typeradio valueF v-modelcurrentQuestion.userAnswer / 错误/label /template多选时userAnswer必须是数组v-model绑定 checkbox 后Vue 会自动把选中值 push 进数组选中的状态也能正确回显不需要你手动做增删。这也是题目数据模型一开始就区分题型的原因。5.2 组件与父页面的数据上报基础版把题目写死在组件里真实项目大概率是要由父页面传题目的。这里我推荐把题目组数据通过 prop 传入答题状态在组件内部维护提交时用事件把结果抛给父组件。这么设计的好处是组件不关心题目从哪来也不关心提交后要跳哪个页面保持单一职责script setup const props defineProps({ bank: { type: Array, required: true }, mustAnswer: { type: Boolean, default: false } }) const emit defineEmits([finish]) const questions ref( props.bank.map(q ({ ...q, userAnswer: Array.isArray(q.userAnswer) ? [...q.userAnswer] : (q.userAnswer ?? ) })) ) function submit() { submitted.value true emit(finish, { score: score.value, total: questions.value.length, answers: questions.value.map(q ({ questionId: q.id, answer: q.userAnswer })) }) } /script这里有一个很隐蔽的坑{ ...q, userAnswer: ... }是浅拷贝。如果q.userAnswer是数组直接写q.userAnswer ?? []会让新对象和父组件原对象共享同一个数组子组件里push就会改动父级数据。所以拷贝时对数组要单独[...q.userAnswer]。父页面使用Quiz :bankquizData finishhandleFinish /大多数业务只在提交时需要结果所以没必要用v-model把答案实时同步给父组件。如果要实时保存草稿可以在watch(questions, ...)里加一层 debounce再emit(save, ...)让父组件自己决定什么时候存。把组件内部的响应式数据用defineExpose暴露出去也可以但我更推荐事件这种单向数据流调试时不容易糊涂。5.3 要不要用路由实现一题一页团队里经常有人问“上一题下一题能不能用路由参数实现比如/quiz/1、/quiz/2”可以但要分场景。如果你的产品要求每道题可以被收藏、分享刷新后停留在当前题那就很适合把当前题目放到 URL 里const route useRoute() const router useRouter() const questionId computed(() Number(route.params.id)) const currentQuestion computed(() questions.value.find(q q.id questionId.value) ) function goNext() { const idx questions.value.findIndex(q q.id questionId.value) if (idx questions.value.length - 1) { router.push(/quiz/${questions.value[idx 1].id}) } }这种方案的核心是把“当前题”提升到 URL 层刷新不丢进度还可以直接给同事发一个“第 3 题”的链接。缺点是路由栈会被上一题下一题填满用户按浏览器返回键会很难受。如果只是考试、填问卷这种内部流程没必要用路由组件内currentIndex就够了。我的处理习惯是题目总量不大、不要求永久链接优先组件内切换要做学习路径、课程章节导航才考虑路由方案。6. 实战中踩过的坑与排查思路6.1 答案是字符串还是数组坑在哪里单选用字符串多选用数组这个原则看起来很简单但实际项目里我见过太多多选题目偷懒存字符串的例子userAnswer: A,C。等到提交判分时你要拆字符串去和答案比较还要考虑用户先选 C 还是先选 A排序问题全冒出来。更麻烦的是如果你用 checkbox 的v-model绑定一个字符串Vue 不会帮你把多个选中值拼成逗号字符串你得自己监听 change 事件手动组装代码量蹭蹭往上涨。正确做法是从数据层就区分类型。单选userAnswer是字符串多选是数组判断题也是字符串。判分函数统一处理function isCorrect(q) { if (Array.isArray(q.answer)) { return ( Array.isArray(q.userAnswer) q.userAnswer.length q.answer.length q.answer.every(a q.userAnswer.includes(a)) ) } return q.userAnswer q.answer }如果后端接口返回的答案是A,C这种字符串前端在接数据时就转成数组别等判分时再处理。数据入口做标准化后面所有逻辑都清爽。6.2 响应式数据丢失的三种常见情况问答组件里九成的诡异 bug 都和响应式丢失有关。第一种是从接口拿数据后把数组赋给了一个普通变量let questions [] const res await fetch(/api/questions).then(r r.json()) questions res.data // 页面不会更新应该从一开始就用ref([])然后questions.value res.data。Vue 3 的响应式基于 Proxyref包裹数组后内部对象也会变成响应式代理用户答案的修改才能触发视图更新。第二种是v-for的 key 用了 index。选项固定不增删时其实问题不大但一旦选项顺序由接口随机返回或者题目类型切换导致选项列表重建index 作 key 会出现选中状态错位。建议题目用q.id做 key选项如果有稳定的optionCode就用optionCode做 key没有就给选项加一个。第三种是给对象动态添加新属性。Vue 2 里userAnswer如果在初次渲染时才手动this.$set(q, userAnswer, )才能响应Vue 3 Proxy 虽然能拦截新增属性但最好的习惯仍然是在数据创建时就带上userAnswer字段。接口返回的题目没有这个字段就在前端做一层映射补上不要裸用后端字段。6.3 Vue Devtools 调试与问题速查装 Vue Devtools 建议从浏览器官方扩展商店下载不要从来路不明的网站拿安装包。组件面板选中 Quiz 组件后可以直接看到questions、currentIndex、score这些状态改一下currentIndex的值页面立刻切题这能帮你快速判断是数据问题还是渲染问题。如果页面没反应问题基本出在方法或事件绑定上。另外一个调试技巧从接口拿题时先打开 Network 面板看返回数据再打开 Devtools 的 Components 面板看questions字段有没有被正确赋值。很多“题目列表空白”的问题不是渲染写错而是接口字段名和你代码里用的不一致比如后端给的是title不是stem。下面是我在实际项目中遇到的高频问题速查表问题现象大概率原因排查/解决方案切题后答案丢失userAnswer存在临时变量里没写回响应式数组检查数据模型答案统一挂在questions的元素上下一题可点击但页面没变化修改了非响应式变量或直接改了 props用ref/reactive必要时深拷贝数据键盘左右键在输入框里翻题监听 keydown 时没排除 input/textareahandler 里用e.target.closest过滤多选判分总不对数组直接用比较用长度 includes逐项比较组件样式污染其他页面没加 scoped加style scoped需要深入子组件用:deep()路由跳转后键盘翻题仍生效onUnmounted没移除监听add/removeEventListener 成对出现一题一页模式下find不到题目route.params.id是字符串和数字不相等用Number(route.params.id)转成数字这个表不是让你背的而是当你被某个问题卡住时能按现象快速定位。我最后想说一个容易被忽略的点问答组件虽然小但它对“数据驱动视图”的要求非常高。只要把题目数据模型和响应式数据的边界理清楚剩下的功能基本都是在这个模型上做派生。如果你正在做的项目刚好需要问卷、考试、自测这类模块这个组件完全可以当底座继续扩展往里加计时器、图片题、音频题、随机抽题都不需要推翻重写。我在实际项目里做这个问答组件踩过最深的一脚就是一开始把“当前选的答案”放在currentAnswer临时变量里结果切题回来要么丢答案要么要手动同步最后改成让userAnswer跟着questions走整个组件才真正稳下来。数据模型定清楚了上一题下一题、答题卡、判分、重置全都是在同一个数组上做派生代码量少逻辑也顺。建议你先按上面的基础版跑通再根据业务加校验和多选。最后再分享一个小技巧给每个题目一个稳定 id所有跳转和统计都拿 id 说话别拿索引当 id 用。
网站建设高端定制企业官网