简介:在页面中实现文字批注和选中高亮,往往需要组合事件监听、DOM插入与样式控制。这套压缩包正为此类需求提供了基于jQuery的轻量级示例,面向前端开发者,演示了选中文本后动态添加批注、变更背景颜色以及删除批注的完整交互流程,也适用于快速搭建带标注功能的阅读器或文档管理界面。压缩包共10个文件,含3个JavaScript脚本、2个CSS样式、1个HTML页面、2个图片及2个数据库文件,总大小仅80KB,结构精简,便于直接运行查看效果并上手改造。目前已有1223人学习浏览,通过示例可学到检测文本选区、利用元素样式进行背景高亮、将批注动态写入DOM并响应删除操作等关键手段;若结合富文本编辑器和本地存储,还可进一步扩展出可编辑、可持久化的批注系统。
1. 页面添加批注:选中文字上背景色,这个包到底解决了什么
不少人看到“页面添加批注”这五个字,第一反应是“加个输入框,存个备注,很难吗”。真写到代码里你就会发现,最难的不是输入框,而是“选中添加背景色”这一下:鼠标选中一段文字,松手,高亮出现,批注条弹出,关页面再打开,高亮和批注还在原位置。这个压缩包把这整条链路做了下来,从 Selection 选区捕获、Range 边界提取,到文本偏移序列化,再到重渲染时恢复高亮,都收在了里面。适合在阅读器、文档预览、编辑审校这类页面里快速嵌入批注能力的开发者。拆这个包,重点不在高亮样式,而在“坐标怎么记、漂移怎么防、重建怎么找”。
2. 把“选中”变成坐标:Range 与 Selection 的核心处理
2.1 Selection 取数与 Range 边界:startContainer 与 endContainer
浏览器给到开发者的第一道手就是 window.getSelection()。多数人的习惯是取 selection.toString() 拿到选中文字,然后直接包个 span 上背景色。这个流程在小范围演示里确实能跑,但一旦你的页面里混着图片、换行、内嵌标签,toString() 返回的文本和页面里的真实文本节点对不上,你存下来的东西就可能是错的。
先看这段最基础的标准取法:
const selection = window.getSelection(); if (!selection.rangeCount || selection.isCollapsed) return; const range = selection.getRangeAt(0); const startNode = range.startContainer; // 选区的起点容器节点 const startOffset = range.startOffset; // 起点在容器内的偏移 const endNode = range.endContainer; // 选区的终点容器节点 const endOffset = range.endOffset; // 终点偏移 const selectedText = range.toString(); console.log({ startNode, startOffset, endNode, endOffset, selectedText });这里有三个关键点:isCollapsed 是判断用户是否真的选中了一段内容而不是只点了光标;startContainer 可能是元素节点也可能是文本节点,offset 的含义也因此不同;range.toString() 拿到的是用户“看得见的”纯文本,但缺少位置信息,所以还需要配合 getRangeAt(0) 拿到真实边界。我在实际拆这个包时,第一件事就是确认它有没有把 startContainer 的节点类型分情况处理,而不是直接把 offset 当成字符下标。
startContainer 是文本节点时,startOffset 就是字符偏移;是元素节点时,startOffset 表示第几个子节点。这两个含义混在一起,很容易把批注坐标记错。包里通常会封装一个 normalize 函数来做统一转换。
2.2 偏移序列化:为什么“纯文本偏移”比 DOM 路径更稳
包裹高亮之后马上会遇到一个问题:你修改了 DOM,原来存下来的 startContainer 和 endContainer 引用已经失效了。如果存 DOM 路径(比如 body > div > p:nth-child(2) 这种),页面稍微动态几分就崩。更稳的方案是“纯文本偏移”——不管页面里嵌套多少层标签,把所有文本节点拼起来算总长度,批注只存起点偏移和终点偏移。
计算文本偏移的常见做法是用 TreeWalker 遍历全部文本节点:
function getTextOffset(rootNode, targetNode, charOffset) { const walker = document.createTreeWalker( rootNode, NodeFilter.SHOW_TEXT, null ); let currentIndex = 0; let node = walker.nextNode(); while (node) { const nodeLength = node.nodeValue.length; if (node === targetNode) { return currentIndex + charOffset; } currentIndex += nodeLength; if (currentIndex >= /* 所有文本总量 */) break; node = walker.nextNode(); } return -1; }计算全文总长度和文本节点的累计偏移时,要先约定好换行算不算长度。浏览器里文本节点的换行通常会被折叠成空格或直接消失,这个差异容易导致重建时的偏移错位;我一般会约定好“批注基于 rendered 文本”,也就是用户看到什么就记什么,出现换行差异时不强行靠拢。这段代码里的 currentIndex 是累计偏移,targetNode 来自上一节 getRangeAt(0) 拿到的容器节点;把选区起止各跑一遍 getTextOffset,就能得到一个可以持久化的区间。
2.3 松手就要弹工具栏:mousedown 与选区保持的配合
选中文本后点击弹出的批注条,是另一个高频翻车点。mousedown 一点,工具栏按钮还没敲,选区就没了。原因在于浏览器在 mousedown 时会把默认选区清掉,按钮的 click 事件根本来不及响应。
处理方式是在工具栏容器上用 preventDefault 拦掉 mousedown:
toolbar.addEventListener('mousedown', (event) => { event.preventDefault(); // 阻止默认行为,保住当前选区 });mousedown 阶段被阻止后,选区不会因为点击工具栏而消失,click 事件照常触发。某些浏览器还会在 mouseup 前重绘选区,因此需要在 selectionchange 事件里判断当前选区是否和记录的一致,不一致就隐藏工具栏。这就是为什么很多成熟批注库的工具栏窗口会带一个“保持聚焦”的选项,本质上都是在处理同一个坑。
3. 高亮包裹与批注落库:数据结构和 DOM 操作写法
3.1 用 surroundContents 包裹还是 extractContents 重建
拿到选区和偏移之后,下一步是给选中文字上背景色。最直接的想法是 range.surroundContents(span),但这个 API 有条件限制:选区的边界必须落在文本节点边界上,跨节点选区会直接抛错。比如选中“
今天天气不错
”里的一部分,surroundContents 大概率就挂了。通用做法是先用 extractContents 把选中内容抽出来,包一层 span 再插回去。这是我在这个包里看到的标准思路。每个文本节点被切分再包裹后,DOM 结构会变化,所以包裹完成后要重新计算偏移,这部分逻辑要和持久化时用的偏移算法保持一致。
function highlightRange(range) { const span = document.createElement('span'); span.className = 'text-highlight'; // 把选中内容从原位置抽出来 const extracted = range.extractContents(); span.appendChild(extracted); range.insertNode(span); return span; }extractContents 会连同文本节点一起抽出,span 包裹后重新插入,range 会自动指向插入的位置。批量批注时注意:每包裹一段之后,原始 range 已经变了,要先保存偏移,包裹完成后 setStart / setEnd 重新定位。这里容易踩的坑是 extractContents 会把选中区域内的标签结构也抽出来,比如选中内容里刚好横跨了一个
,重建时文本节点数量与原索引对不上,逐字符去对齐比较稳。
3.2 批注数据的最小结构设计
这个包的数据结构设计得比较精简。核心不依赖后端数据库,一份 JSON 结构就够把批注和页面绑定在一起。参考的最小结构能拆成这样:
| 字段 | 类型 | 作用 |
|---|---|---|
| id | string | 批注唯一标识,重建高亮时对应回挂载节点 |
| startOffset | number | 选区起点在整个文本中的字符偏移 |
| endOffset | number | 选区终点偏移 |
| selectedText | string | 选中的原始文本,用于兜底校验和展示 |
| color | string | 背景色值,如 #ffeb3b |
| note | string | 批注内容 |
| createdAt | number | 时间戳,用于排序和分组展示 |
selectedText 一定要存。重建高亮时如果发现偏移对不上,可以用它做文本片段的二次校验,减少错误高亮。这个字段的价值在动态页面里会被放大,后面会专门讲。
3.3 从偏移重建高亮:TreeWalker + splitText 的正向做法
重建是整个批注功能里最容易出 bug 的部分——页面加载完成后,DOM 结构可能和你存批注时不完全一样,纯文本偏移就成了唯一的锚点。重建的思路是:从根节点出发,用 TreeWalker 依次累加文本节点的长度,找到目标偏移所在的文本节点,然后用 splitText 从节点中间切割。一次可以切出一个 start、一个 end,中间整段文本节点直接包 span。
function rebuildRange(rootNode, startOffset, endOffset) { const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT, null); let currentOffset = 0; let node = walker.nextNode(); let startNode = null, startNodeOffset = 0; let endNode = null, endNodeOffset = 0; while (node) { const len = node.nodeValue.length; const nodeStart = currentOffset; const nodeEnd = currentOffset + len; // 起点落在当前文本节点内 if (startNode === null && startOffset >= nodeStart && startOffset <= nodeEnd) { startNode = node; startNodeOffset = startOffset - nodeStart; } // 终点落在当前文本节点内 if (endOffset >= nodeStart && endOffset <= nodeEnd) { endNode = node; endNodeOffset = endOffset - nodeStart; } if (startNode && endNode) break; currentOffset = nodeEnd; node = walker.nextNode(); } const range = document.createRange(); range.setStart(startNode, startNodeOffset); range.setEnd(endNode, endNodeOffset); return range; }这个实现里最需要注意两个点:一是 startOffset 等于 nodeStart 时要防止重复命中,二是空文本节点的长度是 0,不会影响偏移累加但要跳过避免误判。边界命中逻辑写成 >= 而不是 > 的原因,就是允许选区起点恰好落在某个文本节点的第 0 个字符上。重建后调用 highlightRange 包 span,就能把存下来的批注位置重新渲染出来。
splitText 切出来的新文本节点在渲染时不会自动合并,但重建同一条批注时,只要每次从头遍历,结构变化不影响结果。之后可以用 parentNode.normalize() 把多余空节点合并掉,减少 DOM 噪音。
4. 避坑篇:选中批注最常见的一组翻车现场
4.1 双击选中时多吃了换行和空格
现象:用户双击一个词,高亮区域却把前后空格和换行都带上了。原因:双击选中的范围本身包含了词边界外的空白字符,range.toString() 里也能看到这些不可见字符。解决:保存偏移前对文本两侧做 trim 处理,并同步修正 startOffset 和 endOffset。注意中英文混排时,trim 只处理 ASCII 空格,全角空格要用 \u3000 一起处理。
const text = range.toString(); const leading = text.match(/^[\s\u3000]+/)[0]?.length ?? 0; const trailing = text.match(/[\s\u3000]+$/)[0]?.length ?? 0; const finalStart = getTextOffset(root, range.startContainer, range.startOffset) + leading; const finalEnd = getTextOffset(root, range.endContainer, range.endOffset) - trailing;代码里 leading 和 trailing 分别算出了选中文本两侧多余空白的长度,修正到最终偏移上。不要用 text.trim() 之后直接拿 trim 后文本长度去推导,因为空白字符在多个文本节点间分布时,两侧的 offset 需要分别修正,一次 trim 会把对齐关系弄乱。
4.2 点工具栏,选区就消失
现象:选中文字后弹出批注条,点击批注条上的任意按钮,高亮没出来,选区先没了。原因:mousedown 的默认行为清除了选区。解决:前面第 2.3 节提到的 toolbar mousedown preventDefault 是第一步。另一个隐蔽问题是,如果你在 selectionchange 事件里做了“选区状态变化就刷新工具栏”的逻辑,preventDefault 也救不回来——selectionchange 在 preventDefault 后依然会触发,需要缓存一份 lastRange 做对比,只有真正变化时才关工具栏。
4.3 重建时 splitText 把前面的 Range 全部打乱
现象:一次渲染 10 条批注,第一条能精确高亮,第二条开始位置全部漂移。原因:每重建一条批注都会调用 splitText,文本节点被切碎,后续偏移累计的起点变化了。解决:重建前先把整份批注按 startOffset 从大到小排序,或者每次都从根节点重新遍历。前一种做法性能好,但注意创建 range 的实际顺序仍然要按 startOffset 从小到大,否则 DOM 插入顺序错乱会导致后面的 range 锚点失效。简单可靠的方法是全部基于初始文本结构计算好 node 引用和偏移,再一次插入,避免边切边算。
4.4 高亮标签嵌套后背景色串层
现象:批注 A 和批注 B 有重叠区域,渲染后背景色变成一层叠一层,文字被包了两层 span,取消批注时删不干净。原因:连续批注时,第二个 range 的起止点可能落在第一个批注 span 内部,extractContents 把 span 又抽了一层出来。解决:插入新批注前,先检测重叠区。一种常用策略是“完全重叠才合并,半重叠时按整段文本重新拆分”:
function getOverlaps(startOffset, endOffset, annotations) { return annotations.filter( (item) => startOffset < item.endOffset && endOffset > item.startOffset ); }查到重叠批注后,把新批注的区间拆成多个子区间,分别落高亮。每次渲染前也可以统一做一次区间排序和合并,把相邻同色批注合并成一个完整区间,页面结构会干净很多。
4.5 动态加载页面重建失败,但数据明明没坏
现象:SPA 里路由切换后,批注位置整体错乱或直接消失。原因:页面数据是异步加载的,重建批注时文本还没渲染完,偏移量按照空容器的文本长度去匹配,自然全错。解决:在数据渲染完成的回调之后启动重建函数,并保留一个“兜底重试”标志。文本长度不足预期时,用 MutationObserver 监听容器子树变化,新内容插入后再跑一次重建;超过三次仍失败,则放弃定位并提示“批注位置已失效”。
5. 进阶:批注定位回跳与动态内容的容错技巧
批注功能做到能存能渲染只是及格,真正好用要能“点批注列表,页面滚到对应高亮处”。实现回跳的核心不是 scrollIntoView,而是找到高亮 span 之后,用它附近的上下文去校验位置是否正确。
function jumpToAnnotation(id) { const span = document.querySelector(`[data-annotation-id="${id}"]`); if (!span) return; span.scrollIntoView({ block: 'center', behavior: 'smooth' }); span.classList.add('annotation-active'); setTimeout(() => span.classList.remove('annotation-active'), 1200); }给每个高亮 span 挂上 style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />