
做 Quill 编辑器相关需求的人几乎都会在某一个具体时刻撞上同一个问题当前光标究竟落在innerHTML字符串的第几个字符上。这个问题听起来很怪因为 Quill 本身提供的getSelection()明明已经返回了{ index, length }为什么还要费劲去算一个 HTML 字符串里的下标答案藏在业务落地的细节里。Quill 的 index 是 Delta 模型下的扁平字符偏移它把整篇文档看成一串纯文本加换行符而innerHTML是 DOM 序列化之后的字符串里面混着标签名、属性、引号、实体转义、空行占位。两者之间没有现成的换算公式getSelection().index直接拿去当innerHTML的下标用结果一定是错位的。这篇内容就是把这个错位彻底讲清楚从需求场景、模型原理、方案选型一路写到完整代码、边界验证和踩坑记录适合已经上手 Quill、正在做批注定位、变量占位符插入、内容快照比对或者导出后二次处理的开发者参考前端基础一般也能跟着一步步复现。1. 需求场景拆解innerHTML 索引到底解决什么问题1.1 三类最典型的落地场景我在实际项目里遇到需要光标所在 innerHTML 索引的场景基本集中在三类。第一类是在光标处插入不可编辑的占位内容。比如某个内容协作系统里编辑区需要支持插入变量标签用户点一下工具栏的插入用户昵称就要在光标位置塞入一个span classvar-tag>function getIndexByClone(quill) { const range quill.selection.getRange(); if (!range) return -1; const probe range.cloneRange(); probe.selectNodeContents(quill.root); probe.setEnd(range.startContainer, range.startOffset); const frag probe.cloneContents(); const holder document.createElement(div); holder.appendChild(frag); return holder.innerHTML.length; }这个写法代码量最少看起来最优雅但它有两个致命问题。第一cloneContents只克隆选区覆盖到的内容跨元素时它会自动补齐结构但空块会被丢掉。比如文档是第一段、空行、第二段光标在第二段开头克隆出来的片段里那个空行段落会变成一个空的p/p甚至直接消失因为空段落在选区里没有实际内容。原本的pbr/p是 13 个字符克隆后变成p/p是 7 个字符直接少 6 个。第二选区起点在文本节点中间时克隆出来的片段会保留父级标签但不会保留光标之后的兄弟节点这本身是对的可问题在于某些浏览器会对不完整的结构做补全补出来的标签长度和原始innerHTML里的不一致。我实测过十来个典型场景这个方案在纯文本单段落时准确率 100%在多段落带空行时准确率掉到六成左右。用来做粗略估计可以用来做插入定位不行。3.2 方案二DOM 序列化模拟推荐这是我认为最稳的方案。核心思想是不去真的序列化而是照着序列化规则边走边数。从quill.root的第一个子节点开始深度优先遍历遇到元素就累加开始标签长度遇到文本就累加转义后长度遇到目标节点就停在指定偏移处立刻返回累计值。这个方案的好处是完全可控每一步长度都是按规范算出来的不存在浏览器补全的意外天然兼容 Quill 1.x 和 2.x 的 DOM 结构差异因为它是实时遍历不预设任何结构遇到空段落、嵌入图片、嵌套行内标签全都是同一套逻辑不需要写特判可以顺便算出任意节点的位置不只是光标代价是代码量相对多一些需要自己维护一份空元素表还要写转义函数。但这份代码写一次就能长期复用我觉得完全值得。3.3 方案三临时标记锚点法思路是往光标位置插入一个特殊标记取innerHTML用indexOf找到标记的位置然后把标记删掉。因为标记本身不占可见宽度也不影响渲染。function getIndexByMarker(quill) { const range quill.selection.getRange(); if (!range) return -1; const marker \u200B; // 零宽空格 const anchor document.createTextNode(marker); const probe range.cloneRange(); probe.collapse(true); probe.insertNode(anchor); const html quill.root.innerHTML; const index html.indexOf(marker); anchor.remove(); return index; }这个方法极其简单准确率也高因为innerHTML里的零宽空格不会被转义indexOf一定命中。但它有三个隐患。隐患一是会触发 Quill 的变更监听。Quill 在root上挂了 MutationObserver你往 DOM 里插一个文本节点它会认为内容变了可能触发text-change事件甚至把零宽空格同步进 Delta。虽然你随后删掉了但事件已经发出业务侧的监听函数可能已经执行了副作用。隐患二是同步时机。Quill 的 DOM 更新是异步批处理的你插入标记后立刻读innerHTML读到的可能是 Quill 尚未处理的中间状态。隐患三是零宽字符污染。如果删除失败比如在try块里抛了异常零宽空格就留在内容里了用户复制粘贴到别处会带上一堆看不见的字符后续做字符串比对时全是脏数据。所以我的建议是这个方案只用于调试和交叉验证生产环境用方案二。第 4 节我会给出一段用标记法验证方案二的测试脚本两者相互印证非常省心。3.4 三方案横向对比与选型建议维度cloneContents 法DOM 序列化模拟临时标记法代码量最少约 10 行中等约 60 行最少约 12 行多段落准确性差空行丢失高高空行处理会少算 6 个字符左右精确精确实体转义依赖浏览器不可控完全可控天然正确副作用无无可能触发 text-change对 Quill 版本差异的容忍度低高中适合场景粗略估算生产环境定位调试校验、语义 HTML 场景选型结论很清楚日常用方案二调试时用方案三做交叉验证方案一可以忘掉。补充一个判断依据如果你的业务里光标位置需要用来做字符串切割再拼接那就必须用方案二因为任何一次少算都会让拼接点落进标签内部产出非法 HTML。如果只是用来做高亮某个位置这类容错性强的展示方案三也够用。4. 完整实现从 getSelection 到 innerHTML 下标4.1 第一步稳定地拿到当前 Range先把取 Range 这一步封装好处理好失焦、无选区、选区在编辑器外这三种情况。function getEditorRange(quill) { // 优先走 Quill 内部 Selection失焦后依然能拿到最后一次选区 try { const internal quill.selection quill.selection.getRange ? quill.selection.getRange() : null; if (internal quill.root.contains(internal.startContainer)) { return internal; } } catch (e) { // 某些版本内部 API 结构不同静默降级 } // 回退到标准选区 API const sel window.getSelection(); if (!sel || sel.rangeCount 0) return null; const range sel.getRangeAt(0); if (!quill.root.contains(range.startContainer)) return null; return range; }这里有个细节值得说quill.selection.getRange()在用户点击工具栏按钮导致编辑器失焦后返回的是最后一次有效选区这个特性非常有用。而window.getSelection()在失焦后可能返回空选区或者返回按钮元素上的选区。所以优先内部 API 是对的但要包一层try因为不同小版本的内部结构可能有微调。4.2 第二步写好转义函数和标签长度函数// 空元素表这些标签序列化后没有结束标签 const VOID_TAGS new Set([ area, base, br, col, embed, hr, img, input, link, meta, param, source, track, wbr ]); // 文本节点转义顺序不能变 必须最先处理 function escapeText(str) { return str .replace(//g, amp;) .replace(/\u00a0/g, nbsp;) .replace(//g, lt;) .replace(//g, gt;); } // 属性值转义注意双引号要转尖括号不用 function escapeAttr(str) { return str .replace(//g, amp;) .replace(/\u00a0/g, nbsp;) .replace(//g, quot;); } // 开始标签长度包含尖括号和结尾的 function openTagLength(el) { let len 1 el.localName.length 1; // 标签名 for (const attr of el.attributes) { len 1 attr.name.length 2 escapeAttr(attr.value).length; // 1 是空格2 是 和 的引号与等号 } return len; } // 结束标签长度空元素返回 0 function closeTagLength(el) { if (VOID_TAGS.has(el.localName)) return 0; return 2 el.localName.length 1; // / 标签名 }openTagLength里1 attr.name.length 2 escapeAttr(attr.value).length的拆解一个空格属性名等号加一对引号共 2 个字符属性值转义后的长度。逐项加起来就是这一段的字符数。这里顺带说一个优化点如果某个属性值是空字符串比如span class按规范输出依然是class长度 9。不要想当然地跳过空属性。4.3 第三步核心遍历函数这是整个方案的心脏。/** * 计算某个 DOM 位置在 root.innerHTML 字符串中的字符下标 * param {HTMLElement} root 序列化的根元素不含自身标签 * param {Node} node Range 的 startContainer * param {number} offset Range 的 startOffset * returns {number} innerHTML 中的下标失败返回 -1 */ function getHtmlIndex(root, node, offset) { if (!root || !node) return -1; if (!root.contains(node) root ! node) return -1; let total 0; let hit false; function outerLength(el) { if (el.nodeType Node.TEXT_NODE) return escapeText(el.data).length; if (el.nodeType ! Node.ELEMENT_NODE) return 0; let len openTagLength(el); for (const child of el.childNodes) len outerLength(child); len closeTagLength(el); return len; } function walk(el) { if (hit) return; if (el.nodeType Node.TEXT_NODE) { if (el node) { total escapeText(el.data.slice(0, offset)).length; hit true; } else { total escapeText(el.data).length; } return; } if (el.nodeType ! Node.ELEMENT_NODE) return; total openTagLength(el); if (el node) { // 光标落在元素节点上offset 是子节点索引 for (let i 0; i offset i el.childNodes.length; i) { total outerLength(el.childNodes[i]); } hit true; return; } for (const child of el.childNodes) { walk(child); if (hit) return; } total closeTagLength(el); } // 从 root 的子节点开始root 自身的标签不计入 innerHTML for (const child of root.childNodes) { walk(child); if (hit) break; } return hit ? total : -1; }这段代码里有三个关键设计值得逐一说清。设计一hit标志控制提前返回。一旦命中的目标节点所有外层递归都要立刻停止并且不能追加结束标签。因为光标在元素内部那个元素的结束标签还没到自然不应该算进去。这一点如果写错每个层级都会多算 4 到 8 个字符跨三层嵌套就偏了 20 多。设计二元素匹配时只累加前置兄弟节点。光标落在元素节点上时offset表示在第几个子节点之前。这时候只需要把0到offset - 1的子节点完整长度加进去然后停。这正好对应p|br/p和pbr|/p两种位置。设计三outerLength单独抽出来。它和walk的区别是outerLength是一次性算完整节点walk是边走边停。分开写比合并写清晰得多也不会互相干扰。4.4 第四步组合成对外接口function getCursorHtmlIndex(quill) { const range getEditorRange(quill); if (!range) return -1; return getHtmlIndex(quill.root, range.startContainer, range.startOffset); } // 使用 const quill new Quill(#editor, { theme: snow }); const pos getCursorHtmlIndex(quill); if (pos 0) { const html quill.root.innerHTML; const before html.slice(0, pos); const after html.slice(pos); const next before span classvar-tag>function verifyHtmlIndex(quill) { const range getEditorRange(quill); if (!range) return { pass: false, reason: no range }; const calculated getHtmlIndex(quill.root, range.startContainer, range.startOffset); const marker \u200B; const anchor document.createTextNode(marker); const probe range.cloneRange(); probe.collapse(true); probe.insertNode(anchor); const actual quill.root.innerHTML.indexOf(marker); anchor.remove(); return { pass: calculated actual, calculated, actual, diff: actual - calculated }; }验证通过的标准是pass true。如果diff是正数说明你少算了如果diff是负数说明多算了。经验上少算通常发生在空元素表漏了某个标签多算通常是属性值里的被误转义了。建议准备的测试场景有这些每一个都覆盖一类边界场景编号内容描述验证要点S1单段落纯文本光标在中间基础正确性S2两个段落光标在第二段开头块级标签和换行符S3三段之间有两个空行光标在第三段空元素的结束标签S4加粗文本内部的光标行内标签嵌套S5文本包含和实体转义长度S6段落中含一张图片光标在图片后自闭合标签和超长属性值S7链接文本内的光标多属性标签S8列表项内的光标Quill 版本的 DOM 结构差异S8 这个场景值得多说两句。Quill 1.x 的无序列表渲染成ulli.../li/ul2.x 渲染成olli>function locateByHtmlIndex(root, target) { if (target 0) return null; let total 0; let result null; let inTarget false; function walk(el) { if (inTarget || result) return; if (el.nodeType Node.TEXT_NODE) { const full escapeText(el.data); if (total full.length target) { // 落在文本内部逐字符还原偏移 let rest target - total; let i 0; while (i el.data.length) { const unit escapeText(el.data[i]).length; if (unit rest) break; rest - unit; i; } result { node: el, offset: i }; return; } total full.length; return; } if (el.nodeType ! Node.ELEMENT_NODE) return; const open openTagLength(el); if (total open target) { // 下标落在开始标签内部退回到父元素的对应子节点位置 const parent el.parentNode; let idx 0; for (let i 0; i parent.childNodes.length; i) { if (parent.childNodes[i] el) { idx i; break; } } result { node: parent, offset: idx }; return; } total open; for (const child of el.childNodes) { walk(child); if (result) return; } total closeTagLength(el); } for (const child of root.childNodes) { walk(child); if (result) break; } return result; }使用的时候配合document.createRange()const loc locateByHtmlIndex(quill.root, savedIndex); if (loc) { const r document.createRange(); r.setStart(loc.node, loc.offset); r.collapse(true); const sel window.getSelection(); sel.removeAllRanges(); sel.addRange(r); }反向函数有两个容错设计要注意。第一个是下标落在标签内部时的退化处理——理论上不应该出现但如果数据是脏的或者 HTML 结构变了就会发生。这时候退回到父元素的子节点位置至少不会崩。第二个是文本节点内部逐字符还原用el.data[i]取单个 UTF-16 单元转义后长度可能是 1 也可能是 5逐项扣减rest直到扣不动为止此时i就是文本节点内的偏移。emoji 在这套逻辑下会被拆成两个单元但因为每个单元转义后都是 1累计长度不变恢复出的 offset 依然正确。5. 踩坑实录与常见问题排查5.1 空行少算最典型的偏差来源这个问题我遇到过至少三次每次都是同一个根因忘了把br当成空元素处理。段落为空时Quill 渲染的是pbr/p。如果br被当成普通元素会多算一个/br也就是 5 个字符反过来如果遍历时把整个br跳过会少算 4 个字符。我建议在代码里把VOID_TAGS写成一个模块级常量并且加一句注释说明它的来源避免后来的人优化时误删。同时在closeTagLength里显式返回 0不要靠条件判断的偶然成立。排查方法很直接在测试用例里造一个三段两空行的文档光标停在最后一段开头跑一遍自测脚本。如果diff是 4 的倍数基本可以确定是空元素处理出了问题。5.2 实体转义nbsp;的 5 个字符从 Word 或者网页复制粘贴过来的内容空格经常是不换行空格\u00A0而不是普通空格\u0020。肉眼完全看不出区别但在innerHTML里前者是 6 个字符的nbsp;后者是 1 个字符。我第一次踩这个坑时测试用的是手打的普通空格一切正常上线之后用户粘贴带格式的文案锚点位置全部偏移偏移量还不固定排查了整整一个下午才发现。应对方式是在转义函数里显式处理\u00A0并且在测试用例里固定加一条包含不换行空格的段落。如果业务侧允许可以在内容入库前统一做一次空格归一化把\u00A0换成普通空格能省掉很多麻烦。但要注意这会影响用户看到的排版效果是否这样做取决于产品要求。的转义同理1 个字符变 5 个。文本里出现价格 折扣这种写法时光标在后半段的位置会偏 4。5.3 属性顺序变化导致的索引漂移这个问题比较隐蔽。Quill 在更新某个节点的属性时可能会先删后加导致属性的排列顺序变化。比如原本是span classql-size-large stylefont-size: 18px;在一次更新后变成span stylefont-size: 18px; classql-size-large字符数没变但内容不同了。对于算下标这个动作本身属性顺序不影响长度所以结果正确。但如果你把下标存起来下次用另一份 HTML 去还原就会错位。这就是为什么我一直强调HTML 下标不能持久化。它只在一个快照的生命周期内有效。需要跨会话记录位置时用 Delta index 或者语义化的定位描述。5.4 Quill 版本差异的应对1.x 和 2.x 在 DOM 结构上有一些差异我列几个影响比较大的结构Quill 1.xQuill 2.x对索引的影响无序列表