资讯动态

Vue2组件实战:从零构建培训项目填空题模块

发布时间:2026/10/14 23:37:26 来源:尧图企业网站定制
接到一个“用 Vue2 写个培训项目里的填空题模块”这种需求多半是新人入行的第一个正经任务。标题里三个词拆开看都不难但合在一起就很有意思Vue2 说明技术栈是经典 Options API 那套培训类项目说明代码要能当教材给人讲新建填空题则是一个完整的前后端交互场景。这篇博文就围绕“从零搭一个填空题模块”展开讲清楚需求拆解、数据设计、组件实现、踩坑实录让刚接触 Vue2 的同学能照着写也让带新人的老手有一份可以直接用来做 walkthrough 的案例。1. 接到任务后先想清楚的三件事1.1 培训类项目到底在培训什么很多培训类项目表面上是“做一个系统”实际上是在训练几件不太容易量化的事第一是组件化的拆分思维第二是数据驱动的渲染思路第三是代码的可读性。填空题这个场景特别适合练手因为交互简单、边界清晰但又能牵扯出 v-model、计算属性、组件通信、数组响应式更新这些 Vue2 里高频的考点。我刚带过的 A 同学接到这个需求时第一反应是“填空不就是一行 input 嘛”。等我说要把题目、答案、校验、判分都做成可配置的时候他才意识到事情没这么简单。这就是培训类项目和普通外包活的区别不是功能跑通就完事而是要让人看完代码能复述出“为什么这么写”。1.2 “填空题”这个需求的功能边界要提前划线为什么只提“新建填空题”而不是“做一套答题系统”因为培训项目通常分阶段交付第一阶段只做填空题本身后面可能接选择题、判断题甚至大题。如果一开始边界不划清很容易把代码写成什么都往里塞的“神组件”。我通常会先拉一个需求清单支持题干中任意位置插入若干个空每个空有独立序号用户依次填入提交后校验答案展示对错与得分可以重置重做题目数据不写死在组件里由父级传入这些列出来之后你会发现“填空题组件”的本质其实是接收一段带占位符的文本渲染成可交互的表单输出用户答案并提供判分。想清楚这一点组件就能设计得清爽。1.3 为什么是 Vue2以及要规避的版本坑培训类项目用 Vue2 不是技术落后而是因为存量系统里 Vue2 的占比仍然很高。新人在培训中练过 Vue2进了实际项目组通常能更快上手。但 Vue2 有几个和 Vue3 非常不一样的地方写之前要心中有数数据响应式基于Object.defineProperty直接通过下标改数组不会触发视图更新组件通信主要靠 props 向下、$emit向上没有 Vue3 里那么多的语法糖Options API 中data、computed、methods、watch各管一摊逻辑分散对 TypeScript 的支持不如 Vue3培训项目一般用 JavaScript 写更顺手知道这些你就能理解下文里不少“看起来很奇怪的写法”到底是为了解决什么问题。2. 项目初始化与目录结构规划2.1 用 Vue CLI 快速搭一个干净的骨架既然确定是 Vue2直接使用vue/cli初始化命令如下vue create vue2-fill-blank选项里选Manually select features把Babel、Router、ESLint勾上CSS 预处理器根据团队习惯选Sass或者Less。培训项目不追求花哨稳定、干净、可复现最重要。vue create完之后我习惯顺手装一个axios因为后续对接后端接口几乎必用npm install axios如果你们团队用yarn命令换成yarn add axios就行。2.2 目录结构一开始就按“可维护”的标准建培训项目最常见的毛病是所有代码堆在一个views页面里一个文件两千行。填空题这种场景本身不复杂但如果你打算做成“培训范例”目录结构还是正经一点比较好。我建议这样划分src ├── api │ └── questions.js # 接口请求统一放这 ├── components │ └── fill-blank │ ├── FillBlank.vue # 填空题主体组件 │ ├── BlankItem.vue # 单个空组件 │ └── ResultPanel.vue # 判分结果展示 ├── router │ └── index.js # 路由配置 ├── views │ └── Practice.vue # 练习页填空题所在页面 └── utils └── validator.js # 答案校验的纯函数工具有同学会问就一个填空题而已搞这么多文件不累吗答案是培训项目的核心目标不是“少写代码”而是“让每个人都能看懂并照着扩展”。当第二周要求新增选择题时大家只需要在components下再建一个choice-question目录而不需要去动FillBlank里的逻辑。2.3 配置 ESLint 和基础规范Vue CLI 初始化时选了 ESLint默认配置是ESLint Prettier。这一套下来基本能用但我会额外在.prettierrc里固定几个约定因为培训项目里最怕的是“十个人十种格式化风格”{ semi: false, singleQuote: true, printWidth: 100, trailingComma: es5 }不加分号、单引号、每行 100 字宽、尾逗号用 es5 风格。这些不是为了美感而是为了减少代码评审时无意义的“格式争论”。新人在培训阶段就养成统一的代码风格后面写真实项目会省很多事。3. 核心数据结构设计填空题的“灵魂”3.1 用数据驱动渲染而不是把题目标记写死在页面里我见过很多新手写填空题直接在模板里写死p中国首都是 input /面积最大的省份是 input /。/p这样写演示没问题但完全不具备复用性和扩展性。真实项目里题目来自后端接口题型可能变、空的数量可能变、答案可能有多选写死的模板必然撑不住。正确做法是让组件只负责“接收数据 - 渲染界面 - 提交数据”。数据层面要设计一套题目结构{ id: q_001, type: fill_blank, title: 中国首都是{{blank1}}面积最大的省份是{{blank2}}。, blanks: [ { id: blank1, answer: 北京, score: 5 }, { id: blank2, answer: 新疆, score: 5 } ], analysis: 本题考查中国地理常识。 }这里有个技巧题干里使用{{blank1}}这种占位符标记空的位置渲染时把题干的文本按占位符拆成片段再用v-for把文本和输入框交替渲染出来。这样就不需要给文本做复杂的正则匹配逻辑简单也直观。3.2 把题目文本拆成可渲染片段的具体方法实际渲染前我们需要把title字符串转成数组比如上面的题转换成[ { type: text, content: 中国首都是 }, { type: blank, blankId: blank1 }, { type: text, content: 面积最大的省份是 }, { type: blank, blankId: blank2 }, { type: text, content: 。 } ]这个转换过程我放在计算属性里因为题目数据本身是响应式的如果后续题目内容变了渲染片段能自动更新computed: { segments() { const segments [] const tokens this.question.title.split(/\{\{(blank\d)\}\}/) tokens.forEach((token, index) { if (/^blank\d$/.test(token)) { segments.push({ type: blank, blankId: token }) } else if (token) { segments.push({ type: text, content: token }) } }) return segments } }String.split配合捕获分组的好处是分隔符会保留在结果数组中所以我们能通过正则直接判断当前 token 是不是占位符。这个方法看起来很朴素但比用replace维护一个全局标记要稳得多不容易出现下标错乱。3.3 用户答案的数据结构也要精心设计用户填的答案需要单独维护我建议用对象存储键是 blankId值是用户输入的字符串data() { return { userAnswers: { blank1: , blank2: } } }为什么不直接用数组因为题目中的空可能有增删调整用数组按下标读很容易错位而用对象按 key 读写顺序问题就完全不用操心。Vue2 里this.userAnswers.blank1 xxx也是响应式的因为对象属性在初始化时就存在没有新增属性不会踩$set的坑。4. 填空组件的具体实现与交互逻辑4.1 模板结构如何把段落和输入框拼装成一个完整题干组件模板是填空题最直观的部分。根据上面的segments计算属性我们可以用v-for循环渲染template div classfill-blank h3 classfill-blank__title{{ question.title }}/h3 template v-forsegment in segments :keysegment.blankId || segment.content span v-ifsegment.type text classfill-blank__text{{ segment.content }}/span input v-else v-modeluserAnswers[segment.blankId] classfill-blank__input typetext :disabledsubmitted :class{ fill-blank__input--correct: submitted isCorrect(segment.blankId) } / /template div classfill-blank__footer el-button typeprimary :disabledsubmitted clickhandleSubmit提交/el-button el-button clickhandleReset重置/el-button /div /div /template需要注意一个细节template标签上的是:key不要省略。v-for里的输入框如果不给稳定 keyVue 在复用节点时可能出现输入框内容串位的情况尤其是v-if和v-else切换渲染时。key 用segment.blankId || segment.content保证文本片段和空片段各自有唯一标识。为什么用template包一层而不是直接用v-if在span和input之间切换因为在同一个循环里渲染两种不同标签Vue 2 的虚拟 DOM 需要合理的分支结构template是在渲染层做分组又不产生额外 DOM 节点最适合这种场景。4.2 交互逻辑提交、校验、重置提交的核心是校验答案。这里我把校验规则独立成一个模块方便复用和测试。基本规则包括去掉首尾空格后忽略大小写比较允许配置exact是否精确匹配和multiAnswer多个可接受答案。utils/validator.js 里的代码大致如下const normalize (str) String(str || ).trim().toLowerCase() export function compareAnswer(userInput, expected) { if (Array.isArray(expected)) { return expected.some((item) normalize(userInput) normalize(item)) } return normalize(userInput) normalize(expected) } export function checkAllAnswers(userAnswers, blanks) { return blanks.map((blank) ({ ...blank, userAnswer: userAnswers[blank.id] || , correct: compareAnswer(userAnswers[blank.id], blank.answer) })) }这里有两个容易被忽略的边界点用户的输入可能是undefined或nullnormalize里先String()一次再 trim稳妥多选题型中一个空可能对应多个正确答案所以expected要支持数组some判断只要命中一个就算对组件内调用methods: { handleSubmit() { const results checkAllAnswers(this.userAnswers, this.question.blanks) this.submitted true this.$emit(submit, results) }, handleReset() { this.userAnswers {} this.question.blanks.forEach((blank) { this.$set(this.userAnswers, blank.id, ) }) this.submitted false this.$emit(reset) } }这里有点小讲究重置时我没有直接写this.userAnswers {}因为 Vue2 的响应式系统对新增属性是“非响应式”的。虽然后续赋值用$set能补救但更好的习惯是初始化时就把所有 blank 的 key 都铺好。我一般在created里跑一次initAnswerscreated() { this.initAnswers() }, methods: { initAnswers() { const initial {} this.question.blanks.forEach((blank) { initial[blank.id] }) this.userAnswers initial } }4.3 判分与得分计算用计算属性保持数据单一来源得分不需要单独存一个字段直接从results里算就行。这样不管用户怎么重置、重做分数都实时同步不会出现“答案改了分数没变”的问题computed: { totalScore() { if (!this.results) return 0 return this.results.reduce((sum, item) sum (item.correct ? item.score : 0), 0) }, correctCount() { if (!this.results) return 0 return this.results.filter((item) item.correct).length } }在handleSubmit里把结果存到 data 的results字段中。计算属性依赖results只要提交动作触发分数自动重算。这种“结果驱动展示”的模式在答题类组件里非常实用后续如果要做倒计时自动交卷只要往results里塞数据就行视图层完全不用动。5. 组件拆分与通信一个填空题也不要堆成一座山5.1 拆分的边界在哪里填空中“单个空”的输入框如果只是原生 input那确实不需要拆但我建议把BlankItem独立成组件原因有两个后面可能要升级为支持下拉选空的“半填空题”模式原生 input 的 focus、blur、错误态展示集中封装后页面模板会干净很多BlankItem.vue是一个极简单的子组件template input classblank-item typetext :valuevalue :disableddisabled input$emit(input, $event.target.value) blur$emit(blur) / /template script export default { name: BlankItem, props: { value: { type: String, default: }, disabled: { type: Boolean, default: false } } } /script这里有个 Vue2 老手都知道的点v-model本质是valueprop 加input事件的语法糖。子组件里声明了valueprop并对外$emit(input, ...)父组件就可以直接写v-modeluserAnswers[segment.blankId]。这是最经典的 Vue2 自定义表单组件写法培训中经常考自己动手写一次印象最深。5.2 父子通信的模式选择填空题、练习页、判分结果三者的数据关系大概是Practice.vue父负责从接口取题把题目传给FillBlankFillBlank子负责内部答题与自测提交时把结果抛给父ResultPanel孙纯粹展示结果数据由FillBlank传入通信规则简单明确props 向下事件向上。不需要引入 Vuex 或 EventBus 来存临时答案。很多新手特别喜欢什么数据都往 Vuex 里塞但填空题这种页面级状态放在组件内部才是最合理的。培训中应该刻意训练“先想想真的需要全局状态吗”这个习惯。Practice.vue大致如下template div FillBlank v-ifquestion :questionquestion submithandleSubmit resethandleReset / /div /template script import FillBlank from /components/fill-blank/FillBlank.vue import { getQuestionById } from /api/questions export default { components: { FillBlank }, data() { return { question: null, lastResult: null } }, created() { this.fetchQuestion() }, methods: { async fetchQuestion() { const res await getQuestionById(q_001) this.question res }, handleSubmit(results) { this.lastResult results }, handleReset() { this.lastResult null } } } /script5.3 结果展示组件纯粹的一页纸ResultPanel我设计成只接收一个results数组渲染每道空的正确与否template div classresult-panel div classresult-panel__header 答对 strong{{ correctCount }}/strong / {{ results.length }}得分 {{ totalScore }} /div ul li v-foritem in results :keyitem.id span{{ item.title }}/span span :classitem.correct ? is-right : is-wrong {{ item.correct ? 正确 : 错误 }} /span /li /ul /div /template这种“展示组件只负责根据 props 画界面”的划分方式是培训中重点强调的容器组件与展示组件分离思想。填空题业务里FillBlank是容器组件BlankItem和ResultPanel是展示组件。后续要加导出成绩、打印错题都是在容器层操作不会把简单组件搅浑。6. 常见问题与排查技巧实录6.1 数组直接改下标视图纹丝不动这是 Vue2 新手必踩的坑。有人会在handleSubmit里写this.results[1].correct true页面完全没变化。原因是Object.defineProperty拦截不到数组下标直接赋值。解决办法有三类用this.$set(this.results, 1, newItem)整体替换数组在设计数据时避免“事后改下标”的需求比如更正时直接重新生成一个完整数组我在填空题里基本不会去修改results数组的某个成员而是每次提交重新checkAllAnswers生成新数组赋给this.results。从根源上绕开响应式陷阱比记住各种$set用法更省心。6.2 输入框内容串位培训项目中 F 同学遇到过在第一个空输入“北京”第二个空输入“上海”提交后显示第一个空也变成了“上海”。排查半天发现是v-for里没有给input绑定合适的 key。当 Vue 2 复用元素时如果两个输入框没有唯一 keyDOM 节点可能被复用导致 value 串位。我最后给输入框外层加上了:keysegment.blankId || segment.content问题立刻消失。说到底v-for里加不加 key 不是可选项而是性能和数据正确性的双重保障。宁可在所有循环里都给 key也不要偷懒。6.3 参考答案里有多个可接受写法填空题的答案校验比选择题复杂因为用户可能填“北京”也可能填“北平”如果你出的是历史题。我在这里给answer字段设计成“数组表示多个可接受答案”blanks: [ { id: blank1, answer: [北京, 北平], score: 5 } ]compareAnswer里已经处理了数组逻辑这样出题端配数据时灵活度就够了。如果你希望区分大小写可以在compareAnswer里加一个选项参数而不是写死。6.4 重置后输入框没有清空很多人在handleReset里写this.userAnswers {}看起来把对象置空了但输入框依然显示旧值。原因在于userAnswers里原来的 key 跟组件模板里的v-model绑定断了Vue 找不到对应响应式依赖。正确做法是重新初始化所有 keyhandleReset() { const initial {} this.question.blanks.forEach((blank) { initial[blank.id] }) this.userAnswers initial this.submitted false this.results null }这里顺便说一句如果你非要把userAnswers置空成{}后续输入框绑定的userAnswers[segment.blankId]会成为新增属性不是响应式的输入就会断断续续不更新。为了避免这些问题最简单就是初始化时把 key 铺满并且重置时重新铺。6.5 提交按钮连点时重复提交答题场景常见的一个问题用户手一抖双击提交回调触发两次后端收到两条记录。我一般做两层防护提交成功后立刻把submitted置为true按钮加disabled如果后端有 loading 态在handleSubmit开头加一个if (this.submitting) return的防抖判断handleSubmit() { if (this.submitted || this.submitting) return this.submitting true ... this.submitting false }这个习惯在培训阶段养成后做任何表单提交类功能都不会再出低级事故。7. 扩展思路填空题做完之后可以往哪走填空题组件跑通事实上已经练到了一个 Vue2 组件的完整闭环props、data、computed、methods、watch、$emit、v-model、生命周期。后续扩展无非是几个方向把FillBlank的判分逻辑抽成一个通用AnswerChecker这样选择题、判断题也能复用在blank上再加一个placeholder字段或者按空给分不同分值把出题端和管理端分开一个页面管理题库 JSON另一个页面练习答题两者共用同一套数据结构支持音频题、图片题在题干文本中混入图片组件如果项目里用了 Element UI 这类组件库把input换成el-input整体逻辑基本不用变。这说明底层数据设计对了UI 层换成什么花样都只是皮囊。遇到要新增题型只需要在question.type上做判断在模板里分流到不同组件。数据格式保持统一后面的选择题和判断题都能顺势接进来不需要推倒重来。这也就是培训类项目为什么要反复强调“数据结构先行”的原因。我在给新人做 review 时最看重的不是代码能不能跑而是能不能说清楚“这段逻辑将来如果需求变了我要改哪里”。如果你的答案指向很明确比如“改validator.js里的compareAnswer就行”那这个填空题就是合格的一次交付。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑