用Vue实现上一题下一题问答:从数据驱动到组件化实践
发布时间:2026/9/25 6:17:17来源:尧图网络
前两天临时要做一个在线答题的小页面需求不复杂题库里每一道题单独展示底部放上一题、下一题两个按钮用户选过的答案切走再切回来得自动回显最后交卷的时候统计得分。听起来是非常基础的功能对吧我刚接到这个需求的时候也觉得半小时就能搞定结果真写起来Vue 的响应式更新给我上了一课数据改了视图没动。后来我把这个案例从最简版本开始重写一路补上进度条、判分逻辑、组件封装才发现这个不起眼的“上一题下一题问答效果”几乎把所有 Vue 入门阶段最重要的知识点都串了起来数组渲染、下标切换、双向绑定、计算属性、生命周期、组件通信、路由跳转。如果你刚开始学 Vue想找一个小而完整的练手项目或者正在准备前端面试想用一段真实代码讲清楚 Vue 的数据驱动原理这篇文章应该能给你一个可以直接抄、还能讲明白的参考。1. 问答切换背后本质上就是 Vue 的数据驱动基本功1.1 先把需求拆成三个不可少的部分很多同学看到“上一题下一题”第一反应是这不就是个下标加减吗对但只答对了一半。完整拆开来看这个需求其实包含三层数据层要有一个题目集合questions还要有一个当前序号currentIndex。题目集合决定了有哪些内容可以切序号决定了当前显示哪一道。视图层页面要根据currentIndex渲染出对应的题干和选项。选项通常是多选的 checkbox 或单选的 radio用户点了之后要在当前题目上留下选择状态。交互层上一题、下一题两个按钮点击后改变currentIndex同时要处理好边界——第一题时“上一题”应该禁用/不生效最后一题时“下一题”同理。为什么要强调这三层因为很多纯新手写出来的代码把所有逻辑揉在一个click里面看起来能跑但换个场景立刻崩。用 Vue 的思路想问题得分清什么是状态、什么是视图、什么是行为。状态是questions和currentIndex视图是模板里根据状态渲染出来的 DOM行为是 methods 里的goPrev和goNext。你改的永远是状态视图是跟着状态自动变的这就是“数据驱动视图”的意思。1.2 为什么这个案例值得认真写一遍我面试别人的时候挺喜欢让候选人聊聊“做过的小功能”十个人里有八个会说轮播图、Tab 切换但很少有人主动说“自己做过一个完整的答题组件”。其实这两个东西本质一模一样都是“当前项 切换动作”但答题组件会额外涉及用户选择、状态回显、判分逻辑复杂度刚好比 Tab 切换高一个台阶又不至于像完整后台项目那样学起来吃力。从知识点覆盖来说这个案例能练到v-for渲染列表和:key的使用数组下标访问与修改v-model在 radio 上的双向绑定计算属性computed的派生状态响应式原理里的数组更新检测组件化封装时的 props 和 emit路由参数在页面状态恢复中的应用这一串列出来你会发现它比很多“跑通一个 TodoMVC”更贴近真实业务。1.3 环境问题怎么解决这里多说一句环境准备。如果你只是想快速验证代码逻辑最简单的方式是用 Vue 官方 CDN 直接写一个.html文件不用装任何脚手架。如果你打算认认真真作为一个练手项目维护建议直接基于 Vite 创建项目命令也很简单npm create vuelatest创建完按提示一路回车选不选 TypeScript、要不要路由看你自己。本篇文章的核心代码不依赖任何第三方库也不用安装额外依赖无论是 Vue 2 还是 Vue 3 的项目都能直接迁过去。我下面先以 Vue 2 的选项式写法为主线讲解因为这种写法对新手最直观Vue 3 的差异我会在最后单独做对照。2. 最简可用版本一个数组、一个下标、两个按钮2.1 数据结构先行写这个功能之前我建议你先别急着打开编辑器先把题目数据结构定义清楚。我实际用的是这样一个结构questions: [ { stem: Vue 2.x 中实现响应式主要依赖哪个 API, options: [Object.defineProperty, Proxy, Reflect], answer: Object.defineProperty, userAnswer: }, { stem: Vue 3 的响应式核心是用什么实现的, options: [Proxy, Object.defineProperty, Map], answer: Proxy, userAnswer: }, { stem: 以下哪个不是 Vue 的生命周期钩子, options: [created, mounted, rendered, beforeDestroy], answer: rendered, userAnswer: } ]题目用数组是因为数组天然适合“第几道”这种场景下标访问就是 O(1) 的时间复杂度不管是 3 道还是 300 道都没压力。stem是题干options是选项列表answer是正确答案userAnswer用来记录用户当前选择的答案初始值给空字符串。很多初学者会纠结“为什么不用 id”——用 id 当然也可以但在这个场景里currentIndex本身就是这道题在列表里的下标我们切换上一题下一题移动的也是下标直接用数组下标反而更直观。等以后要做收藏、跳转指定题目、从后端拉取随机题目的时候再考虑引入 id 也不迟。2.2 第一版完整代码下面是第一版完整代码可以直接跑template div classquiz div classcard h3{{ currentIndex 1 }} / {{ questions.length }}/h3 p classstem{{ currentQuestion.stem }}/p label v-for(item, idx) in currentQuestion.options :keyitem classoption input typeradio :nameq_ currentIndex :valueitem v-modelcurrentQuestion.userAnswer / span{{ item }}/span /label div classactions button :disabledcurrentIndex 0 clickgoPrev上一题/button button :disabledcurrentIndex questions.length - 1 clickgoNext 下一题 /button /div /div /div /templateexport default { data() { return { currentIndex: 0, questions: [ // 题目数组结构见上一节 ] }; }, computed: { currentQuestion() { return this.questions[this.currentIndex]; } }, methods: { goPrev() { if (this.currentIndex 0) { this.currentIndex--; } }, goNext() { if (this.currentIndex this.questions.length - 1) { this.currentIndex; } } } };这段代码的核心逻辑只有三行currentIndex存当前序号currentQuestion计算属性根据序号返回当前题目两个方法控制序号加减。模板里所有展示内容都来自currentQuestion所以只要currentIndex一变整道题的题干和选项都会跟着刷新。2.3 为什么是“只渲染当前一道题”而不是全部渲染再切换有一个很容易被新手采用的方案把三道题全部用v-for渲染出来然后用v-show或者:class控制显示隐藏。看起来好像也没问题真做起来会有一堆小毛病。最直接的问题是 radio 分组。如果所有题目都在同一个 DOM 树里渲染name属性一旦相同它们就会变成一个互斥的 radio 组——你在第一题选了 A切到第二题选 B回到第一题发现 A 被自动取消了。这个 bug 排查起来非常隐蔽因为“点上一题下一题”本身是正常的但选项状态莫名其妙丢失。另一个问题是性能。如果题目总数很多比如 300 道全部渲染意味着页面上有 300 个题干、上千个选项节点而用户同时只能看到一道题这些不可见的 DOM 白白占着内存。虽然 Vue 的虚拟 DOM 能处理但完全没有必要。所以更合理的方案是模板里只写一个题目的渲染结构通过currentQuestion来替换当前展示的题目内容。每次切换时Vue 会复用相同结构的 DOM 节点并更新文本内容天然省掉了“一堆隐藏 DOM”的问题。2.4 几个容易被忽略的关键点name为什么要绑定currentIndex因为 radio 的互斥规则是“同一name下只能选一个”。如果我们写成namequestion第一题和第二题就会在同一个 radio 组里互斥。加上currentIndex之后每道题的 radio 组都是独立的这是这个案例里非常容易被忽略但非常重要的细节。:keyitem用选项文本做 key是为了让 Vue 在更新列表时能准确识别哪一项变了。这里用文本做 key 是安全的因为选项文本在题目内基本不重复如果你将来要支持选项编辑、动态增删建议换成有唯一性的 id否则使用索引当 key 会导致状态错乱。disabled边界第一题的“上一题”按钮要禁用最后一题的“下一题”按钮要禁用。同时方法内部也做了判断双保险防止按钮样式被浏览器强制点击或未来出现逻辑漏洞时越界。3. 从“按钮能切”到“答题能用”回显、进度与判分闭环第一版代码跑通之后你会发现“切题”这件事已经解决了但离“能用”还差得远。用户在这个页面上要完成一整份问卷至少还要有答案回显、进度反馈、交卷判分。这一节就是把这几块补上。3.1 答案回显为什么 v-model 已经帮你搞定了先说你最担心的“切走再切回来用户答案还在不在”的问题。第一版的代码里radio 的v-modelcurrentQuestion.userAnswer已经天然处理了回显。原理不复杂v-model在 radio 上的行为分两步第一步是把绑定的数据写入到 radio 的checked属性决定哪个选项被选中第二步是用户点击 radio 时把选中项的值写回数据。所以只要数据里userAnswer一直在切到别的题再切回来radio 会照常读数据并恢复选中状态。这里有一个实战中的小坑很多人会画蛇添足地给 radio 加一个:checkeditem currentQuestion.userAnswer同时又用v-model。你会发现在 Vue 里:checked会被v-model覆盖两个东西一起写反而容易出问题。记住一句口诀单选项状态归v-modelchecked交给 Vue 自己计算不要手动干预。3.2 进度条让用户心里有数没有进度反馈的答题页面用户做到一半会非常焦虑这问卷到底多少题我还有多少没做所以即便需求文档里没写进度条我也建议加一个。进度条本质上是一个派生状态已答题目数除以总题目数。它不需要单独存在 data 里用 computed 计算就好computed: { answeredCount() { return this.questions.filter((q) q.userAnswer ! ).length; }, progressPercent() { if (this.questions.length 0) return 0; return Math.round((this.answeredCount / this.questions.length) * 100); } }模板里可以这样用div classprogress-bar div classprogress-inner :style{ width: progressPercent % }/div /div p已答 {{ answeredCount }} / {{ questions.length }} 题完成 {{ progressPercent }}%/p进度条建议用百分比去控制内部宽度而不是写死像素值。这样不管题目总数是 5 还是 50宽度自适应都成立。我们还可以在题目总数大于 1 时把progressPercent做成动画顺手给.progress-inner加一个transition: width .2s效果会自然很多。3.3 交卷判分逻辑交卷按钮做在最后一题下方其实体验不太好更符合直觉的做法是进度条下方始终放一个“交卷”按钮点击时判断是否已答完然后统一判分。判分就是一个循环比较methods: { submit() { const unanswered this.questions.filter((q) q.userAnswer ); if (unanswered.length 0) { alert(还有 ${unanswered.length} 道题未作答请完成后再交卷); return; } const total this.questions.length; const correct this.questions.filter( (q) q.userAnswer q.answer ).length; this.result { total, correct }; } }这里我刻意用了两次filter而不是一次循环里同时统计“未答数”和“正确数”是为了代码更直白。如果你偏爱函数式写法也可以一次性reduceconst stats this.questions.reduce( (acc, q) { if (q.userAnswer ) acc.unanswered; if (q.userAnswer q.answer) acc.correct; return acc; }, { unanswered: 0, correct: 0 } );两种都可以。实际项目里我反而建议先写直白版本把逻辑讲清楚再考虑“更高级”的写法。代码首先是写给人看的其次才是给机器跑的。result要记得在data()里初始化成null这样模板里可以用v-ifresult控制得分区域是否显示。判完之后我们还可以把答案与正确性的对照表展示出来方便用户复盘错题。3.4 答完之后的复盘与错题跳转既然已经记录了每题的用户答案和正确答案不做复盘就太可惜了。交卷后我会在页面下方渲染一个结果列表每道题的状态用绿色/红色标识并且支持点击“跳到该题”。跳转逻辑很简单就是this.currentIndex indexdiv v-ifresult h4得分{{ result.correct }} / {{ result.total }}/h4 div v-for(q, index) in questions :keyq.stem classresult-item :classq.userAnswer q.answer ? right : wrong clickcurrentIndex index 第 {{ index 1 }} 题{{ q.userAnswer q.answer ? 正确 : 错误 }} /div /div到这里这个问答效果已经从一个“能切题的 Demo”变成了一个“能答完一整份问卷并给反馈的小应用”。代码量增加不多但体验跨度非常大。4. 实际开发中最容易踩的四个 Vue 响应式坑这一节我要单独写。因为上面所有代码看起来都很简单但我在实际开发里包括帮同事 review 代码时见过太多人倒在响应式更新的细节上。这几个坑你今天不踩迟早会在别的项目里踩。4.1 直接改数组下标视图不更新最经典的坑没有之一。很多同学写答题功能时会这样写this.questions[this.currentIndex].userAnswer A;然后发现radio 的选中状态没变页面纹丝不动console 里数据却已经改了。原因要去 Vue 2 的响应式原理里找。Vue 2 用Object.defineProperty拦截对象属性的读写但数组是个例外直接通过下标赋值arr[0] AVue 2 无法感知这个变化。Vue 官方文档里明确写了只有七种数组方法push、pop、shift、unshift、splice、sort、reverse以及Vue.set/vm.$set才会触发视图更新。所以正确答案是this.$set(this.questions[this.currentIndex], userAnswer, A);或者用splice整体替换当前项const target this.questions[this.currentIndex]; this.questions.splice(this.currentIndex, 1, { ...target, userAnswer: A });为什么我上面的第一版代码用v-model没踩这个坑因为v-model内部帮我们调用了响应式的赋值方法而不是裸写数组下标赋值。这也是我建议这个场景优先用v-model的真实原因——它不只是方便还顺手避开了响应式的大坑。Vue 3 使用者先别笑Vue 3 的reactive基于 Proxy确实能直接监听数组下标赋值。但如果你用的是ref包裹数组记得要操作arr.value[0] A很多人会把.value丢掉然后一脸茫然地来找 bug。4.2 radio 的 name 冲突与 v-model 的“隐性覆盖”刚才 2.4 里提过:name要绑定currentIndex但实际项目里还有一个变体问题如果你把所有题目的选项都统一渲染在一个循环里name 就更容易重复。比如后台管理系统常见的“一页表格里有多行每行带一组单选项”如果你只写namerowRadio那整页只能选一个。这种问题排查起来非常折磨人因为代码语法完全正确v-model也绑了不同的数据但浏览器层面 radio 分组的规则是按name走的。真遇到了第一反应一般是去查渲染出来的 DOM而不是去怀疑name。所以写模板的时候习惯性把当前题目索引或当前行 id 拼进name比如:nameq_ currentIndex能帮你省掉大量无意义的 debug 时间。还有一个和它对称的坑当你需要“重置所有选项”时直接清空一个变量是不够的。因为 radio 的选中态是 data 驱动的你得把每道题的userAnswer都重置成DOM 才会跟着重置。只重置currentIndex是没有用的。4.3 复制题目数组时小心浅拷贝污染了原始数据前面代码里我们的题目是写死在data()里的。真实业务中题目几乎都是从接口拿到的而接口返回的数据往往没有userAnswer字段需要我们初始化一个默认值。我见过很多这样的写法this.questions res.data; this.questions.forEach((q) { q.userAnswer ; });这在功能上能跑但存在两个隐患。第一res.data是 axios 返回的原始响应对象的一部分直接在上面挂userAnswer字段等于在修改后端资源对象。万一后续还有别的逻辑需要依赖这个原始对象很容易被你的“污染”影响。第二如果接口数据被缓存或复用到别的地方整个对象是引用共享的你在这个组件里加的字段别的组件也能“看到”。稳妥的做法是创建一份新的数组而不是直接在原数据上改造this.questions res.data.map((item) ({ ...item, userAnswer: }));map返回的是一个新数组...item把每个题目的原始字段复制出来再加上userAnswer初始化字段。这样原始数据完全不动你在这个组件里怎么折腾都安全。4.4 空数据与极小数据量的边界题目总量可能是 0也可能是 1。这两种情况都会让“上一题/下一题”按钮处于比较尴尬的状态。题目总量为 0 时questions.length - 1是 -1currentIndex questions.length - 1的 disabled 判断变成0 -1结果是 false按钮看起来“可点”但点完currentQuestion是 undefined模板里再访问currentQuestion.stem直接报错。题目总量为 1 时首页就是末页两个按钮都应该禁用。所以我在正式版本里会给按钮加一个统一判断题目数小于等于 1 时直接把整个按钮区隐藏或禁用div classactions v-ifquestions.length 1 button :disabledcurrentIndex 0 clickgoPrev上一题/button button :disabledcurrentIndex questions.length - 1 clickgoNext 下一题 /button /div另外从路由参数或其他外部状态恢复currentIndex时一定记得做范围约束别让用户刷新后看到一个“不存在的第 99 题”。具体的约束写法我放在下一节里面一起讲。5. 这个“小问答”能延展出的真正价值组件化、路由传参与面试基础功能做完坑也踩过了如果你只想“交差”到上一节其实已经可以收工。但如果这个案例是你的练手项目或者你想拿它去面试那我建议再往前走一步。5.1 把问答卡片封装成组件前面所有代码都放在一个页面组件里问题在于题目数据一旦变多这个组件会越来越臃肿而且“答题卡片”本身是一个高复用单元——它可以出现在试卷页、每日一练、错题回顾、后台预览甚至小程序里。不做成组件同一份代码就得复制粘贴四遍。抽组件时我习惯这样设计props接收一个questions数组组件内部只管展示和交互。组件内部保留currentIndex、userAnswer等私有状态不把答题状态提升到父级。组件通过$emit(finish, result)把交卷结果抛给父组件父组件负责后续提交、展示得分弹窗等业务逻辑。组件内部代码的骨架不变只是把questions从 data 换成了 props把答题数据拷贝成一个内部副本。父组件使用时就变成QuizCard :questionslist finishhandleFinish /这样一个基础组件未来接接口、做题型扩展多选、填空、判断都只需要在内部加逻辑不会影响外面的试卷页面。面试时你能主动说出“我把题目和交互封装成了一个独立组件父组件只关心数据源和最终结果”这句话的含金量比“我会用 v-for”高太多。5.2 用路由参数实现“刷新后仍停留在当前题”用户答了 50 题突然手滑刷新了页面如果currentIndex重置成 0那是灾难级体验。解决办法很简单把当前题号同步到 URL 的 query 参数里页面加载时优先从 URL 恢复题号。在 Vue Router 中点击“下一题”时同步更新 URLgoNext() { if (this.currentIndex this.questions.length - 1) return; this.currentIndex; this.$router.replace({ query: { ...this.$route.query, index: this.currentIndex } }); }页面初始化时恢复created() { const raw Number(this.$route.query.index); const index Number.isNaN(raw) ? 0 : raw; this.currentIndex Math.min(Math.max(index, 0), this.questions.length - 1); }这里有两个小细节值得说。第一用replace而不是push因为向前/向后翻题不应该产生大量历史记录否则用户按返回键会陷入“下一题、下一题、下一题”的循环地狱。第二恢复时要用Math.min(Math.max(index, 0), length - 1)做双边界校准防止 URL 被人为改成?index999之后页面白屏。这个“状态同步到 URL”的思路不限于题号还可以扩展到问卷 id、当前 Tab 页、筛选条件只要是“刷新后需要保持”的状态都可以用同样套路。应对面试题“前端如何做状态持久化”时这也是一条非常实用的答案。5.3 面试时怎么把这个案例讲出深度如果你打算在面试里聊这个项目我建议不要只停留在“我用 v-for 渲染了列表用 v-model 绑定了 radio”这种操作层面。一个两三分钟就能说完的小功能换一种讲法会立刻显得有层次。第一步描述需求时突出场景感。比如“这是一个在线刷题模块题目从接口拉取用户逐题作答要求已答答案可回显交卷后能查看错题与分数。”对方一听就知道你面对的不只是“做个 Demo”而是一个完整业务闭环。第二步讲数据设计。说明为什么userAnswer放在题目对象里而不是单独一个对象里为什么需要给每道题一个userAnswer初始值。这一步体现的是“状态建模”能力。第三步讲响应式的坑。主动抛出“Vue 2 里直接改数组下标不触发视图更新我用了$set处理换成 Vue 3 之后基于 Proxy 可以直接赋值”这种话。对方立刻能判断出你确实踩过坑而不是背了一堆面试题。第四步讲扩展性。模板提到“切题时把当前 index 同步到路由参数刷新后可以恢复答题卡片抽成了组件父组件统一处理结果”。这种“可维护性”意识是面试官非常看重的加分项。5.4 Vue 3 组合式写法对照如果你用的是 Vue 3上面所有逻辑换成script setup写法也非常直接核心代码就变成了script setup import { ref, computed } from vue; const currentIndex ref(0); const questions ref([]); // 从接口加载题目后记得初始化 userAnswer // questions.value res.data.map(item ({ ...item, userAnswer: })) const currentQuestion computed(() { return questions.value[currentIndex.value]; }); const goPrev () { if (currentIndex.value 0) currentIndex.value--; }; const goNext () { if (currentIndex.value questions.value.length - 1) { currentIndex.value; } }; /script注意两点一是在组合式写法里所有数据都要通过.value访问这是新手最常见的报错来源二是questions如果是ref包裹的数组直接questions.value[0].userAnswer A是可以触发更新的因为数组的响应式代理由 Vue 3 的 reactive 在底层处理好了不再需要$set这种方法。如果你正在用 Vue 3 开发直接把前面 Vue 2 代码里的this.xxx全部换成xxx.value就能完成迁移。5.5 我的一点个人习惯写这种小功能的时候我习惯先把数据结构定义清楚再写模板最后补样式。很多人喜欢边写边想数据结构结果做到一半发现userAnswer忘了初始化然后回显、判分、统计全部出问题。一个简单的经验是凡是“用户会在页面里产生、并且离开页面后还想恢复”的数据一定在你的初始 data 里有一个明确的字段和默认值。你把userAnswer: 写死在题目对象里后面所有判分逻辑、进度统计、回显都可以直接依赖它不需要到处做if (q.userAnswer ! undefined)这种防御判断。这个习惯放在任何框架里都成立。如果你做完这个案例想再进一步可以试试扩展成多选题、判断题、填空题的混合题型每种题型渲染不同控件判分逻辑也要分开走。到那一步你会发现组件内部需要拆分出更细的子组件而状态管理也会慢慢从“组件内 data”走向“可组合的 store”。这就是一个前端项目从 0 到 1 的真实演进路径。以上这些就是我基于一次“简单”需求踩出来的完整心得希望对你也有用。
网站建设高端定制企业官网