Quill光标位置到innerHTML字符串下标的换算方案
做 Quill 编辑器相关需求的人,几乎都会在某一个具体时刻撞上同一个问题:当前光标究竟落在innerHTML字符串的第几个字符上。这个问题听起来很怪,因为 Quill 本身提供的getSelection()明明已经返回了{ index, length },为什么还要费劲去算一个 HTML 字符串里的下标?答案藏在业务落地的细节里。Quill 的 index 是 Delta 模型下的扁平字符偏移,它把整篇文档看成一串纯文本加换行符;而innerHTML是 DOM 序列化之后的字符串,里面混着标签名、属性、引号、实体转义、空行占位。两者之间没有现成的换算公式,getSelection().index直接拿去当innerHTML的下标用,结果一定是错位的。这篇内容就是把这个错位彻底讲清楚,从需求场景、模型原理、方案选型一路写到完整代码、边界验证和踩坑记录,适合已经上手 Quill、正在做批注定位、变量占位符插入、内容快照比对或者导出后二次处理的开发者参考,前端基础一般也能跟着一步步复现。
1. 需求场景拆解:innerHTML 索引到底解决什么问题
1.1 三类最典型的落地场景
我在实际项目里遇到需要"光标所在 innerHTML 索引"的场景,基本集中在三类。第一类是在光标处插入不可编辑的占位内容。比如某个内容协作系统里,编辑区需要支持插入"变量标签",用户点一下工具栏的"插入用户昵称",就要在光标位置塞入一个<span class="var-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>甚至直接消失,因为空段落在选区里没有实际内容。原本的<p><br></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, '&') .replace(/\u00a0/g, ' ') .replace(/</g, '<') .replace(/>/g, '>'); } // 属性值转义,注意双引号要转,尖括号不用 function escapeAttr(str) { return str .replace(/&/g, '&') .replace(/\u00a0/g, ' ') .replace(/"/g, '"'); } // 开始标签长度,包含尖括号和结尾的 > 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>和<p><br>|</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 class="var-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 的无序列表渲染成<ul><li>...</li></ul>,2.x 渲染成<ol><li>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 渲染的是<p><br></p>。如果br被当成普通元素,会多算一个</br>,也就是 +5 个字符;反过来,如果遍历时把整个<br>跳过,会少算 4 个字符。
我建议在代码里把VOID_TAGS写成一个模块级常量,并且加一句注释说明它的来源,避免后来的人"优化"时误删。同时在closeTagLength里显式返回 0,不要靠条件判断的偶然成立。
排查方法很直接:在测试用例里造一个"三段两空行"的文档,光标停在最后一段开头,跑一遍自测脚本。如果diff是 4 的倍数,基本可以确定是空元素处理出了问题。
5.2 实体转义: 的 5 个字符
从 Word 或者网页复制粘贴过来的内容,空格经常是不换行空格\u00A0而不是普通空格\u0020。肉眼完全看不出区别,但在innerHTML里,前者是 6 个字符的 ,后者是 1 个字符。
我第一次踩这个坑时,测试用的是手打的普通空格,一切正常;上线之后用户粘贴带格式的文案,锚点位置全部偏移,偏移量还不固定,排查了整整一个下午才发现。
应对方式是在转义函数里显式处理\u00A0,并且在测试用例里固定加一条"包含不换行空格的段落"。如果业务侧允许,可以在内容入库前统一做一次空格归一化,把\u00A0换成普通空格,能省掉很多麻烦。但要注意,这会影响用户看到的排版效果,是否这样做取决于产品要求。
&的转义同理,1 个字符变 5 个。文本里出现"价格 & 折扣"这种写法时,光标在后半段的位置会偏 4。
5.3 属性顺序变化导致的索引漂移
这个问题比较隐蔽。Quill 在更新某个节点的属性时,可能会先删后加,导致属性的排列顺序变化。比如原本是<span class="ql-size-large" style="font-size: 18px;">,在一次更新后变成<span style="font-size: 18px;" class="ql-size-large">,字符数没变,但内容不同了。
对于"算下标"这个动作本身,属性顺序不影响长度,所以结果正确。但如果你把下标存起来,下次用另一份 HTML 去还原,就会错位。
这就是为什么我一直强调HTML 下标不能持久化。它只在一个快照的生命周期内有效。需要跨会话记录位置时,用 Delta index 或者语义化的定位描述。
5.4 Quill 版本差异的应对
1.x 和 2.x 在 DOM 结构上有一些差异,我列几个影响比较大的:
| 结构 | Quill 1.x | Quill 2.x | 对索引的影响 |
|---|---|---|---|
| 无序列表 | `
|