quill 富文本编辑器获取光标所在 innerHTML 索引这件事,我第一次碰到是在给一个内容审核后台做定位高亮的时候。产品要求很直白:用户在编辑器里点到哪,右侧的 HTML 源码面板就要同步高亮到对应的那段标签区间,点一下源码也要反过来把光标跳到编辑器里对应位置。听上去是个双向绑定的活,动手才发现里面全是坑——Quill 给你的getSelection()返回的是{index, length},那是 Delta 模型下的线性字符偏移;而你想拿来定位的innerHTML是一串带标签、带属性、带实体转义的字符串,两套坐标根本没在一个维度上。这篇文章就把我从零踩出来的这条路完整摊开:为什么这两个坐标对不上、有几种换算思路、每种思路的适用边界在哪、代码怎么写、哪些细节会让偏移量莫名其妙差几个字符、以及线上跑起来之后才知道的那些经验。前端做富文本的同学、需要对接外部 HTML 渲染的同学、做批注和内容审核的同学,都能从里面拿到能直接抄的东西。
1. 先把坐标系统对齐,不然越算越乱
1.1 Quill 的 index 到底是什么单位
很多人第一反应是quill.getSelection().index就是光标在这个 HTML 里的字符位置,因为它看起来就是个数字嘛。但 Quill 的 index 计量的是 Delta 里的操作位,单位是"内容字符",不含任何标记。你写一段<p>abc</p>,光标停在 c 后面,index是 3;而这段字符串的长度是 12,你想要的那个位置是 6。差出来的 6 个字符全是<p>和</p>。
更麻烦的是有些东西在 Delta 里算 1,在 HTML 里算一大坨。插入一张图片,Delta 里就是{insert: {image: '...'}}这一个嵌入对象,index 只前进 1;但序列化出来的 innerHTML 是<img src="data:image/png;base64,iVBORw0KGgo...">,一行 base64 几千个字符,索引直接爆掉。所以你不能指望用一个比例或者一个固定补偿值去换,两套坐标之间的映射是非线性的,必须逐节点走一遍。
我在项目初期图省事,写过一段用quill.getText(0, index).length反推的代码,在纯文本段落里跑得挺欢,一上列表和图片就全乱。这个教训很值钱:Delta index 和 HTML 索引之间不存在解析式,只存在遍历式。
1.2 真正需要 HTML 偏移量的四类场景
绝大多数时候你不需要这个索引,因为 Quill 自己的setSelection(index, length)已经能搞定所有编辑器内的定位。但下面这几类场景绕不开:做批注锚点时,你得把选区的位置序列化成一个能存库的区间,而评审系统往往只认字符串偏移;做敏感词或术语检测时,你要在渲染出来的 HTML 上插入高亮标签,插入位置必须精确到字符;做 AI 改写时,要把某一段原样文本抠出来交给模型,再原位置替换回去,抠取的边界就是字符索引;还有一类是外部渲染器对接,比如你有一份历史遗留的富文本高亮库,它只接受"在字符串第 N 位插入一个 mark",你没法改它,只能适配它。
这四类场景有个共同点——它们都发生在 Quill 之外,都在跟"一份 HTML 字符串"打交道。所以问题的本质不是"怎么在 Quill 里定位",而是"怎么把 Quill 里的位置翻译成外部世界的坐标"。想明白这一层,方案选型就有方向了。
1.3 三套坐标的对照关系
| 坐标类型 | 获取方式 | 计量单位 | 是否包含标签/属性 | 稳定程度 | 典型用途 |
|---|---|---|---|---|---|
| Delta index | quill.getSelection().index | 内容字符 | 否 | 高,与 HTML 结构无关 | 编辑器内定位、内容操作 |
| DOM Range | window.getSelection().getRangeAt(0) | 节点 + 节点内偏移 | 否 | 中,受 DOM 结构影响 | 浏览器原生选区操作 |
| HTML 字符串索引 | 需自行计算 | UTF-16 码元(按.length计) | 是 | 低,受序列化规则影响 | 字符串拼接、外部渲染 |
换算路径只有一条是可靠的:Delta index ↔ DOM Range 交给 Quill 自己做,DOM Range ↔ HTML 索引由我们算。千万别尝试 Delta index 和 HTML 索引直连,中间必须落到 DOM 上,因为只有 DOM 才知道每个节点被序列化成多长。
2. 方案选型:五条路各自的适用边界
2.1 克隆 Range 法,最省事但有个致命尾巴
思路很轻巧:把光标之前的内容整体克隆出来,塞进一个临时 div,读它的innerHTML.length。代码短得像玩具:
function htmlIndexByClone(root) { const sel = window.getSelection(); if (!sel.rangeCount) return -1; const range = sel.getRangeAt(0); if (!root.contains(range.startContainer)) return -1; const pre = document.createRange(); pre.selectNodeContents(root); pre.setEnd(range.startContainer, range.startOffset); const frag = pre.cloneContents(); const box = document.createElement('div'); box.appendChild(frag); return box.innerHTML.length; }问题是cloneContents会把 Range 跨越到的祖先元素一起复制,并自动补齐闭合标签。举例:原文是<p>abc<b>def</b></p>,光标停在 c 后面,克隆结果是<p>abc</p>,长度 9;但你真正想要的"HTML 前缀"是<p>abc,长度 6。多出来的</p>就是误差。
这个误差在光标停在段落中间时几乎必然出现,而且大小不固定(取决于祖先链有多深)。可以事后剥离尾部闭合标签,但剥离逻辑很难写对,因为你要判断哪些闭合标签是真的在原文里就该有的。我试过用正则一层层剥,遇到嵌套列表和各种 style 属性就崩了。这个方法适合快速验证和一次性脚本,不适合上生产。
2.2 遍历累加法,慢一点但结果稳
换成模拟序列化的方式:从头遍历 DOM 树,遇到文本节点就累加它转义后的长度,遇到元素节点就累加它的开标签长度,递归处理子节点后再累加闭合标签长度;当遍历到光标所在的文本节点和偏移时,停下来,返回累计值。这个思路没有任何"猜测"成分,算出来的就是浏览器序列化时会产生的那串字符的前缀长度。
它的开销是 O(节点数),比克隆法重,但节点数在正常文档里也就几百上千个,一次遍历毫秒级,完全可接受。而且它有个克隆法没有的好处:可以中途停止,不需要真的把前面所有内容都复制一遍。
2.3 哨兵法,用浏览器自己的序列化器当裁判
还有一条更"赖"的路:在光标处插一个带特殊属性的空元素,读整个容器的innerHTML,indexOf找到哨兵位置,然后把哨兵删掉。
function htmlIndexByMarker(root, range) { const marker = document.createElement('span'); marker.setAttribute('data-caret-probe', '1'); range.insertNode(marker); const html = root.innerHTML; const key = '<span>function getNativeRange(quill) { // Quill 2.x 内部维护了原生 Range if (quill.selection && typeof quill.selection.getRange === 'function') { const r = quill.selection.getRange(); if (r) return r; } const sel = window.getSelection(); if (!sel || !sel.rangeCount) return null; return sel.getRangeAt(0); }拿到 Range 之后必须校验range.startContainer在不在quill.root里面。编辑器失焦、选区跑到工具栏、或者用户点在右侧面板上,都可能让 Range 指向容器外面,这时候返回 -1,别硬算。
3.2 把两个序列化辅助函数写对
整个算法依赖两个"模拟浏览器序列化"的函数,这两个函数写错,后面全错。第一个负责文本节点转义:
const TEXT_ESCAPE = { '&': '&', '<': '<', '>': '>', '\u00A0': ' ' }; function serializeText(text) { return text.replace(/[&<>\u00A0]/g, ch => TEXT_ESCAPE[ch]); }注意三点。第一,用单次正则加映射表,天然避免&被二次转义成&amp;的顺序问题。第二,文本节点里的引号"和'不转义,只有属性值里才转义,别多此一举。第三,\u00A0(不换行空格)在主流浏览器序列化时都会输出成 ,必须映射,这一个字符的差异就能让索引整体偏移,而且在长文本里越靠后偏得越多。
第二个函数生成开标签字符串。这里有个非常巧妙的写法,不用手写属性拼接:
function serializeOpenTag(el) { const clone = el.cloneNode(false); // 只克隆自身,不带子节点 const html = clone.outerHTML; const tag = el.namespaceURI === 'http://www.w3.org/1999/xhtml' ? el.tagName.toLowerCase() : el.tagName; const closing = `</${tag}>`; const tail = html.lastIndexOf(closing); return tail === -1 ? html : html.slice(0, tail); }cloneNode(false)再读outerHTML,浏览器会按它自己的规则把属性顺序、引号风格、style 归一化全部处理好,我们只是把尾部的闭合标签切掉。属性值里可能出现>、"这类字符,但lastIndexOf(closing)是从后面找闭合标签,不受属性值内容干扰,比indexOf('>')稳得多。
至于 void 元素,判断表要完整:
const VOID_TAGS = new Set([ 'area','base','br','col','embed','hr','img','input', 'link','meta','param','source','track','wbr' ]);Quill 里最常撞见的是img和br,但embed(视频)和hr(分割线)也可能出现,别只判断这两个。
3.3 递归遍历的核心循环
真正的主函数长这样,注释比代码多,因为这里的每个分支都对应一类真实场景:
function htmlIndexOfCursor(root, targetNode, targetOffset) { let pos = 0; let result = -1; function walk(node) { // 文本节点:命中就按偏移切片累加,否则整段累加 if (node.nodeType === Node.TEXT_NODE) { if (node === targetNode) { const off = Math.min(targetOffset, node.data.length); pos += serializeText(node.data.slice(0, off)).length; result = pos; return true; } pos += serializeText(node.data).length; return false; } // 注释等其它节点,innerHTML 同样会序列化 if (node.nodeType !== Node.ELEMENT_NODE) { if (node === targetNode) { result = pos; return true; } pos += node.nodeType === Node.COMMENT_NODE ? `<!--${node.data}-->`.length : 0; return false; } const tag = node.namespaceURI === 'http://www.w3.org/1999/xhtml' ? node.tagName.toLowerCase() : node.tagName; const open = serializeOpenTag(node); // void 元素没有子节点,也没有闭合标签 if (VOID_TAGS.has(tag)) { if (node === targetNode) { result = pos; return true; } pos += open.length; return false; } pos += open.length; // 光标恰好落在元素节点上(空行、块级元素边界) if (node === targetNode) { const children = node.childNodes; const lim = Math.min(targetOffset, children.length); for (let i = 0; i < lim; i++) walk(children[i]); result = pos; return true; } for (let i = 0; i < node.childNodes.length; i++) { if (walk(node.childNodes[i])) return true; } pos += `</${tag}>`.length; return false; } walk(root); return result; }调用入口简单封装一层:
function getCursorHtmlIndex(quill) { const range = getNativeRange(quill); if (!range) return -1; const root = quill.root; if (!root.contains(range.startContainer)) return -1; return htmlIndexOfCursor(root, range.startContainer, range.startOffset); }3.4 反向映射:从 HTML 索引还原光标
业务上往往要成对出现,比如源码面板点一下要跳回编辑器。反向算法的骨架一样,只是判断条件换成"当前累计长度是否已经覆盖目标值":
function textOffsetFromHtml(text, htmlOffset) { let acc = 0; for (let i = 0; i < text.length; i++) { const len = serializeText(text[i]).length; if (acc + len > htmlOffset) return i; acc += len; } return text.length; } function rangeFromHtmlIndex(root, target) { if (target <= 0) { const r = document.createRange(); r.setStart(root, 0); r.collapse(true); return r; } let pos = 0; let out = null; function walk(node) { if (out) return; if (node.nodeType === Node.TEXT_NODE) { const len = serializeText(node.data).length; if (pos + len >= target) { const r = document.createRange(); r.setStart(node, textOffsetFromHtml(node.data, target - pos)); r.collapse(true); out = r; return; } pos += len; return; } if (node.nodeType !== Node.ELEMENT_NODE) return; const tag = node.tagName.toLowerCase(); pos += serializeOpenTag(node).length; if (VOID_TAGS.has(tag)) return; for (let i = 0; i < node.childNodes.length; i++) { walk(node.childNodes[i]); if (out) return; } pos += `</${tag}>`.length; } walk(root); if (!out) { const r = document.createRange(); r.selectNodeContents(root); r.collapse(false); return r; } return out; }反向映射有个天然缺陷:当目标索引正好落在一个标签内部(比如指到了<p cla|ss="x">的中间),没法还原成一个合法光标位置,只能落到最近的文本边界。所以对外暴露接口时,最好约定"只接受文本位置",或者返回时带上一个snapped标记,让调用方知道发生了吸附。
4. 那些让偏移量对不上的序列化细节
4.1 实体转义是最常见的偏差来源
前面提过 ,但实际项目里踩得最惨的是用户粘贴进来的内容。从 Word 或者网页里复制一段文字,里面带&、<、全角不换行空格,全部会在 innerHTML 里膨胀。一个&从 1 个字符变 5 个,一百个&就是 400 个字符的偏差,而且是累积性的,越往后越离谱。
我的处理方式是:在计算之前先把内容规范一遍,用 Quill 的clipboard.dangerouslyPasteHTML或者自己的清洗逻辑,把全角空格、零宽字符、多余 span 全部清掉。清洗发生在内容进入编辑器时,而不是在计算索引时,这样偏移量在整个生命周期内都是稳定的。
4.2 空行的<br>和 Quill 的特殊节点
Quill 的空段落长这样:<p><br></p>。光标落在这个空行里时,startContainer通常是那个<p>元素,startOffset是 0 或 1,而不是文本节点。如果你的遍历函数只处理文本节点命中,这里就会返回一个错误的值。所以第 3.3 节里那个"光标恰好落在元素节点上"的分支不是可选项,是必须项。
另外 Quill 2.x 的列表项里有<span class="ql-ui" contenteditable="false"></span>,这是列表标记的占位节点,用户完全看不见,但它在 innerHTML 里实实在在占着几十个字符。这意味着:如果你把 innerHTML 直接丢给外部渲染器,列表的偏移量会整体错位,因为外部渲染器不会渲染这个 span。解决办法要么用getSemanticHTML()作为基准,要么在计算时把ql-ui这类节点按零长度处理——但后者要求你前端的渲染逻辑也做同样的忽略,两边必须一致。
我个人的选择是:在内容模型层面统一用 getSemanticHTML,只在需要跟 Quill 内部状态对齐时才用 innerHTML,并且在接口注释里写死这个约定,避免后来人搞混。
4.3 属性带来的长度膨胀
<span style="color: rgb(230, 0, 0); font-size: 18px;">这样一个标签就有 60 多个字符。一段带格式的短文本,标签长度可能是正文的好几倍。这不影响正确性,但影响两件事:一是索引值会变得很大,存数据库时要注意字段类型;二是这类属性很容易被浏览器归一化,比如style="color:#e60000"可能被序列化成rgb(230, 0, 0),如果内容经过一次 round-trip,之前算好的索引就全废了。
所以我的原则很明确:HTML 索引只能作为瞬时坐标,绝不能持久化。要存,就存 Delta 快照加 Delta index,需要时现场换算。
4.4 图片和嵌入内容的长度黑洞
一张 base64 内联图片,长度轻松上万。光标在图片后面,索引就是一万多。这个数字本身没错,但它极不稳定:图片被重新压缩、被替换成 CDN 链接、被裁切,长度全变。如果你的批注系统把索引存了下来,第二天图片一换,批注位置就飞到天边去了。
解决方案是把图片这类 embed 内容在坐标层面视为"一个不可分割的原子",用一个占位符替代它参与长度计算,同时记录"第几个 embed"。这样索引就与图片的实际字节数解耦了。实现上可以在遍历时给每个 embed 记一个编号,返回{ htmlIndex, embedCount }这样的复合坐标。
5. 常见问题与排查实录
5.1 光标在段落中间时索引差了几个字符
这几乎百分百是克隆 Range 法留下的尾巴,也就是尾部闭合标签被算进去了。验证方法很简单:在控制台里把克隆出来的box.innerHTML打印出来,肉眼看看结尾是不是多了</p>、</li>这类标签。换成遍历累加法立刻就好。
如果是遍历法也偏,那大概率是serializeText漏了某种转义。把光标前后的那段 HTML 打印出来,跟原文本逐字符对比,一定会发现某个实体没被映射。
5.2 光标在列表里索引整体偏移
先检查ql-ui那个 span。你可以这样验证:把 innerHTML 里的class="ql-ui"换成别的字样再算一次,如果结果变了,就是这个原因。处理办法见 4.2 节,要么统一基准,要么两边都忽略。
还有一种情况是嵌套列表,<ul><li>a<ul><li>b</li></ul></li></ul>,光标在内层 li 里。递归遍历本身能处理好,但如果你的实现里对li做了特殊处理(比如跳过第一个子节点,因为里面是 ql-ui),内层和外层的规则不一致就会错。
5.3 输入法组字期间索引乱跳
中文输入时,compositionstart到compositionend之间,DOM 里会有临时文本,光标位置也会频繁变动。这段时间算出来的索引没有意义,因为内容还没定型。我的做法是在组字期间挂起所有基于索引的计算,等compositionend之后再触发一次。
let composing = false; quill.root.addEventListener('compositionstart', () => { composing = true; }); quill.root.addEventListener('compositionend', () => { composing = false; scheduleRecompute(); });Quill 自身也有quill.on('composition-before')之类的钩子,版本不同名字略变,用原生事件更省心。
5.4 问题速查表
| 现象 | 最可能原因 | 快速验证 | 修复方式 |
|---|---|---|---|
| 索引固定偏小/偏大一个小值 | 尾部闭合标签被算入/漏算 | 打印克隆片段看结尾 | 改用遍历法 |
| 越往后偏移越大 | 实体转义漏映射( 最常见) | 对比原文与 innerHTML 中特殊字符 | 补全 ESCAPE 映射表 |
| 列表位置全错 | ql-ui占位 span | 替换该 class 名重算 | 统一基准或按零长度处理 |
| 图片后位置剧烈跳动 | base64 长度变化 | 看索引是否上万 | embed 原子化处理 |
| 空行返回 -1 或 0 | 光标在元素节点上未处理 | 打印 startContainer.nodeType | 补元素节点分支 |
| 失焦后拿到旧位置 | Range 未校验 | 检查root.contains() | 加容器校验、失焦置空 |
| 计算偶发卡顿 | 大文档全量遍历 | 打时间戳统计耗时 | 缓存前缀长度、节流 |
5.5 一个容易忽略的坑:normalize 之后节点引用失效
如果你在别处调用了root.normalize(),相邻的文本节点会被合并,之前缓存的节点引用全部作废,再拿去比较node === targetNode永远不成立。表现就是函数返回 -1 或者返回一个莫名其妙的小值。这类问题极难从现象倒推,我建议在函数入口处加一句断言式的检查:
if (!root.contains(targetNode)) { console.warn('[htmlIndex] target node detached'); return -1; }contains对已经脱离文档的节点会返回 false,能提前暴露问题,比返回一个错误数字强得多——错误数字会静默污染下游数据。
6. 把索引真正用起来:三个落地场景
6.1 场景一:批注锚点的插入与还原
用户在编辑器里选一段话,点"添加批注",系统需要把这段位置的起止索引存下来,并在 HTML 上包一层<span class="anno">const start = htmlIndexOfCursor(root, range.startContainer, range.startOffset); const end = htmlIndexOfCursor(root, range.endContainer, range.endOffset); // 先插后面那个 html = html.slice(0, end) + '</span>' + html.slice(end); html = html.slice(0, start) + `<span class="anno">let cache = { node: null, offset: -1, value: -1 }; function getCursorHtmlIndexCached(quill) { const range = getNativeRange(quill); if (!range) return -1; const { startContainer: n, startOffset: o } = range; if (cache.node === n && cache.offset === o) return cache.value; const v = htmlIndexOfCursor(quill.root, n, o); cache = { node: n, offset: o, value: v }; return v; }
缓存要记得在内容变更时失效,text-change里把cache.node置空即可。这个优化在典型场景下能省掉八成以上的重复计算。
7.2 用对拍测试保证长期正确
遍历法的正确性完全依赖"我模拟的序列化规则和浏览器一致"这个假设,而浏览器的规则是会变的。所以一定要有一组对拍用例。我的做法是准备十几段刁钻内容——带&、带全角空格、带嵌套列表、带图片、带空行、带自定义 class——对每一段,在每个文本节点的每个偏移上都跑一遍遍历法和哨兵法,断言两者相等。
这个测试第一次跑就抓出了两个 bug:一个是注释节点没处理(Quill 里少见,但粘贴内容可能带),一个是\u00A0之外的另一种不换行字符\u2007没映射。这类问题靠肉眼审查代码基本发现不了。
7.3 版本升级的回归清单
需要重点回归的升级有三类:Quill 大版本升级(1.x 到 2.x,列表结构、getSemanticHTML都有变化)、浏览器大版本升级(序列化规则可能微调)、内容清洗规则变更。每次升级后,把 7.2 的对拍用例跑一遍,全绿再发布。这个习惯帮我挡住了一次因为升级导致的全站批注位置偏移事故。
最后一个体会:这套东西真正难的不是算法,是约定。你必须跟所有下游系统明确一件事——我们约定的基准到底是innerHTML还是getSemanticHTML,这个基准一旦定下来就是契约,改一次全量数据作废。我现在的习惯是在项目文档第一页就写死这一条,比写多少代码都管用。