最近在给一个在线教学后台开发题目组件,其中一项需求就是“完形填空”加“填空题”的混合题型:在段落里任意位置挖若干个空,每个空既能做成勾选式(给几个选项选一个,比如完形填空那种ABCD),也能做成输入式(用户直接往输入框里敲答案),作答完成之后退出页面,下次进来还要能恢复上一次的作答状态。这个需求初看不算难,但真正动手做下来,我把它拆成了“数据结构设计、解析渲染、双版本交互、保存回显”四层,每一层都有不少容易踩坑的细节。这篇文章就把我的完整实现思路和代码分享出来,里面有详细注释,你可以直接拿去改造成自己的组件。
1. 从“挖空填答案”到“段落数据化”:这个需求真正的核心难点
之所以说这个需求“看起来简单、想清楚难”,是因为绝大多数人听到“填空题”的第一反应,都是找一段文本、把某个词替换成输入框,然后完事。但真正放到在线教育、问卷系统、考试系统这种场景里,事情就没那么简单了。
1.1 需求还原:两种填空形态和一个回显要求
先把需求拆清楚。标题里提到的功能点,落到真实业务上大致是这三件事:
- 段落中的任意位置可以挖空,空格数量不固定,位置完全由出题人控制。
- 每个空有两种作答形态:勾选式(从选项列表中选一个)和输入式(手动输入文本)。
- 用户作答后,数据要能保存下来,重新打开页面时能回显上一次的答案。
关键就在“任意位置”“多个空”“保存回显”这三个词上。如果只是写一个死页面的填空demo,确实十分钟就能完成;但一旦要动态出题、动态渲染、动态保存,就必须先回答一个根本问题:段落文本和填空位,在代码里到底怎么表示?
1.2 最初直接替换字符串的方案,为什么不行
我见过不少同学的实现方式是这样一个套路:把题目文本存成带特殊标记的字符串,然后用正则或replace把标记替换成<input>标签,再用v-html渲染到页面上。
const text = '今天天气____,适合____。'; text.replace(/____/g, '<input class="blank-input" />');这个方案在展示环节确实能跑通,看起来很直接。但越写到后面越难受,主要卡在三个点上:
第一,v-html直接渲染拼接出来的HTML,存在XSS风险。用户如果在输入框里填了<script>或<img onerror=...>这类内容,重新回显时这些字符串会被当成HTML解析,轻则页面渲染错乱,重则直接注入脚本。第二,答案数据是完全分散的,很难收集。输入框的值都在DOM上,想把每一空的答案整理成结构化数据提交给后端,得用querySelectorAll去DOM里一个个取,取回来还得自己排序、对应空格编号,麻烦不说,还容易出bug。第三,回显困难。刷新页面后,v-html会重新生成DOM,你之前填进去的值全部丢失,需要额外做一步“把保存的答案重新塞回DOM”的操作,而这个操作的可靠性能不能保证,完全看运气。
所以我的结论是:基于字符串拼接和v-html的路线,在“自由控制填空位置+保存回显”这个需求组合下,走不通。需要换一个更扎实的思路。
1.3 把段落抽象成节点数组:这是后面所有代码的地基
正确思路是把一段文本当成一段“数据”来对待,而不是当成“字符串”来拼接。具体来说,把段落拆成一个节点数组,每个节点只有两种类型:
text节点:表示一段普通文本。blank节点:表示一个填空位,记录它对应第几个空。
渲染的时候,用v-for遍历这个数组:碰到text节点就渲染成<span>,碰到blank节点就渲染成输入框或选项组。这样文本和填空位的摆放位置,完全由节点数组的顺序决定,天然就支持“任意位置”和“多个空”。
同时,每个填空位对应的配置信息(选项、正确答案、用户作答结果)可以单独存成一个对象。节点数组只负责“这个位置有个空”,配置对象负责“这个空长什么样、用户填了什么”,二者通过空位编号关联。后面做保存回显、自动批改,都基于这个配置对象来操作,非常顺手。
2. 数据模型设计:一个占位符约定,支撑任意位置和多个空
数据结构是整个填空功能的地基。地基打好了,后面的组件渲染、保存回显都会很顺;地基没打好,后面大概率翻车。
2.1 模板字符串的占位符约定
我采用的方案是:出题人编辑题目时,直接在文本里写{{1}}、{{2}}这种占位符。{{1}}代表“这里是第1个空”,{{2}}代表“这里是第2个空”,以此类推。
之所以选双花括号加数字,是因为这个约定有三个好处:一是可读性强,出题人一眼就能看出第几个空在哪里;二是双花括号在中文文本里几乎不会和正常文字冲突;三是用正则匹配起来非常稳定,不容易误伤正文。
这里要特别注意一点:{{}}只是一个约定,不是框架层面的强制。你完全可以用【1】、&1;、${1}等等任何你喜欢的标记,只要解析函数和编辑约定保持一致就行。我之所以推荐双花括号,是因为它和Vue模板语法、常见的模板引擎习惯一致,团队协作时理解成本最低。
2.2 解析函数:把模板切成text与blank节点数组
解析逻辑是纯JavaScript,不依赖Vue,这也是标题里“js简单实现”的底气。核心思路是用正则全局匹配占位符,把匹配到的位置当作切分点,把原始文本切成若干段。
/** * 解析填空模板字符串 * 输入示例: "今天天气{{1}},适合{{2}},记得带{{3}}。" * 输出示例: * [ * { type: 'text', content: '今天天气' }, * { type: 'blank', index: 1 }, * { type: 'text', content: ',适合' }, * { type: 'blank', index: 2 }, * { type: 'text', content: ',记得带' }, * { type: 'blank', index: 3 }, * { type: 'text', content: '。' } * ] */ export function parseClozeTemplate(template) { const nodes = []; // 兼容 {{1}} 和 {{ 1 }} 这种带空格写法 const regex = /\{\{\s*(\d+)\s*\}\}/g; let lastIndex = 0; let match; while ((match = regex.exec(template)) !== null) { // 占位符之前的普通文本 const text = template.slice(lastIndex, match.index); if (text) { nodes.push({ type: 'text', content: text }); } // 占位符本身转成 blank 节点 nodes.push({ type: 'blank', index: Number(match[1]) }); lastIndex = match.index + match[0].length; } // 处理最后一段文本 if (lastIndex < template.length) { nodes.push({ type: 'text', content: template.slice(lastIndex) }); } return nodes; }这段代码的逻辑很简单:从头到尾扫一遍字符串,遇到一个占位符,就把当前指针到占位符之间这段文本收进数组,再把占位符本身也收进数组,然后移动指针继续扫。扫完之后,如果开头或结尾还有纯文本,也一并收进去。
这段解析函数不依赖任何框架,你放到任何项目里都能用。而且因为node数组是纯数据,后续想加“加粗重点词”“给文本打标记”这类功能,都能在同一个数组上扩展。
2.3 blankMap:每个空位自己的“档案”
光有节点数组还不够。节点数组只是告诉你“第1个位置有个空”,但这个空是勾选还是输入、选项有哪些、用户填了什么,需要另一份数据结构来存。我管它叫blankMap,它的结构大致长这样:
{ // 空位编号: 配置信息 1: { mode: 'select', // 'select' 表示勾选模式,'input' 表示输入模式 options: ['晴朗', '阴天', '下雨', '刮风'], // 勾选模式的选项 answer: '晴朗', // 正确答案,可选,用于自动批改 userAnswer: '' // 用户作答结果,回显时就填这个 }, 2: { mode: 'input', answer: '散步', userAnswer: '' }, 3: { mode: 'select', options: ['伞', '手机', '水杯', '钥匙'], answer: '伞', userAnswer: '' } }这里有一个设计上的关键点:nodes数组和blankMap是解耦的。nodes只负责渲染骨架,blankMap只负责装状态数据,二者通过空位编号(index)关联。这样做的直接好处是,你可以在模板里轻松调整填空位置,比如把{{2}}和{{1}}换一下位置,只要blankMap里的配置跟着编号走,页面渲染和保存回显都不需要改逻辑。
2.4 自由控制位置与多个空的边界情况
用占位符方案之后,“段落中可加多个填空”“可自由控制填空位置”这两个需求就被极大简化了。想加空,就在模板字符串中插入一个{{编号}};想调整位置,就移动占位符的位置;想让某个空重复出现,比如完形填空里不同两处填同一个答案,就复用同一个编号,这样两个位置会共用同一个userAnswer,不过这种情况在真实题目中比较少见,我一般不建议这么用。
不过这里有好几个边界情况需要注意:
一是占位符编号可以不连续。比如用户只写了{{1}}和{{3}},跳过了{{2}}也没关系,因为blankMap里没有2这个key,解析出来的blank节点会用node.index去blankMap里查配置,查不到就跳过或给个警告即可。二是如果一个空位在模板里写了,但在blankMap里忘记配了,运行时会报错,所以在真实项目里我会加一行防御逻辑:渲染前遍历nodes,把所有blank节点的index都去blankMap里查一遍,查不到的补一个默认配置。三是占位符里带了空格,比如{{ 1 }},正则里的\s*就是为了兜住这种情况。
还有一个容易被忽略的点:用v-for渲染文本节点时,文本内容本身就是Vue的插值表达式,天然是安全的文本节点,不会把<script>解析成HTML。这也是我坚持不用v-html的原因之一。
3. 双版本组件实现:勾选式完形填空与输入式填空题的统一抽象
数据结构设计好了,接下来就是渲染层。标题里说的“双版本”,指的就是同一个空位既能渲染成勾选式,也能渲染成输入式。实现上,我拆了一个子组件ClozeBlank,一份代码处理两种模式。
3.1 ClozeBlank子组件:一份代码处理两种模式
子组件的核心逻辑是:接收一个mode属性,mode是select就渲染选项组,mode是input就渲染输入框,其余逻辑完全共用。
<template> <span class="cloze-blank"> <!-- 勾选模式:给出一组选项,点击选择 --> <span v-if="mode === 'select'" class="cloze-options"> <label v-for="opt in options" :key="opt" class="cloze-option" :class="{ 'is-active': userAnswer === opt }" > <input type="radio" :name="'blank_' + blankIndex" :value="opt" :checked="userAnswer === opt" @change="$emit('change', { index: blankIndex, value: opt })" /> {{ opt }} </label> </span> <!-- 输入模式:直接输入答案 --> <input v-else class="cloze-input" type="text" :value="userAnswer" :placeholder="'第' + blankIndex + '空'" @input="onInput" /> </span> </template> <script> export default { name: 'ClozeBlank', props: { blankIndex: { type: Number, required: true }, mode: { type: String, default: 'input' }, options: { type: Array, default: () => [] }, userAnswer: { type: String, default: '' } }, methods: { onInput(e) { this.$emit('change', { index: this.blankIndex, value: e.target.value }); } } }; </script>勾选模式为什么用radio而不是select下拉?因为在完形填空这种场景里,选项一般就3到5个,全部展示出来让用户直接点选,交互效率是最高的。这里用label包裹input还有一个好处:用户不用精确点中小圆点,点整个选项文字区域都能触发选择,移动端上也友好很多。至于什么时候用select下拉,我通常是选项超过5个或页面空间不足时才考虑。
输入模式下,我用了:value="userAnswer"加@input事件,而不是直接在子组件里v-model="userAnswer"。这是有意为之的,下面单独展开说。
3.2 为什么不用v-model,而是props加emit
不少初学者写这种组件,会直接在子组件里给userAnswer加v-model,让子组件自己改自己的数据。这个写法在小demo里没问题,但放在真实项目里会埋雷。
原因有两层。第一层是Vue的props单向数据流约束:子组件不应该直接修改props的值,否则数据流会乱,尤其在组件复用、状态提升的场景下,匿名修改props会让父组件完全不受控。第二层是保存回显的需求场景:父组件需要知道每一次作答变化,然后立刻把答案同步给数据层保存;如果答案值在子组件内部自己改,父组件就很难感知变化,回显时还得想办法强制刷新子组件。
所以我的写法是:子组件只负责“把这个值展示出来”,用户每次输入或选择时,把变化通过$emit('change', { index, value })抛给父组件,父组件统一更新blankMap[ index ].userAnswer。这样数据永远只有一个来源,保存和回显都稳。
3.3 父组件如何渲染整道题
父组件这边的核心逻辑是:拿到模板字符串,调用解析函数得到nodes数组,然后用v-for遍历渲染。
<template> <div class="cloze-paper"> <p class="passage"> <template v-for="(node, idx) in nodes"> <span v-if="node.type === 'text'" :key="idx" class="passage-text"> {{ node.content }} </span> <ClozeBlank v-else :key="'blank-' + node.index" :blank-index="node.index" :mode="blankMap[node.index] ? blankMap[node.index].mode : 'input'" :options="blankMap[node.index] ? blankMap[node.index].options : []" :user-answer="blankMap[node.index] ? blankMap[node.index].userAnswer : ''" @change="onBlankChange" /> </template> </p> </div> </template> <script> import ClozeBlank from './ClozeBlank.vue'; import { parseClozeTemplate } from './clozeParse.js'; export default { name: 'ClozePaper', components: { ClozeBlank }, props: { template: { type: String, required: true }, blankMap: { type: Object, required: true } }, data() { return { nodes: [] }; }, created() { // 解析模板字符串,得到节点数组 this.nodes = parseClozeTemplate(this.template); }, methods: { onBlankChange({ index, value }) { // 统一在这里更新用户答案 if (this.blankMap[index]) { this.blankMap[index].userAnswer = value; } this.$emit('save', { index, value }); } } }; </script>注意看,我在解析和渲染之间做了完全分离。模板字符串是“题目内容”,blankMap是“题目配置”,两者通过template的占位符关联。这样出题人想新出一道题,只需要准备一份字符串和一份配置,页面组件完全不用改动。
这里还有一个细节:我给blank节点加的key是'blank-' + node.index,而不是idx。因为如果一个空位在模板里出现多次(虽然不太推荐),用node.index当key能保证Vue能正确识别是同一个逻辑空位;如果直接用idx,重排或插入操作时可能会触发多余的组件复用问题。
4. 保存与回显:从本地存储到页面恢复的完整链路与踩坑记录
保存回显是整个功能里最容易被忽略、又最容易出bug的部分。我实际测试的时候,前几次总会在回显环节翻车,所以这部分单独拿出来说。
4.1 保存的数据格式与保存时机
保存的数据格式很简单,就是一个对象:key是空位编号,value是用户答案字符串。
{ 1: '晴朗', 2: '散步', 3: '伞' }这里我特意强调“用对象而不是数组”。因为数组的索引是连续的,如果某道题中间某空位被删除或调整,索引就会错位,回显时答案就可能串到别的空位上。用对象加编号key的话,key是多少就填到编号为多少的空位,中间缺号完全不影响。
至于保存时机,我一般两种策略结合:一是每次@change触发时立刻保存,这样用户突然关掉页面也不怕丢数据,但要注意做防抖,避免用户打字过程中频繁写入localStorage;二是在组件beforeDestroy或页面离开前再统一保存一次,兜底。如果项目有后端,这个保存动作就换成请求接口,数据格式保持一致。
4.2 回显示例代码
回显的过程分三步:读取存储数据、解析对象、把答案填回blankMap.userAnswer。
// 从 localStorage 读取答案 export function loadAnswers(storageKey) { try { const raw = localStorage.getItem(storageKey); if (!raw) return null; const data = JSON.parse(raw); return data && typeof data === 'object' ? data : null; } catch (e) { console.warn('读取作答数据失败', e); return null; } } // 回显入口 function restoreAnswers(blankMap, storageKey) { const saved = loadAnswers(storageKey); if (!saved) return; Object.keys(saved).forEach((key) => { if (blankMap[key]) { blankMap[key].userAnswer = saved[key]; } }); }回显代码最核心的原理是:因为ClozeBlank子组件的显示值完全由props.userAnswer驱动,所以只要父组件在created阶段把blankMap里的userAnswer都填好,子组件渲染时就会自动显示出来,不需要额外操作DOM。
4.3 踩坑记录:回显不生效的常见原因
我在调试过程中遇到过几个典型问题,这里逐一记录下来,你直接照着避坑就行。
第一个坑:子组件内部用data复制了props副本。很多同学写子组件时,喜欢把props的值拷贝到一个data字段里,然后操作这个副本。这在普通场景下没问题,但会导致一个非常隐蔽的回显bug:刷新页面时,父组件已经把userAnswer回填好了,但子组件已经用data初始化过一个空字符串副本,props更新时副本不会自动同步,于是输入框看起来是空的。解决方案就是坚持:value加emit的单向数据流,不要搞副本。
第二个坑:存储数据里有脏数据。比如旧版本存了一个{ 5: '' },但当前题目的blankMap里根本没有5号空,回显时如果直接blankMap[key] = value,就会把一个不存在的空位加进去,渲染时可能出现多余节点。我在回显代码里做了if (blankMap[key])的判断,就是为了过滤掉这种情况。
第三个坑:每次@change都保存,但不加防抖。在输入模式下,用户每敲一个字母就写一次localStorage,虽然现代浏览器性能没问题,但频繁序列化大对象仍然会造成不必要的开销。我习惯用一个300毫秒的防抖函数,或者只在失焦和组件销毁时保存一次。
第四个坑:回显时把答案当成v-html渲染了。有同学为了省事,把整段模板字符串和用户答案拼接成一段HTML,然后v-html输出,结果用户输入了<b>这种标签,回显时其中的内容直接变成了加粗文本。这就是我前面反复强调要避免v-html的原因。
第五个坑:存储的key相互冲突。如果后台有多个题目,或者同一个用户在不同会话中做了多道题,建议每个题保存在独立的key下,比如'cloze_paper_' + paperId,不要全塞进同一个key里互相覆盖。
5. 扩展玩法:自动批改、答题卡联动和多题型混合
双版本的填空功能跑通之后,往上叠加功能就非常顺利了。这一章讲讲我在真实项目中用得最多的几个扩展方向。
5.1 自动批改逻辑
如果题目设计的是有标准答案的完形填空或填空题,可以在blankMap里给每个空配上answer字段,然后做自动比对。
/** * 自动批改 * 返回结果示例: { 1: true, 2: false, 3: true } */ export function checkAnswers(blankMap) { const result = {}; Object.keys(blankMap).forEach((key) => { const blank = blankMap[key]; const userAnswer = (blank.userAnswer || '').trim(); const correctAnswer = (blank.answer || '').trim(); // 输入模式下可以做宽容处理,比如忽略大小写 result[key] = userAnswer.toLowerCase() === correctAnswer.toLowerCase(); }); return result; }实际项目里,我的批改逻辑稍微复杂一点:勾选模式用完全匹配,输入模式默认忽略首尾空格和大小写,再开放一个“模糊匹配”开关,支持answerConfig里配置同义词列表,比如“下雨”和“降雨”都算对。这些细节可以根据业务需求自由扩展。
5.2 答题卡与答题状态统计
因为有blankMap这个统一的数据源,统计当前答了几题、还有几题没答,是一行代码的事。
const totalBlanks = Object.keys(blankMap).length; const answeredBlanks = Object.keys(blankMap).filter( (key) => blankMap[key].userAnswer.trim() !== '' ).length;基于这个统计,可以很自然地做出一个答题卡侧边栏:列出所有空位编号,已答的显示高亮、未答的显示灰色,点击编号可以滚动到对应题目位置。因为每个空位在nodes数组里都是独立节点,你给每个blank节点加一个id属性,比如id="blank-1",然后用document.getElementById('blank-1').scrollIntoView()就能实现跳转定位。
5.3 与服务端对接及样式定制
如果要把作答结果提交给后端,我建议提交的数据格式就是答案对象本身,也就是{ 1: '晴朗', 2: '散步', 3: '伞' }这种纯结构化JSON,而不是一段拼接好的HTML字符串。这样服务端存储起来干净,后续做统计分析、跨端展示都很容易。渲染的时候,服务端只需要把模板字符串和blankMap配置返回给前端,前端再走解析渲染流程即可,逻辑保持一致。
样式定制上,我会给cloze-blank、cloze-option、cloze-input这几个类名预留充分的自定义空间。常见的视觉效果是:勾选模式选项选中后背景色高亮加边框高亮;输入模式用一个下划线样式的内联输入框,文字居中。如果你需要“输入框长度跟随答案长度变化”,可以做一个隐形span,把当前答案文本渲染出来,然后让输入框宽度等于这个span的宽度,体验会好不少。
5.4 一个提升体验的小技巧:输入框宽度自适应
这个技巧是我在真实项目里反复调整后觉得最实用的一个。默认input宽度是固定的,如果固定设成100px,答案是两个字时两边留白太多,答案是十个字时又会被截断。我的做法是:在输入框旁边放一个绝对定位的span,它和输入框用同样的字体和字号,把当前答案文本渲染进去,然后用span的offsetWidth动态设置输入框的宽度,同时设置一个最小宽度和一个最大宽度。
function fitInputWidth(value, minWidth = 80, maxWidth = 260) { // 利用 canvas 测量文本宽度,比 DOM 计算更高效 const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); ctx.font = '16px "Microsoft YaHei", sans-serif'; const textWidth = ctx.measureText(value || '').width; return Math.min(Math.max(textWidth + 20, minWidth), maxWidth); }这个实现很轻量,不依赖第三方库,配合输入框的@input事件,每次用户输入时重新计算一次宽度即可。
踩过几次坑之后,我现在做这类带交互的题目组件,最核心的体会就是:数据结构和渲染一定要分离,不要把逻辑建立在DOM上,而是建立在清晰的纯数据之上。文本归文本,配置归配置,节点数组负责位置,blankMap负责状态,二者通过编号关联,后面所有功能都能在这个地基上稳定扩展。这套实现思路不仅适用于Vue,换到React、小程序,甚至纯JavaScript环境,同样适用。