ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手撕Vue填空题组件:双版本实现,支持自由插空、勾选输入与回显

手撕Vue填空题组件:双版本实现,支持自由插空、勾选输入与回显 手撕一个Vue填空题组件双版本实现支持自由插空、勾选输入、数据回显全搞定最近业务上遇到一个让人挠头的需求要在段落里做完形填空填空位置不定有些空要手动输入有些空得从备选词里勾选做完之后还得能存档、回显。翻了一圈现成组件不是太重就是样式写死改起来费劲。索性自己撸一个顺便整理成一篇实战笔记。这篇内容准备用两个版本实现一个纯原生JS版适合轻量场景、不想引框架的页面一个Vue组件版适合Vue 2 / Vue 3项目利用响应式和组件通信把逻辑封装干净。核心功能包括自由控制填空位置、段落中插入多个填空、可输入也可勾选、答案数据序列化保存与回显。两套代码都写了详细注释照着抄就能用。1. 功能需求拆解与方案选型1.1 填空题组件的真实使用场景先捋一下这类组件到底用在哪。最常见的就是在线教育平台、考试系统、问卷表单里的完形填空还有企业内部培训系统里的知识点测验。它跟普通表单输入框最大的区别在于填空不是独立存在的控件它是嵌在段落文本流中的一部分所以在排版上必须跟正文保持自然衔接。具体来说需求可以拆成四个能力自由控制填空位置作者在录入题目时需要能指定正文中哪个位置变成填空而不是固定格式的第几题填空。支持多填空同一个段落里从头到尾可以插多个空每个空有自己的题目序号、ID和答案。两种作答方式第一种是自由输入用户直接在空里敲字第二种是勾选填空从一组备选词里选一个填进去类似于把选择题伪装成填空题。数据保存与回显用户提交后答案要能序列化成JSON存到后端下次进入页面时根据已保存的数据把填空状态还原出来。如果只用一张后端表去存题目ID 用户答案那这些需求还比较好实现难点在于前端交互——你要实时拿用户填在段落中间的值还要在校验时精准提示哪个空没填。这类型的组件用现成的第三方库反而不划算自己封装其实不复杂。1.2 为什么做两个版本我在实现时同时写了原生JS和Vue两个版本原因很简单不是所有项目都能用上Vue。有些老项目或者轻量页面可能就是一个静态页面加几个CDN脚本这时候塞一个Vue进去太重了。用原生JS写零依赖、即插即用压缩完也就几KB。Vue版本则是面向组件化开发场景。填空题往往不是独立存在的它会跟后端交互、表单校验、题目切换等逻辑串在一起。用Vue实现可以把填充状态交给响应式数据管理答案变更自动触发视图更新代码量会大幅度下降可维护性也更好。两个版本的实现思路其实是一致的核心就是把段落文本中的占位标记解析成填空位再用一个状态对象记录每个填空位的答案。理解了这一点不管是React、Vue还是原生JS你都能轻松迁移。1.3 技术方案的取舍实现方案上我考虑过两种策略第一种是用纯文本占位符。比如约定用{{blank_1}}、{{blank_2}}这样的标记嵌入段落文本渲染时通过正则或字符串替换把它替换成输入框或勾选面板。优点是录入题目时很直观编辑时看到的文本跟最终渲染效果一一对应缺点是如果用户输入的答案里也包含{{}}这种字符容易误伤。第二种是用结构化JSON定义。段落文本、填空位、备选词全部放在一个对象里渲染时根据填空位的索引把文本拆分成数组。优点是结构清晰、不容易误判缺点是题目录入时要写一大段JSON维护成本高。我最终选择的是方案一为主、方案二为辅在文本录入场景用{{1}}这种占位符同时暴露出一个解析函数支持把带占位符的题目文本一键转换成结构化JSON。这样既照顾到了编辑体验又给后端存储留了结构化接口。2. 原生JS版本零依赖手写填空题2.1 核心逻辑与数据模型原生JS版本的核心其实就是一个文本替换 事件绑定过程。先把题目文本解析成DOM片段再给每个填空位绑定input事件。数据上我用了最简单的一个对象来存答案// 答案模型 const answerMap { 1: 自由输入的内容, 2: 勾选选项的value, 3: };每个填空位在初始化时给它分配一个>// 题目段落示例文本 const rawText 中国的首都是{{1}}它有{{2}}年的历史。备选{{2北京,上海,南京}};这里的核心逻辑是正则区分两种填空{{1}}表示自由输入填空1是填空ID。{{2北京,上海,南京}}表示勾选填空后面跟的是逗号分隔的选项列表。实际项目里你可以根据需要调整标记格式只要保证解析正则和渲染函数同步修改即可。2.2 DOM渲染与事件委托渲染的完整代码如下我加了很多注释方便直接复制function parseTextToDOM(rawText, answers) { const container document.createElement(div); container.className fill-blank-container; // 正则匹配 {{数字}} 或 {{数字选项1,选项2,...}} const regex /\{\{(\d)(?:([^}]))?\}\}/g; let lastIndex 0; let match; let fragment document.createDocumentFragment(); while ((match regex.exec(rawText)) ! null) { // 添加填空前面的纯文本 if (match.index lastIndex) { fragment.appendChild(document.createTextNode(rawText.slice(lastIndex, match.index))); } const blankId match[1]; const optionsStr match[2]; const savedValue answers[blankId] || ; if (optionsStr) { // 勾选模式渲染一个select下拉或一组按钮这里用select最简单 const select document.createElement(select); select.className blank-select; select.dataset.blankId blankId; // 默认占位选项 const placeholderOpt document.createElement(option); placeholderOpt.value ; placeholderOpt.textContent 请选择; select.appendChild(placeholderOpt); // 渲染选项列表 optionsStr.split(,).forEach(optText { const option document.createElement(option); option.value optText; option.textContent optText; if (savedValue optText) { option.selected true; } select.appendChild(option); }); fragment.appendChild(select); } else { // 自由输入模式 const input document.createElement(input); input.type text; input.className blank-input; input.dataset.blankId blankId; input.placeholder 请输入答案; input.value savedValue; fragment.appendChild(input); } lastIndex match.index match[0].length; } // 添加最后一段文本 if (lastIndex rawText.length) { fragment.appendChild(document.createTextNode(rawText.slice(lastIndex))); } container.appendChild(fragment); return container; }事件委托部分绑定到容器上就行不用管里面是输入框还是下接拉框container.addEventListener(input, function(e) { const target e.target; if (target.classList.contains(blank-input) || target.classList.contains(blank-select)) { const blankId target.dataset.blankId; answerMap[blankId] target.value; } }); container.addEventListener(change, function(e) { const target e.target; if (target.classList.contains(blank-select)) { const blankId target.dataset.blankId; answerMap[blankId] target.value; } });注意select在部分浏览器上只有触发change事件才会更新值所以input和change两个事件都要监听避免数据不同步。2.3 答案序列化与回显保存和回显是业务方最关心的。序列化很简单直接JSON.stringify(answerMap)但要注意后端存储格式的约定——我建议存成一个对象而不是数组这样即使题目中间的填空被删掉也不会因为索引错位导致数据对不上。回显时把JSON字符串解析出来传给parseTextToDOM函数内部根据answers[blankId]自动回填。这一块有个小坑如果你在渲染完成之后再异步回填数据输入框的内容不会自动刷新。解决办法是封装一个独立的setBlankValue(blankId, value)函数手动更新数据和DOM。2.4 原生版本完整调用示例// 初始化 const answers JSON.parse(localStorage.getItem(quiz_answers) || {}); const container parseTextToDOM(中国的首都是{{1}}它有{{2北京,上海,南京}}。{{1}}是世界文明古都。, answers); document.getElementById(app).appendChild(container); // 保存答案 function saveAnswers() { localStorage.setItem(quiz_answers, JSON.stringify(answerMap)); console.log(保存成功, answerMap); }到这里原生版就完事了总共不到100行逻辑代码。关键是理解了占位符解析 事件委托 状态对象这个思路后面Vue版只不过是把状态放进了响应式系统里而已。3. Vue版本组件化封装与响应式数据3.1 组件Props与事件设计Vue版本我采用组件化思路实现对外暴露的接口设计如下props: { // 题目内容文本包含 {{1}} / {{2选项a,选项b}} 形式的占位符 content: { type: String, required: true }, // 已保存的答案对象如 {1: 北京, 2: 上海} savedAnswers: { type: Object, default: () ({}) } }组件内部维护一个answers响应式对象通过watch同步外部传入的savedAnswers。任何答案变更通过update:answers或$emit(answers-change, answers)抛给父组件由父组件负责持久化或提交。这种设计的好处是组件内不关心后端逻辑只管展示和交互业务方拿到答案想存哪里都行——localStorage、后端接口、甚至埋点上报都可以。3.2 用计算属性完成核心解析跟原生JS版本用正则 手动操作DOM不同Vue版本最妙的地方在于解析逻辑可以放到计算属性里模板自动渲染。只要content不变计算属性结果就缓存内容一变填空位自动重算完全不用手动管理DOM。const parsedSegments computed(() { const content props.content; const regex /\{\{(\d)(?:([^}]))?\}\}/g; const segments []; let lastIndex 0; let match; while ((match regex.exec(content)) ! null) { if (match.index lastIndex) { segments.push({ type: text, value: content.slice(lastIndex, match.index) }); } const blankId match[1]; const options match[2] ? match[2].split(,) : []; segments.push({ type: options.length 0 ? select : input, blankId, options, value: props.savedAnswers[blankId] || }); lastIndex match.index match[0].length; } if (lastIndex content.length) { segments.push({ type: text, value: content.slice(lastIndex) }); } return segments; });模板里就简单了用v-for循环渲染即可。文本节点直接输出填空节点根据类型渲染不同的输入控件。3.3 模板实现与样式隔离template div classfill-blank-component template v-for(segment, index) in parsedSegments :keyindex !-- 文本段 -- span v-ifsegment.type text v-textsegment.value / !-- 可勾选的填空 -- select v-else-ifsegment.type select v-modelanswerMap[segment.blankId] classblank-select :data-blank-idsegment.blankId changeemitChange option value-- 请选择 --/option option v-foropt in segment.options :keyopt :valueopt v-textopt / /select !-- 自由输入填空 -- input v-else v-modelanswerMap[segment.blankId] classblank-input typetext :placeholder请填写第 ${segment.blankId} 空 inputemitChange / /template /div /template样式这部分我做了一个比较克制但好用的小细节给input和select都设置width: auto配上虚线边框阅读时能自然感知到这是填出来的内容而不是输入框本身。具体样式可以根据业务系统调整重点是不要破坏正文排版。3.4 数据响应与v-model的坑这里要说一个非常容易踩的坑v-modelanswerMap[segment.blankId]中的answerMap必须在data里预先定义好所有用到的键否则Vue的响应式系统无法劫持后加的属性界面不会自动更新。data() { // 注意不能写成空对象 {}要预置初始键 return { answerMap: {} }; }, created() { // 从 props.savedAnswers 初始化同时确保所有 blankId 都有默认值 const keys this.parsedSegments.filter(s s.type ! text).map(s s.blankId); const initial {}; keys.forEach(k { initial[k] this.savedAnswers[k] || ; }); this.answerMap initial; }如果你用了Vue 3的reactive同样可以用reactive({})加Reflect.set来规避但最简单还是初始化时就生成完整键。实操心得不要在新版本里依赖动态添加属性也能触发更新这个行为Vue 3的Proxy虽然能拦截新增属性但性能会差一些而且很多人沿用Vue 2的写法容易出BUG。预先创建满完整键干脆利落。4. 高级功能扩展与数据持久化4.1 自定义校验与作答状态填空题组件光能填还不够还需要支持校验。比如某个空是必填的、某些空只允许特定格式、或者用户未作答时给出提示。我一般在组件内部维护一个errors对象数据结构跟answerMap对齐const validate () { const errors {}; const blankIds blankIdsFromContent(props.content); for (const id of blankIds) { const value answerMap[id]?.trim?.() || ; if (!value) { errors[id] 该填空未作答; } } return errors; };然后通过validate方法暴露给父组件调用。组件实例返回一个带validate和getAnswers的方法对象业务方提交时校验不通过就把错误信息显示在对应填空下方。这里还有个进阶玩法把未作答做成可视化状态。比如为空时填空底边框是红色虚线答完自动恢复默认样式。实现思路是让errors变成响应式在模板里动态绑定class。4.2 动态增删填空位运营经常会改动题目比如某道完形填空原本3个空后来发现应该挖4个。得益于占位符解析方案改题目内容只需要改content字符串新增一个{{4}}或{{4选项A,选项B}}即可。组件内部计算出新的blankId集合后要把新增的key补到answerMap上否则未作答校验会漏掉watch(() props.content, () { const keys parsedSegments.value.filter(s s.type ! text).map(s s.blankId); keys.forEach(k { if (!(k in answerMap)) { answerMap[k] savedAnswers.value[k] || ; } }); });4.3 本地持久化与后端接口格式前端做完答案存储格式怎么设计直接影响后期统计。我建议统一使用以下格式{ questionId: q_10001, content: 中国的首都是{{1}}它有{{2北京,上海,南京}}。, answers: { 1: 北京, 2: 上海 } }content保存的是带占位符的原始文本answers只存答案。回显时前端拿到这两个字段把content传给组件answers作为savedAnswers初始化即可。这种方式下哪怕题目内容后续有调整也能根据content里的占位符说明哪个空是哪个ID不会错乱。本地存储的话放localStorage是最快的但要记得加JSON序列化和异常捕获function saveToLocal(key, data) { try { localStorage.setItem(key, JSON.stringify(data)); } catch (e) { console.error(保存失败localStorage可能不可用或已满, e); } }4.4 多题模式与答案合并实际业务中一道填空题很少单独存在一般是一个试卷模式包含多道题。这种场景建议在父组件层管理一个对象数组每个元素对应一道题的数据子组件只负责单题渲染。合并提交时把每道题的answers提交到对应questionId下后端按题目ID分开存即可。const allAnswers questionsData.map(q { return { questionId: q.id, answers: q.componentRef.getAnswers() }; });这种多题联动场景我踩过最大的坑是子组件缓存问题。用v-for渲染题目列表时如果题目数据里还有content之外的标记字段Vue的key检测到没变化可能复用组件实例导致savedAnswers不回显。解决办法是给组件加一个:key用questionId别用数组下标。5. 常见问题与排查技巧实录5.1 解析正则失效或误匹配占位符解析看着简单但有些课题文本里可能正好包含{{1}}这样的字符串被误判成填空。排查思路是先看正则是否过于宽泛。比如有些人直接写了/\{\{(\d)\}\}/g那文本里如果真的有{{1}}这种字面量必然被替换。最好约定一个前缀比如{{blank_1}}或{{q1}}降低误判概率。如果你要支持中文选项注意正则里的[^}]能匹配中文没问题。但如果选项里也包含符号就要调整分隔符或者做转义处理。我在生产环境用的是{{2选项A::选项B}}因为::在正常文本中出现的概率远低于逗号。5.2 Vue响应式丢失导致界面不更新这个问题我至少见过十次。现象是输入框内容变了但answerMap里的值没变或者反过来。绝大多数原因是直接在created之后用answerMap[新key] xxx添加属性。前面已经说过解决办法是初始化时把所有blankId全铺好。如果确实需要动态添加Vue 3 用this.answerMap { ...this.answerMap, [newId]: };这样会创建一个新对象触发响应式依赖更新。5.3 下拉框回显值不对勾选填空用的select回显时如果option的value和savedAnswers存的不是完全一致就会出现明明存了北京回显出来却是空白的情况。比如你把备选词存成了 北京带空格那select的option.value也是 北京跟你存的北京对不上。解决思路很朴素存储和展示都用同一个来源。提交答案时直接把选中项的value存进去别做任何trim或格式化回显时直接拿存的值对比。真要规范化就在批量处理时做不要在组件内部做两套逻辑。5.4 段落长文本的性能优化如果一个题目段落非常长比如整篇文章挖了50个空每个空都渲染一个输入框会不会卡顿实测下来50个输入框在普通浏览器上完全没压力不需要引入虚拟滚动。但如果一个页面同时渲染几十道题每道题50个空那就是几千个输入框这时候可以考虑两个优化点把不在视口范围内的题目v-if掉滚动到附近再渲染。填空输入框改成textarea自动高度而不是普通input方便长答案。其实最影响性能的不是DOM数量而是每次输入都触发整张页面重渲染。Vue的v-model默认是双向绑定输入一次更新一次响应式数据只要组件拆分合理渲染范围就只限制在当前组件内性能问题不大。5.5 不同浏览器之间的兼容差异原生JS版本如果要在老浏览器上跑需要注意三点matches/closest方法在旧浏览器上要polyfill。dataset属性兼容性尚可但IE11也支持。模板字符串、箭头函数等ES6语法建议用Babel转译一下或者在代码里用ES5写法。Vue版本就简单了只要你用的是Vue 2或Vue 3官方脚手架Webpack/Vite会帮你搞定大部分兼容处理唯一要注意的是Proxy在IE上不可用Vue 3不支持IE这个前提要提前跟业务方说清楚。5.6 调试技巧给填空位加可视化标记填空题这种嵌在段落里的组件样式上很容易看不出哪里是能填的。我在开发调试阶段会加一个高亮边框把所有.blank-input和.blank-select的边框颜色设为亮红色一眼扫过去就知道所有填空位有没有被正确渲染。上生产时再切换成正常的虚线边框/* 调试专用 */ .fill-blank-container .blank-input, .fill-blank-container .blank-select { border: 2px solid red; } /* 正式样式 */ .fill-blank-container .blank-input, .fill-blank-container .blank-select { border: 1px dashed #999; border-radius: 4px; padding: 2px 8px; font: inherit; }这个调试样式开关的做法特别适合问题定位建议保留在组件源码的注释里下次排查时直接打开。6. 组件封装之外我的一些心得体会这套填空题组件写完并应用到实际项目之后有几个操作上的体会值得分享。第一能用数据结构解决的问题就不要用DOM操作解决。初版原生JS我是遍历DOM去收集答案的写起来繁琐不说题目一旦增删填空位收集逻辑就崩。后来改成标记解析 状态对象之后整个代码量缩减了差不多一半可读性也上来了。第二给占位符定规则时宁可长一点别凑合。用{{1}}虽然简洁但如果文章正文里碰巧出现数学公式或者JSON展示样例极容易误判。我在实际开发里把规则改成了{{blank_1}}和{{select_2北京,上海}}虽然录入题目时多敲几个字母但换来的是不出幺蛾子。第三回显数据时要处理“题目被改过”的情况。用户上次提交时题目有5个空管理员后来把题改成了7个空那旧数据里只有5个答案。组件初始化时对缺失的空直接显示为空即可不要报错也不要默认填充这样用户体验最好。第四范围收窄原则。填空题看起来只是段落里加几个输入框但它在试卷场景里往往只是冰山一角——前面还有题干展示、音频/图片资源、答题卡、倒计时、得分统计等等。组件设计时一定要克制不要去管题目切换、自动计分这些不属于它的职责把填空部分做纯粹业务层自然好对接。最后再多说一句关于双版本的选择。如果你是个人项目或者快速原型直接用原生JS版本就行甚至不需要构建工具打开一个HTML文件就能看到效果。如果你是在正经的业务工程里那必然选Vue组件版App 开发时会顺滑得多。两个版本的核心解析逻辑是一样的你掌握其中一个另一个就是换个壳而已。这套填空题组件的代码核心逻辑前后加起来也就200行左右但确实帮我顶了不少业务场景。你如果也有类似需求直接照着自己的项目改吧有问题咱们评论区多交流。
返回列表