接到一个“用 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 class="fill-blank"> <h3 class="fill-blank__title">{{ question.title }}</h3> <template v-for="segment in segments" :key="segment.blankId || segment.content"> <span v-if="segment.type === 'text'" class="fill-blank__text">{{ segment.content }}</span> <input v-else v-model="userAnswers[segment.blankId]" class="fill-blank__input" type="text" :disabled="submitted" :class="{ 'fill-blank__input--correct': submitted && isCorrect(segment.blankId) }" /> </template> <div class="fill-blank__footer"> <el-button type="primary" :disabled="submitted" @click="handleSubmit">提交</el-button> <el-button @click="handleReset">重置</el-button> </div> </div> </template>需要注意一个细节:template标签上的是:key不要省略。v-for里的输入框如果不给稳定 key,Vue 在复用节点时可能出现输入框内容串位的情况,尤其是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或null,normalize里先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里跑一次initAnswers:
created() { 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 class="blank-item" type="text" :value="value" :disabled="disabled" @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-model="userAnswers[segment.blankId]"。这是最经典的 Vue2 自定义表单组件写法,培训中经常考,自己动手写一次印象最深。
5.2 父子通信的模式选择
填空题、练习页、判分结果三者的数据关系大概是:
Practice.vue(父)负责从接口取题,把题目传给FillBlankFillBlank(子)负责内部答题与自测,提交时把结果抛给父ResultPanel(孙)纯粹展示结果,数据由FillBlank传入
通信规则简单明确:props 向下,事件向上。不需要引入 Vuex 或 EventBus 来存临时答案。很多新手特别喜欢什么数据都往 Vuex 里塞,但填空题这种页面级状态,放在组件内部才是最合理的。培训中应该刻意训练“先想想真的需要全局状态吗”这个习惯。
Practice.vue大致如下:
<template> <div> <FillBlank v-if="question" :question="question" @submit="handleSubmit" @reset="handleReset" /> </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 } } } </script>5.3 结果展示组件:纯粹的一页纸
ResultPanel我设计成只接收一个results数组,渲染每道空的正确与否:
<template> <div class="result-panel"> <div class="result-panel__header"> 答对 <strong>{{ correctCount }}</strong> / {{ results.length }},得分 {{ totalScore }} </div> <ul> <li v-for="item in results" :key="item.id"> <span>{{ item.title }}</span> <span :class="item.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 复用元素时,如果两个输入框没有唯一 key,DOM 节点可能被复用导致 value 串位。我最后给输入框外层加上了:key="segment.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 找不到对应响应式依赖。正确做法是重新初始化所有 key:
handleReset() { 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就行”,那这个填空题就是合格的一次交付。