尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Vue 实现上一题下一题问答功能:数据驱动与边界处理实战

发布时间:2026/9/25 6:40:55

资讯中心
01
ARTICLE

Vue 实现上一题下一题问答功能:数据驱动与边界处理实战

Vue 实现上一题下一题问答功能:数据驱动与边界处理实战
1. 需求拆解与整体设计思路先说结论上一题下一题问答效果是 Vue 面试里出现频率极高的“小而美”需求也是实际业务里问卷、答题、反馈页面的核心骨架。很多朋友拿到这个需求第一反应是“不就是切换个索引吗”但真正写起来才发现回显、禁用、进度、状态同步每个点都能踩出坑来。我在实际项目里做过不下五版这种功能从最开始的v-if暴力切换到后来用数据驱动重写期间还遇到过选项状态丢失、快速点击双击、路由返回后进度不保留等一系列问题。这篇文章就把我沉淀下来的完整方案拆开讲透从数据设计到边界处理从基础切换到联动扩展配套完整可跑的代码你可以直接拿去用。适合的人群很明确刚学完 Vue 基础、想通过小项目练手的新手以及要在公司项目里快速实现答题/问卷模块、不想反复返工的前端同学。读完你不仅能写出这个功能还能理解“为什么这么写”遇到类似的分步表单、步骤条场景也能举一反三。1.1 这个需求到底在考什么从技术角度看这个看似简单的需求其实覆盖了 Vue 的核心知识点响应式数据驱动题目列表、当前索引、用户答案三者如何联动计算属性按钮禁用状态、进度百分比能否用computed优雅解决事件处理选项点击、按钮切换的参数传递与边界拦截组件通信如果把每一题抽成子组件父子之间答案怎么同步生命周期与持久化切路由再回来答题进度还在不在很多新手卡住不是因为 Vue 语法不熟而是没有养成“先设计数据再写界面”的习惯。点击按钮只是表象本质是“当前展示哪一题”这个状态在变化。把状态抽象出来剩下的一切都是状态的自然推演。1.2 数据驱动的核心状态与UI分离我见过最朴素的实现方式是定义三个变量question1、question2、question3然后用v-if挨个判断当前显示哪一个。这种写法在题目数量固定为 3 时还能凑合但一旦题目变成 30 道、50 道或者题目从接口动态获取代码就会瞬间膨胀到无法维护。正确的思路是用一个数组保存所有题目用一个数字记录当前索引用 UI 去“订阅”这两个数据。数组变了索引跟着走索引变了页面自动切换。这就是数据驱动的核心思想——状态是唯一的真相源UI 只是它的投影。const questions ref([...]) // 所有题目 const currentIndex ref(0) // 当前指针 const answers ref({}) // 用户答案集合只要把这三样东西定下来后续所有逻辑都有了抓手显示哪一题由currentIndex决定按钮禁不禁用由currentIndex和数组长度共同决定答案回显直接去answers里取。2. 核心细节解析与数据设计2.1 题目数据结构的定义数据结构是整件事的地基我建议从一开始就按“可扩展”的标准来设计。一个标准的单选题对象最少需要这些字段{ id: 1, // 唯一标识用id不用索引后面你就知道为什么 type: single, // 题目类型single单选 / multiple多选 title: Vue 中哪个选项不是生命周期钩子, options: [ { label: A, text: created }, { label: B, text: mounted }, { label: C, text: computed }, { label: D, text: destroyed } ] }这里我特别要强调id字段。很多人喜欢直接用数组下标当题号一旦遇到删题、插题、乱序、接口返回顺序变化下标就会错乱用户之前的答案也会对不上。用独立的id答案对象以id为 key 存储题目顺序随便变都不影响已答数据。选项我惯用{ label, text }结构label是 A/B/C/D 这种展示标识text是具体内容。这样渲染的时候很灵活后续如果需要正确选项、答案解析字段直接在对象上追加即可不会破坏已有结构。2.2 指针边界与按钮禁用的艺术“第一题不能点上一题最后一题不能点下一题”——这几乎是这个需求最容易被忽略的点。很多新手写完切换函数就收工了结果用户在第一题疯狂点击上一题currentIndex直接变成 -1页面渲染出undefined控制台一片红。我的建议是双保险按钮层面用disabled拦截函数层面用判断兜底。const isFirst computed(() currentIndex.value 0) const isLast computed(() currentIndex.value questions.value.length - 1) function prev() { if (isFirst.value) return currentIndex.value-- } function next() { if (isLast.value) return currentIndex.value }这两个计算属性本身就是响应式的currentIndex一变按钮的禁用状态立刻同步。计算属性在这里比方法更合适——它是基于状态推导出的新状态有缓存依赖不变就不会重复计算。另外一个细节单题模式和多题模式末尾按钮的文案和逻辑不一样。如果是纯上一题下一题最后一题的下一个按钮应该变成“提交”如果是问卷最后一题自动提交这里要单独判断。我在代码里用了一个isLastQuestion的布尔值来区分避免把“下一题”和“提交”的逻辑揉在一起后面容易改乱。2.3 答案回显与状态保持这是整个需求里**“看起来简单、实际最容易翻车”**的部分。用户的交互路径往往是第 1 题选了 B → 切到第 2 题 → 再切回第 1 题 → B 应该还是选中状态。如果答案只存在于选项按钮的局部状态里切走再切回来一切归零。正确的做法把答案提升到顶层数据用一个对象answers统一管理const answers ref({}) function selectAnswer(questionId, optionLabel) { answers.value[questionId] optionLabel }渲染时判断当前选项是否为选中状态完全依赖answers中的记录li v-foroption in currentQuestion.options :keyoption.label :class{ active: answers[currentQuestion.id] option.label } clickselectAnswer(currentQuestion.id, option.label) 这样做的核心好处是答案不依赖渲染状态而是数据状态。无论你怎么切换题目、怎么重新渲染只要answers里存了值回显就是天然成立的。这也解释了为什么很多新手用:class加isActive局部变量实现选中效果时一切换就失效——因为局部变量在组件复用或路由切换时被重置了。3. 实操过程一步步实现上一题下一题3.1 环境准备与基础骨架搭建先花 30 秒确认环境。如果你是从零开始建议使用 Vite 初始化一个 Vue 3 项目整个流程很快npm create vitelatest quiz-app -- --template vue cd quiz-app npm install npm run dev如果你之前的项目已经装好了 Vue直接跳过这步就行。组件内部我会用script setup组合式 API 来实现——代码更紧凑逻辑也更好复用。如果你项目里用的是 Vue 2 选项式原理完全一样把ref换成data把函数写进methods即可。业界关于组合式和选项式哪个好一直有争论。我的个人体会是简单页面两者差别不大但一旦逻辑变多组合式按“功能”组织代码的优势就会显现——答题逻辑、进度逻辑、持久化逻辑各自独立成块不会像选项式那样所有逻辑挤在methods里靠注释分区。3.2 完整代码实现核心切换与选项选中下面这份代码是我在项目中实际用过的精简版去掉了业务噪音保留核心逻辑可以直接复制到组件里跑。template div classquiz-container !-- 进度条 -- div classprogress-bar span classprogress-text{{ currentIndex 1 }} / {{ questions.length }}/span div classprogress-track div classprogress-fill :style{ width: progressPercent % } /div /div /div !-- 题目卡片 -- div classquestion-card :keycurrentQuestion.id h3 classquestion-title span classquestion-index{{ currentIndex 1 }}./span {{ currentQuestion.title }} /h3 ul classoption-list li v-foroption in currentQuestion.options :keyoption.label classoption-item :class{ active: answers[currentQuestion.id] option.label } clickselectAnswer(currentQuestion.id, option.label) span classoption-label{{ option.label }}/span span classoption-text{{ option.text }}/span /li /ul /div !-- 切换按钮 -- div classbtn-group button classbtn btn-prev :disabledisFirst clickprev上一题/button button classbtn btn-next :disabledisLast clicknext下一题/button /div /div /template script setup import { ref, computed } from vue const questions ref([ { id: 1, type: single, title: Vue 3 中以下哪个是组合式 API 的入口函数, options: [ { label: A, text: data() }, { label: B, text: setup() }, { label: C, text: created() }, { label: D, text: methods } ] }, { id: 2, type: single, title: computed 和 watch 的主要区别是什么, options: [ { label: A, text: 没有区别可以互换 }, { label: B, text: computed 有缓存watch 没有 }, { label: C, text: watch 只能监听异步数据 }, { label: D, text: computed 不能依赖其他 computed } ] }, { id: 3, type: single, title: Vue 组件中v-for 时为什么推荐绑定 key, options: [ { label: A, text: 为了通过 eslint 检查 }, { label: B, text: 帮助 Vue 更高效地复用和更新节点 }, { label: C, text: key 是给 CSS 用的 }, { label: D, text: 不绑定会直接报错 } ] } ]) const currentIndex ref(0) const answers ref({}) 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(() { return questions.value.length ? ((currentIndex.value 1) / questions.value.length) * 100 : 0 }) function prev() { if (isFirst.value) return currentIndex.value-- } function next() { if (isLast.value) return currentIndex.value } function selectAnswer(questionId, optionLabel) { answers.value[questionId] optionLabel } /script核心逻辑加起来也就几行。prev和next负责索引移动selectAnswer负责记录答案三个计算属性派生出页面所需的一切派生状态。不需要任何手动 DOM 操作这是 Vue 数据驱动最舒服的地方。3.3 关于 key 的一个必要说明我注意到你热搜词里有“vue 自定义指令”“vue 生命周期”等话题说明你可能有深入学习 Vue 的打算。这里必须提一个关键细节题目卡片上的:keycurrentQuestion.id不是可有可无的。如果你去掉这个 key当题目切换时Vue 会尝试复用同一个 DOM 节点只更新变化的部分。表面上也能正常显示但有一个隐患如果题目内部有transition动画或者选项里有v-if/v-else分支结构复用时可能会出现过渡不重置、状态残留的 bug。绑定 key 之后每次切换题目 Vue 都会销毁旧节点、创建新节点带来两个好处——过渡动画每次都能完整播放内部状态绝对干净。当然key 也不是越多越好。同一层级的兄弟元素才需要 key比如v-for渲染的选项列表每项的:keyoption.label就是必须的而题目卡片这种单节点key 的作用是强制重建属于一种主动手段。3.4 快速切换的防抖处理在真实项目里如果题目选项涉及图片加载、富文本渲染或者切换后需要发请求获取题目的附加信息快速连点“下一题”就可能出现请求竞态——上一次请求还没返回下一次切换已经触发页面显示的数据可能错位。我处理这类问题的思路很简单切换函数里加一个锁。let switching false function next() { if (switching) return if (isLast.value) return switching true currentIndex.value // 模拟异步加载下一题数据 setTimeout(() { switching false }, 200) }如果你的场景不需要异步这个锁就是多余的。加上它只会在每次点击后产生一瞬间的“无响应”所以请按需使用不要无脑套用。经验之谈防抖和节流不是常态明确知道瓶颈在哪再加否则反而让交互变钝。3.5 路由参数与页面联动你热搜词里有“vue路由参数”“vue动态路由”我顺势多说一句。如果这个问答页的路由是/quiz/:id不同 id 对应不同的题目组那么组件内部监听路由参数并重新加载题目数据的逻辑值得单独拎出来写import { useRoute } from vue-router import { watch } from vue const route useRoute() watch( () route.params.id, async (newId) { currentIndex.value 0 answers.value {} // 根据 newId 请求对应的题目列表 const res await fetchQuiz(newId) questions.value res.data }, { immediate: true } )核心点是切题时索引和答案必须重置否则会串题。这个坑我在接后台管理系统时踩过切换问卷后当前题还停留在上次的索引上用户直接看到了第 5 题一脸懵。4. 常见问题与排查技巧实录4.1 索引越界与数据为空现象控制台报错Cannot read properties of undefined (reading title)页面白屏或闪一下。原因currentIndex超出了数组范围或者questions数据还没从接口返回时组件就开始渲染。排查思路先看报错定位到哪一行——通常是currentQuestion.title里的currentQuestion是undefined。然后检查索引有没有被负数或超长值赋值再检查异步数据的加载时序。解决两个办法叠加使用。第一模板中用v-ifcurrentQuestion包裹渲染区域第二初始化时给currentIndex设默认值 0并且接口返回后做一次索引越界校验。经验来说“后端数据晚到”这个问题在真实项目中几乎是必然发生的过度乐观是新手最容易犯的错。4.2 选项状态刷新后丢失现象答题过程中刷新浏览器所有已选答案全部清空。原因answers是组件内存对象刷新即销毁。解决配合localStorage做持久化。我在投票类活动中用过一版用户在断网、误关页面后重新打开还能继续答体验提升非常明显。核心代码import { watch } from vue // 初始化时从 localStorage 读取 const saved localStorage.getItem(quiz_answers) if (saved) { answers.value JSON.parse(saved) } // 每次 answers 变化时自动写入 watch( answers, (val) { localStorage.setItem(quiz_answers, JSON.stringify(val)) }, { deep: true } )这里有两个注意点watch必须开deep: true因为answers是对象修改嵌套属性时浅层监听不会触发存储时用JSON.stringify序列化读取时用JSON.parse反序列化。如果只是localStorage.setItem(quiz_answers, answers.value)存进去的是[object Object]读出来就废了。4.3 多选逻辑接入我把这个需求做成单选时很多朋友问多选怎么接。直接说答案数据结构不变答案存储结构变一下即可。单选存字符串多选存数组function toggleAnswer(questionId, optionLabel) { const current answers.value[questionId] || [] if (current.includes(optionLabel)) { answers.value[questionId] current.filter((item) item ! optionLabel) } else { answers.value[questionId] [...current, optionLabel] } }选中判断也要从 optionLabel改成includes。渲染逻辑基本不用大改。4.4 组件化拆分时的通信方案当题目组件被单独抽离后父子组件的高频交互就成了关键。我这里的经验是尽量让子组件“受控”——父组件通过props传入题目数据和当前答案子组件只负责展示和派发事件不直接修改父数据。!-- QuizItem.vue -- script setup const props defineProps({ question: { type: Object, required: true }, answer: { type: [String, Array], required: false, default: } }) const emit defineEmits([select]) function handleSelect(optionLabel) { emit(select, props.question.id, optionLabel) } /script这样父组件只需维护answers和切换逻辑子组件完全无状态测试起来非常干净。如果你用了 Vuex 或 Pinia答案状态也可以放进 store 里组件之间直接共享免去一层层 emit 的繁琐。项目足够大时Pinia 方案会让代码更清爽。4.5 样式适配的细节热搜词里提到“vue样式”“vscode vue 怎么制作手机软件”说明你可能要考虑移动端展示。回答页的样式我一直推荐这几个要点选项热区要大手机用户靠手指点击选项最小高度建议不低于 44px整行可点比只点小圆点友好得多选中态要明显边框颜色、背景色、左侧色条三管齐下色弱用户也能看清底部按钮吸底长题面滚动时上一题/下一题按钮固定在底部减少操作成本不要全局引入第三方 UI 框架这种轻量问答页纯 CSS 就能实现没必要为了几个按钮拖一个组件库进来打包体积反而吃亏下面是我常用的一个选中态样式思路.option-item { border: 2px solid #e5e7eb; border-radius: 8px; padding: 12px 16px; cursor: pointer; transition: all 0.2s ease; } .option-item.active { border-color: #2563eb; background: #eff6ff; }核心就一点用边界和背景的双重变化强化选中反馈比单纯换文字颜色更能减少误选。5. 扩展思路从“能用”到“好用”5.1 答题卡总览与快速跳题当题目数量超过 10 道纯靠“上一题”“下一题”逐个翻效率太低。我建议加一个答题卡面板用一个小格子展示所有题号答过的格子显示高亮点击任意格子直接跳转到对应题目。实现方式非常简单——因为所有状态都已经在手上了div classanswer-sheet span v-for(q, index) in questions :keyq.id classsheet-item :class{ answered: answers[q.id], current: index currentIndex } clickcurrentIndex index {{ index 1 }} /span /div不需要新增任何额外状态currentIndex直接赋值即可完成跳转。这一招在 20 题以上的长问卷里体验提升极大用户在手机上也不用一遍遍点“下一题”去回看某道题。5.2 计时功能与自动交卷如果你要做的是一场限时测验计时逻辑可以这么设计const remainSeconds ref(3600) const timer setInterval(() { remainSeconds.value-- if (remainSeconds.value 0) { clearInterval(timer) submitQuiz() } }, 1000)两个关键点一是页面隐藏时计时器要不要暂停我建议根据考试场景自己权衡——正式考试需要暂停避免作弊练习场景不需要二是切换路由时记得clearInterval否则计时器会泄漏用户从答题页跳到别的页面再回来时间早就走完了。5.3 键盘快捷键操作PC 端使用时键盘操作能明显提升答题速度。我习惯在组件挂载后监听键盘事件组件卸载时移除监听import { onMounted, onUnmounted } from vue function handleKeydown(e) { if (e.key ArrowLeft) prev() if (e.key ArrowRight) next() if (/^[a-dA-D]$/.test(e.key)) { const option currentQuestion.value.options.find( (o) o.label.toLowerCase() e.key.toLowerCase() ) if (option) selectAnswer(currentQuestion.value.id, option.label) } } onMounted(() window.addEventListener(keydown, handleKeydown)) onUnmounted(() window.removeEventListener(keydown, handleKeydown))键盘事件的挂载和卸载成对出现这是很多新手容易遗漏的——只挂不卸在 SPA 里会造成事件重复绑定和内存泄漏经验之谈务必养成习惯。A/B/C/D 直接选答案这个功能在长期做内部培训系统时收获的反馈是最好的用户基本可以全程不碰鼠标。我做这个功能时最深的体会是一个需求的价值往往不在表面交互而在数据设计是否合理。只要questions、currentIndex、answers这三驾马车搭得稳后面加进度条、答题卡、跳题、持久化、多选、计时全都是往车上装货一点不用改底盘。反过来如果一上来就急着写按钮和样式数据设计得过且过改一版就要推翻一次。最后再分享一个小技巧在你写prev和next之前先把边界条件的测试用例想好。第一题点上一题、最后一题点下一题、空数据源渲染、快速连点、刷新恢复——这些场景在纸上过一遍代码里加好对应的判断上线之后基本不需要为交互逻辑返工。数据先行、边界兜底、由简入繁这是我在 Vue 问答功能上踩过坑之后总结出的实战流程希望能帮你少走一些弯路。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。