ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

在线答题系统完形填空功能:纯JS与Vue3双版本实现

在线答题系统完形填空功能:纯JS与Vue3双版本实现 做在线答题系统的时候我接到一个“完形填空”需求。标题看着简单一段英文文章中间挖几个空学生填单词做完能保存下次进来还能回显。真动手才发现拆需求能拆出六个子功能存数据也有不少边界情况。这个功能最麻烦的点不在“放几个输入框”而在“怎么让填空位置完全可控”“怎么在段落中间嵌多个控件”“怎么把答案和题面一一对应存下来”。这篇文章我直接把我做过的两个版本完整放出来一个纯JavaScript实现不依赖框架一个是Vue 3组件版。两个版本都能满足“可勾选可输入、自由控制填空位置、段落加多个填空、保存回显”这几个硬指标代码里我写了详细注释照着抄就能用。1. 需求拆解填空题不只是嵌几个输入框1.1 标题里藏着的六个核心诉求先花一分钟把标题翻译成人话。一个完整的填空功能至少要拆出下面这些东西可自由控制填空位置不是只能在句子末尾放输入框而是在段落中间任意位置挖空。一个段落加多个空一段英文可以有5个、10个空格每个空独立记录答案。可勾选并输入有些空要求手动输入单词有些空要给选项点击填入甚至两者共存。保存数据用户填完答案之后能把结果上报给后端或者存到本地。回显数据页面重新打开时之前填的答案要自动填回对应的空。双版本一套用原生JS写不需要框架也能跑一套用Vue封装成组件方便在工程化项目里直接用。这六点看着简单但有一个关键点特别容易忽略填空位置和答案之间的对应关系。用户填的是第3个空保存的时候你得知道这个答案是给第3个空用的。如果题目顺序调整了或者空被删了几个答案数据能不能跟着变这就引出了数据结构和解析方案的设计问题。1.2 为什么做双版本两个方案的适用场景完全不同不少同事问过我为什么不用一个版本搞定。我的回答是填空题功能天然分两种使用场景。第一种是“轻量嵌入”。比如一个静态HTML页面、一个CMS后台生成的试卷、甚至是用Markdown写的文档页面你不可能为了一个填空功能引入整个Vue脚手架。这时候原生JS的类库就是最好的选择引入一个脚本几分钟就能初始化。第二种是“工程化集成”。如果你正在做一个基于Vue的在线教育平台、考试系统、习题管理后台那最好把填空题封装成组件通过props传题面、通过事件抛答案父页面只管数据流其他交给组件内部处理。这样代码才能复用维护成本也低。两个版本的核心逻辑其实是相通的解析题面、渲染控件、收集答案、回显数据。区别只在于渲染层的实现方式。所以这篇文章我会先讲通核心设计再分别放两个版本的完整代码。1.3 方案核心用占位符标记让填空位置完全可控拿到需求我第一个想的问题是怎么标记“这里有填空”最直接的办法是在题面里写死一个特殊的占位符渲染的时候识别它并替换成输入控件。我最终用的是双花括号加数字ID的格式比如今天天气{{1}}适合出门{{2}}。{{1}}表示第一个空{{2}}表示第二个空。题面本身就是纯文本填空题的配置数据里存一个blanks数组来描述每个空的类型和选项。这样做的优势非常明显题面和配置分离编辑题面时不需要碰代码。填空位置靠文本标记控制想放在哪个词后面就在哪个词后面写一个占位符。后续支持富文本或Markdown渲染时这个解析模式依然成立。2. 数据结构设计先定规矩再写代码2.1 题目配置content blanks 的拆分逻辑数据结构是整个功能的灵魂。一个填空位需要描述三样东西它是谁、它长什么样、它的标准答案是什么。我给出的配置结构长这样const question { content: The {{1}} cat sat on the {{2}} mat., blanks: [ { id: 1, type: input, answer: black }, { id: 2, type: click, options: [red, green, blue], answer: red } ] };这里的content是带占位符的原始文本blanks是填空描述数组。注意我用的是id而不是数组下标来标识填空位后面我会解释为什么。type字段决定了这个空的交互形式input普通输入框手动填单词。select下拉选择从选项里选一个。click候选词勾选点一下选项填入空白完形填空最常见。如果需要“可勾选并输入”可以再扩展一个type: both同时渲染输入框和候选词按钮。这种结构的好处是题面和填空配置彻底解耦。后端返回题目时只需要把题面和配置一起返回前端就能直接渲染。2.2 答案数据结构以 blankId 为键的扁平对象用户填完答案后上报给后端的数据格式必须是稳定的。我推荐用以下格式const answers { 1: black, 2: red };键是填空位的字符串ID值是用户填的内容。这个格式简单、稳定、可扩展。后端存储时也可以用JSON字段直接存回显时把这个JSON原样返回前端就行。这里有一个很容易踩的坑我在后面第九章会详细说答案对象返回给父组件之后父组件如果直接用它做v-model的双向绑定会因为对象引用问题导致子组件重新渲染时答案被清空。稳妥的做法是组件内部维护一份答案副本只在保存或提交时才emit出去。2.3 为什么用id而不是用下标顺序变了答案不会错位用数字作为填空位ID的理解成本很低但它和“数组下标”有一个本质区别。假设你的题目里删掉了第一个空后端返回的题目数据结构里blanks数组的下标会整体移位。如果你把答案存成数组[black, red]第一个元素永远对应第一个起居位置的空删除操作立刻让所有答案错位。但用ID就安全多了。删除后剩下的空ID不变答案依然能精准对应。这也是在线答题系统里必须考虑的定数。你在设计自己的功能时如果数据量会变更强烈建议用ID方案而不是下标方案。3. 核心机制段落解析器与渲染流程3.1 正则解析把一段话拆成 text 和 blank 片段不管哪个版本第一步都是解析题面。目标很简单把带占位符的字符串拆成一个片段数组数组里每项的 type 是text或者blank。以纯JS版为例核心正则解析逻辑如下function parseContent(content, blankIds) { const parts []; // 只匹配配置里存在的ID避免未知占位符干扰 const idPattern blankIds.join(|); const regex new RegExp(\\{\\{(${idPattern})\\}\\}, g); let lastIndex 0; let match; while ((match regex.exec(content)) ! null) { // 占位符前面的普通文本 if (match.index lastIndex) { parts.push({ type: text, content: content.slice(lastIndex, match.index) }); } // 占位符本身 parts.push({ type: blank, id: match[1] }); lastIndex regex.lastIndex; } // 尾部剩余文本 if (lastIndex content.length) { parts.push({ type: text, content: content.slice(lastIndex) }); } return parts; }这段代码的核心思路是用regex.exec循环匹配通过lastIndex记住上一个匹配结束的位置把中间的文本截出来作为 text 片段。用生活化的比喻来说这就像一个字符串肉铺先把整块肉分成“普通肉”和“排骨”每块排骨都贴上编号标签渲染的时候按顺序摆好。注意这里的正则匹配穿了一层动态构造。blankIds.join(|)会把 [1, 2] 变成字符串 1|2最后正则变成\{\{(1|2)\}\}。这样有一个好处如果题面里出现了{{99}}但配置里没有这个空它会被当成普通文本显示不会被错误替换成空输入框。3.2 占位符解析的边界情况处理解析这一环节至少有四种边界情况需要处理第一种占位符紧挨着中文或英文字母。例如hello{{1}}world解析逻辑不受影响因为lastIndex只依赖正则匹配的位置不管前后是什么字符。第二种题面里有多个空格连续。普通字符串里的空格在HTML渲染时会被折叠成一个解析本身没问题但渲染成HTML后你会发现空格数量不对。这个问题我放在第7.1小节专门讲。第三种同一个填空ID重复出现。比如{{1}}...{{1}}这在逻辑上允许吗我的建议是不允许。同一个答案填在同一道题的两个空里从业务上讲很少有这种需求。如果真出现渲染阶段会出现两个控件绑定同一个ID数据会互相覆盖。在做解析时如果是做给其他人用的组件我建议在初始化时检测一下重复ID直接报错提示开发者。第四种段落里有不在配置中的占位符。比如配置里只定义了空1和空2题面里写了个{{3}}。按照我3.1小节的实现这个{{3}}不会被特殊处理作为普通文本显示。这个行为是安全的。3.3 双版本解析逻辑的差异点纯JS版本和Vue版本的解析逻辑顺序几乎一样都是从“解析片段”到“渲染片段”。区别在于渲染阶段的技术选型纯JS版本用document.createElement动态创建DOM节点再通过appendChild插入容器。Vue版本使用computed计算属性缓存解析结果模板里用v-for循环片段数组v-if判断type来渲染不同控件。有一个性能上的差别值得说明纯JS版本每次重新渲染都会销毁旧DOM再创建新DOM对于填空题这种几十个控件的规模完全没压力但如果做成上千个空位的题库展示就要考虑用文档碎片DocumentFragment减少回流。Vue版本的DOM Diff会把更新范围控制在真正变化的节点上天然更高效。4. 纯JS版本完整实现不依赖框架也能跑4.1 初始化入口与整体架构纯JS版本我把功能封装成一个类ClozeFill。对外提供三个核心方法init初始化渲染、getAnswers获取当前所有答案、setAnswers回显答案。class ClozeFill { /** * param {HTMLElement|string} container 容器DOM或选择器 * param {Object} options 配置项 * param {Object} options.question 题目配置 */ constructor(container, options) { this.container typeof container string ? document.querySelector(container) : container; this.question options.question || {}; this.answers options.answers || {}; this.parsedParts []; this.init(); } init() { const blanks this.question.blanks || []; const blankIds blanks.map(item String(item.id)); // 1. 解析题面 this.parsedParts parseContent(this.question.content, blankIds); // 2. 渲染 this.render(); } }类设计遵循“初始化做两件事解析和渲染”。这样如果后续需要刷新题面只需要重新调用init就可以完全重置。4.2 渲染输入框与下拉框渲染阶段我使用DocumentFragment做性能优化。因为一个段落里可能十几个填空位每次都appendChild会让浏览器多次计算样式和布局。用文档碎片统一组装后再一次性插入能减少回流次数。render() { this.container.innerHTML ; const fragment document.createDocumentFragment(); this.parsedParts.forEach(part { if (part.type text) { const span document.createElement(span); span.textContent part.content; fragment.appendChild(span); return; } const blank this.question.blanks.find( item String(item.id) part.id ); const wrap document.createElement(span); wrap.className cloze-blank-wrap; // 根据 type 创建不同控件 if (blank.type input || blank.type both) { const input document.createElement(input); input.type text; input.className cloze-input; input.dataset.blankId blank.id; input.value this.answers[String(blank.id)] || ; wrap.appendChild(input); } if (blank.type select || blank.type both) { const select document.createElement(select); select.className cloze-select; select.dataset.blankId blank.id; // 默认选项 const placeholder document.createElement(option); placeholder.value ; placeholder.textContent 请选择; placeholder.disabled true; select.appendChild(placeholder); // 候选词 (blank.options || []).forEach(opt { const option document.createElement(option); option.value opt; option.textContent opt; select.appendChild(option); }); select.value this.answers[String(blank.id)] || ; wrap.appendChild(select); } // 候选词点击填入对应标题里的“可勾选” if (blank.type click || blank.type both) { const optionBox document.createElement(div); optionBox.className cloze-options; (blank.options || []).forEach(opt { const btn document.createElement(button); btn.type button; btn.textContent opt; btn.dataset.opt opt; btn.addEventListener(click, () { const input wrap.querySelector(input); const select wrap.querySelector(select); if (input) { input.value opt; } if (select) { select.value opt; } // 触发自定义事件方便外部监听 this.container.dispatchEvent(new CustomEvent(cloze:change, { detail: { blankId: blank.id, value: opt } })); }); optionBox.appendChild(btn); }); wrap.appendChild(optionBox); } fragment.appendChild(wrap); }); this.container.appendChild(fragment); }这里有一个细节值得注意在both模式下我会同时渲染输入框和候选词按钮。点击候选词会自动把值填入输入框同时输入框也支持手动修改。这正好实现了标题里的“可勾选并输入”。4.3 getAnswers一次遍历收集所有答案收集答案的逻辑很简单遍历容器内所有带>getAnswers() { const result {}; const controls this.container.querySelectorAll([data-blank-id]); controls.forEach(control { // 同一个blankId可能有input和select用id直接覆盖 result[control.dataset.blankId] control.value.trim(); }); return result; }trim很重要。用户可能在输入框里敲了空格对答案判断来说前后空格应该被忽略。这个细节在后端匹配答案时能省掉很多问题。4.4 setAnswers回显的外部入口回显就是给类提供一个批量设置答案的方法设置完重新渲染setAnswers(answers) { this.answers { ...answers }; this.render(); }这里用展开运算符复制一份数据避免外部修改这个对象时影响内部状态。重新渲染后之前填的内容会清空换成新传入的答案。用户如果在页面上已经填了一部分调用这个方法会把旧答案覆盖掉所以在真实项目里这个方法一般只在初始化阶段调用。5. Vue版本组件化改造与体验增强5.1 组件接口props、emits、expose 三件事Vue版本我封装成一个单文件组件ClozeFill.vue。组件接口设计是重头戏先定清楚再写实现props接收content题面文本、blanks填空配置、initAnswers回显的初始答案。emits对外发送change事件答案变化、save事件保存按钮点击。expose暴露getAnswers和setAnswers方法方便父组件通过ref调用。关于答案数据的管理我特别强调一下不要在组件内部直接修改props里的对象。Vue的Props是单向数据流直接改对象属性虽然能跑但会让数据流变得不可控。正确方式是内部维护一个answersMap响应式对象所有变更走它。const props defineProps({ content: { type: String, required: true }, blanks: { type: Array, default: () [] }, initAnswers: { type: Object, default: () ({}) } }); const emit defineEmits([change, save]); const answersMap ref({});5.2 computed解析与v-model双向绑定解析逻辑放进computed好处是当content改变时片段数组会自动重新计算模板同步更新。template div classcloze-container :style{ whiteSpace: pre-wrap } template v-for(part, index) in parseParts :keypart.type - (part.id || index) span v-ifpart.type text{{ part.content }}/span span v-else classblank-wrap input v-ifblankType(part.id) input || blankType(part.id) both v-modelanswersMap[part.id] classcloze-input :data-blank-idpart.id inputhandleChange(part.id) keydown.enter.preventfocusNext(part.id) / select v-else-ifblankType(part.id) select v-modelanswersMap[part.id] classcloze-select :data-blank-idpart.id changehandleChange(part.id) option value disabled请选择/option option v-foropt in blankOptions(part.id) :keyopt :valueopt {{ opt }}/option /select div v-ifblankType(part.id) click || blankType(part.id) both classcloze-options button v-foropt in blankOptions(part.id) :keyopt typebutton classcloze-option :class{ active: answersMap[part.id] opt } clickchooseOption(part.id, opt) {{ opt }}/button /div /span /template /div /template关于v-modelanswersMap[part.id]这是Vue 3里一个“小甜点”用法。answersMap是ref({})包装的响应式对象通过索引访问动态属性也能被Vue的响应式系统追踪。这在Vue 3的Proxy机制下完全可用不需要像Vue 2那样额外调用$set。5.3 键盘Enter/Tab切换对标真实答题体验真实在线考试系统里学生填完一个空按回车或Tab应该自动跳到下一个空。这个体验在Vue版本里实现起来很顺手。const parsedParts computed(() { const blankIds props.blanks.map(item String(item.id)); return parseContent(props.content, blankIds); }); // 获取题目中所有填空位的出现顺序 const blankSeq computed(() { return parsedParts.value .filter(part part.type blank) .map(part part.id); }); function focusNext(currentId) { const index blankSeq.value.indexOf(currentId); if (index -1 || index blankSeq.value.length - 1) return; const nextId blankSeq.value[index 1]; const el containerRef.value?.querySelector([data-blank-id${nextId}]); if (el) el.focus(); }containerRef是模板根节点的 ref通过querySelector找到下一个控件并调用focus()。这里注意select控件也有focus()方法所以两种控件可以共用这套逻辑。5.4 组件与父组件的通信暴露方法和事件父组件拿到组件实例后怎么获取答案最优雅的方式是组件暴露getAnswers方法。defineExpose({ getAnswers: () ({ ...answersMap.value }), setAnswers, reset });父组件用法示例template ClozeFill refclozeRef :contentquestion.content :blanksquestion.blanks :init-answerssavedAnswers savehandleSave / button clicksubmit提交答案/button /template script setup function submit() { const answers clozeRef.value.getAnswers(); // 交给后端 } /script这样父组件完全不需要关心填空题内部的DOM和状态管理只通过暴露出来的方法做交互符合组件化设计的核心思想高内聚低耦合。6. 保存与回显的数据链路6.1 保存的数据格式与后端对接保存数据时建议把题目的元信息和答案分开。一次提交的数据可能是这样{ questionId: 10086, courseId: 202, answers: { 1: black, 2: red }, answerTime: 86 // 答题用时秒 }后端只需要原样存下answers字段回显时原样返回。如果后端用的是MySQL的JSON字段或者MongoDB这类文档数据库这个格式直接就能存不需要额外的关联表。6.2 回显的时机问题异步数据可能晚于组件挂载回显数据有一个典型时序问题页面加载组件后后端数据才返回。如果组件只在mounted里读取一次initAnswers之后数据到达就没反应了。解决方法是监听initAnswers的变化watch( () props.initAnswers, (val) { if (!val) return; Object.keys(val).forEach(key { answersMap.value[key] val[key]; }); }, { immediate: true, deep: true } );immediate: true保证首次渲染时就能回填初始答案deep: true保证数据深层变化也能被感知。但这里有个坑如果父组件传入的initAnswers和用户正在编辑的answersMap是同一个对象或者共享引用watch触发时会把页面上的新答案覆盖回旧值。我在真实项目里就踩过这个坑后面第7.4小节会专门展开。6.3 提交前的数据校验与兜底提交前至少要做一个校验必填的空是否都填了。这在组件内部可以实现function validate() { const missing []; props.blanks.forEach(blank { const val answersMap.value[String(blank.id)]; if (!val || !val.trim()) { missing.push(blank.id); } }); return { valid: missing.length 0, missing }; }返回的missing数组能帮助前端精确定位哪些空没填方便在UI上高亮提示。这块逻辑看起来简单但等到真上线面对几百个学生同时答题时这个提示能减少大量的客服咨询。7. 常见问题与排查技巧实录7.1 空格和换行被浏览器吃掉这是最隐蔽的问题。你输入的内容明明是“The cat sat”有两个空格渲染到HTML页面上却变成了一个空格因为HTML默认会合并连续空白字符。段落里的换行同理在HTML中会被当作一个普通空格。解决方法是给容器加上white-space: pre-wrap样式.cloze-container { white-space: pre-wrap; line-height: 2; }这样题面中的换行、缩进、连续空格都会原样保留。Vue版本里我直接在模板行内写了这个style纯JS版本里在CSS文件里加即可。7.2 正则解析被特殊字符干扰动态构造正则时如果填空ID不是纯数字比如业务上用了字符串IDblank_a、blank_b直接拼进正则模板里可能出现问题。ID里有圆括号、加点号等正则元字符时必须转义。function escapeRegExp(str) { return str.replace(/[.*?^${}()|[\]\\]/g, \\$); }把这个函数应用到blankIds.join(|)的每个ID上就能保证ID再奇怪也能正确匹配。虽然数字ID基本不会踩这个坑但写通用组件时这个细节值得保留。7.3 Vue列表渲染的Key问题模板里我用了:keypart.type - (part.id || index)。这个key的写法有讲究文本片段用text-0、text-1这样的序号作为key填空片段用blank-1、blank-2这样的ID作为key。如果用纯index当key在做动态增删填空位时Vue的Diff算法可能会复用错误的DOM节点导致输入框里的内容串位。用ID做key可以最大程度保证每个填空位和对应DOM的状态一致。7.4 异步回显数据导致覆盖用户正在编辑的答案这是一个非常真实的场景学生正在填第5个空此时后端返回了之前保存的答案如果initAnswers的watch没有做好防护第5空的答案会被强制覆盖。我的处理方式是加一个标记只有第一次接收initAnswers时才执行回填后续变化忽略。let initialized false; watch( () props.initAnswers, (val) { if (!val || initialized) return; initialized true; Object.keys(val).forEach(key { answersMap.value[key] val[key]; }); }, { immediate: true, deep: true } );如果业务上确实需要“外部修改initAnswers时就同步更新”建议改成显式调用setAnswers方法而不是隐式watch这样组件行为是可控的不会出现“我自己都不知道什么时候被覆盖”的诡异Bug。7.5 输入框宽度自适应别让长答案撑破布局英语单词长短不一输入框宽度固定会很不协调。我用的方案是给输入框设置一个基础宽度再用ch单位微调.cloze-input { width: 10ch; min-width: 60px; text-align: center; border: none; border-bottom: 1px solid #aaa; outline: none; background: transparent; } .cloze-input:focus { border-bottom-color: #409eff; }ch单位按字符数计算宽度英文场景下非常直觉。用下划线样式的输入框在试卷场景里的观感比普通文本框自然很多。用户点击输入框时聚焦样式变化也方便答题时快速定位当前正在填的空。7.6 候选项点击后状态高亮勾选模式里用户点击了一个选项我一般会给这个按钮加active高亮这样用户在屏幕上扫一眼就能看出自己选了哪个词。同时要允许“再次点击取消”如果用户选错了再点一次同一个选项应该把答案清空。逻辑不复杂但能减少误操作带来的挫败感。这里贴一下核心实现function chooseOption(blankId, opt) { if (answersMap.value[blankId] opt) { answersMap.value[blankId] ; } else { answersMap.value[blankId] opt; } handleChange(blankId); }这个交互细节做与不做用户的体验差距很明显。很多填空题组件只做“点击覆盖”不支持取消实际使用中会被吐槽无数遍。最后再分享一个小技巧。无论你用的是纯JS版本还是Vue版本在开发时我都建议给填空位加一个“空位编号”的视觉标记比如在输入框右上角显示一个小小的序号。这不仅能帮助开发者调试时定位数据对应的位置用户在做题时也能更清楚地知道这是第几个空。尤其是一篇文章里超过10个空的时候序号对答题节奏的辅助作用很大。我第一版没加这个编号测试时自己数空都容易数错加上之后整个体验就顺了。你自己动手做的时候这个细节可以直接加上成本极低收益却很实在。
返回列表