最近在给一个在线教学后台开发题目组件其中一项需求就是“完形填空”加“填空题”的混合题型在段落里任意位置挖若干个空每个空既能做成勾选式给几个选项选一个比如完形填空那种ABCD也能做成输入式用户直接往输入框里敲答案作答完成之后退出页面下次进来还要能恢复上一次的作答状态。这个需求初看不算难但真正动手做下来我把它拆成了“数据结构设计、解析渲染、双版本交互、保存回显”四层每一层都有不少容易踩坑的细节。这篇文章就把我的完整实现思路和代码分享出来里面有详细注释你可以直接拿去改造成自己的组件。1. 从“挖空填答案”到“段落数据化”这个需求真正的核心难点之所以说这个需求“看起来简单、想清楚难”是因为绝大多数人听到“填空题”的第一反应都是找一段文本、把某个词替换成输入框然后完事。但真正放到在线教育、问卷系统、考试系统这种场景里事情就没那么简单了。1.1 需求还原两种填空形态和一个回显要求先把需求拆清楚。标题里提到的功能点落到真实业务上大致是这三件事段落中的任意位置可以挖空空格数量不固定位置完全由出题人控制。每个空有两种作答形态勾选式从选项列表中选一个和输入式手动输入文本。用户作答后数据要能保存下来重新打开页面时能回显上一次的答案。关键就在“任意位置”“多个空”“保存回显”这三个词上。如果只是写一个死页面的填空demo确实十分钟就能完成但一旦要动态出题、动态渲染、动态保存就必须先回答一个根本问题段落文本和填空位在代码里到底怎么表示1.2 最初直接替换字符串的方案为什么不行我见过不少同学的实现方式是这样一个套路把题目文本存成带特殊标记的字符串然后用正则或replace把标记替换成input标签再用v-html渲染到页面上。const text 今天天气____适合____。; text.replace(/____/g, input classblank-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 classcloze-blank !-- 勾选模式给出一组选项点击选择 -- span v-ifmode select classcloze-options label v-foropt in options :keyopt classcloze-option :class{ is-active: userAnswer opt } input typeradio :nameblank_ blankIndex :valueopt :checkeduserAnswer opt change$emit(change, { index: blankIndex, value: opt }) / {{ opt }} /label /span !-- 输入模式直接输入答案 -- input v-else classcloze-input typetext :valueuserAnswer :placeholder第 blankIndex 空 inputonInput / /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个或页面空间不足时才考虑。输入模式下我用了:valueuserAnswer加input事件而不是直接在子组件里v-modeluserAnswer。这是有意为之的下面单独展开说。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 classcloze-paper p classpassage template v-for(node, idx) in nodes span v-ifnode.type text :keyidx classpassage-text {{ node.content }} /span ClozeBlank v-else :keyblank- node.index :blank-indexnode.index :modeblankMap[node.index] ? blankMap[node.index].mode : input :optionsblankMap[node.index] ? blankMap[node.index].options : [] :user-answerblankMap[node.index] ? blankMap[node.index].userAnswer : changeonBlankChange / /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属性比如idblank-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环境同样适用。