简介:这份资源面向需要在前端页面实现文字批注功能的开发者,尤其适合已掌握基础 DOM 操作、希望快速落地选中高亮与批注交互的初中级前端。包内共 10 个文件,以 3 个 js 脚本、2 个 css 样式、1 个 html 页面为主,另含 2 张 png 图片与 2 个 db 文件,压缩包约 80KB,体积轻量便于直接运行调试。核心示例演示了选中文本后动态添加背景色、创建与编辑批注、点击删除批注等完整流程,并借助 jQuery 简化事件监听与 DOM 插入,同时涉及颜色选择、状态保存与恢复等扩展思路。目前已有 1223 人学习下载,读者可据此理解批注系统的关键实现路径,并在此基础上扩展富文本编辑、响应式布局与撤销重做等能力,为项目中的标注、审阅类需求提供可复用的参考方案。
1. 页面文字批注这件事,为什么值得单独拆一个包
做后台系统、文档协作、在线审阅类产品的前端,早晚会撞上同一个需求:用户选中一段文字,点一下按钮,这段文字就带上背景色,旁边还能挂一条批注。听起来简单,真动手才发现坑不少——选区怎么拿、跨节点怎么处理、刷新之后批注还在不在、多个批注重叠怎么办。我手上这份「前端页面添加文字批注.rar」就是冲着这个场景来的,核心能力是选中文字后添加背景色并绑定批注内容,属于纯前端可落地的实现方案,不依赖后端也能先跑通交互。它适合正在做富文本审阅、合同标注、教学批注、内容校对这类功能的前端开发,也适合想搞明白 Selection 和 Range 到底怎么配合的人。下面我按「能跑起来 → 参数怎么调 → 哪里会翻车」的顺序,把这份资源拆开讲透。
2. 选区与 Range:批注功能的地基怎么打
2.1 为什么不能直接用 innerHTML 拼接
很多人第一反应是拿到选中文字,然后用字符串替换的方式把<span style="background:yellow">选中文字</span>塞回去。这个做法在单段落纯文本里能跑,但只要页面结构稍微复杂一点就会翻车。原因是innerHTML重新赋值会销毁原有 DOM 节点,绑在这些节点上的事件监听、React/Vue 的虚拟 DOM 引用全部失效,页面直接变成黑匣子——看起来渲染出来了,但点不动。
正确的地基是浏览器原生的 Selection 和 Range API。用户用鼠标划选文字时,浏览器内部维护了一个 Selection 对象,它指向文档中的一段或多段 Range。Range 的startContainer、startOffset、endContainer、endOffset四个属性精确描述了选区的起止位置。批注功能要做的,就是把这四个值存下来,再据此把选区包一层带背景色的标签。
// 获取当前选区并提取关键位置信息 function getSelectionInfo() { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return null; const range = sel.getRangeAt(0); // 选中的纯文本,用于批注内容展示 const text = sel.toString(); if (!text.trim()) return null; return { text, startContainer: range.startContainer, // 起始节点,可能是文本节点 startOffset: range.startOffset, // 起始偏移量 endContainer: range.endContainer, // 结束节点 endOffset: range.endOffset, // 结束偏移量 rect: range.getBoundingClientRect() // 选区在视口中的位置,用于弹批注框 }; }这段代码是整个功能的入口。rangeCount为 0 说明用户只是点了一下没划选,直接返回 null。sel.toString()拿到的是纯文本,注意它会把跨段落的换行也带进来,后面做批注内容匹配时要留意。getBoundingClientRect()返回的矩形用于把批注输入框定位到选区旁边,这是交互体验的关键——框弹到屏幕外面用户就找不到了。
2.2 用 surroundContents 包裹选区的正确姿势
拿到 Range 之后,最直接的包裹方式是range.surroundContents()。它会把选区内容提取出来,塞进你指定的新节点里,再放回原位。
// 给当前选区添加背景色高亮 function highlightSelection(color = '#fff3b0') { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return null; const range = sel.getRangeAt(0); const mark = document.createElement('mark'); mark.style.backgroundColor = color; mark.className = 'annotation-highlight'; // 生成唯一 id,方便后续绑定批注数据 mark.dataset.annotationId = 'anno_' + Date.now(); try { range.surroundContents(mark); } catch (e) { // 选区跨越了多个不连续的节点时会抛异常 console.warn('选区结构复杂,surroundContents 失败:', e.message); return null; } sel.removeAllRanges(); // 清除选区,避免视觉上重复高亮 return mark.dataset.annotationId; }surroundContents有一个硬性限制:如果选区起点和终点在不同的块级元素里,比如从<p>中间划到下一个<p>中间,它会直接抛InvalidStateError。这不是 bug,是规范就这么定的。常见做法是先用range.cloneContents()把内容克隆出来,判断里面有没有块级标签,有的话就降级处理——要么提示用户缩小选区,要么按段落拆成多个 mark 分别包裹。我一般会在产品层面直接限制「一次只能批注同一段落内的文字」,省掉大量边界判断。
参数方面,color默认给了个柔和的黄色#fff3b0,比纯黄#ffff00在白色背景上更耐看。dataset.annotationId是后面把批注内容和高亮块关联起来的钥匙,用时间戳生成简单够用,正式项目建议换成 uuid 避免并发冲突。
2.3 批注数据的存储结构
高亮只是视觉层,批注内容得单独存。这份资源里用的是一份扁平数组,每条记录包含 id、文本、位置信息和批注正文。
// 批注数据模型示例 const annotations = [ { id: 'anno_1712345678901', text: '选中的原文内容', comment: '这里的数据口径需要和财务确认', color: '#fff3b0', createdAt: '2024-04-05T10:00:00Z', // 用于持久化恢复的定位信息 anchor: { startXPath: '/html/body/div[2]/p[1]/text()[1]', startOffset: 12, endXPath: '/html/body/div[2]/p[1]/text()[1]', endOffset: 24 } } ];这里用 XPath 而不是直接存 DOM 节点引用,是因为节点引用没法序列化,刷新页面就丢了。XPath 是字符串,可以存 localStorage 也可以发给后端。恢复的时候用document.evaluate把 XPath 转回节点,再重建 Range。注意 XPath 对页面结构变化极其敏感,如果批注保存后页面又插入了新元素,原来的路径可能就指偏了。稳妥做法是给批注容器加稳定的 id 或 data 属性,XPath 里带上这些锚点。
3. 从选中到落库:一套可复现的批注流程
3.1 监听 mouseup 而不是 selectionchange
选区的获取时机很讲究。selectionchange事件触发太频繁,用户每拖动一像素就触发一次,在里面做 DOM 操作会卡。而且它在选区被清除时也会触发,容易拿到空选区。我一般监听mouseup,等用户松开鼠标再读取选区,这时候选区已经稳定了。
// 监听鼠标抬起,读取选区并显示批注按钮 document.addEventListener('mouseup', (e) => { // 点击在批注按钮或输入框上时不处理,避免误触发 if (e.target.closest('.annotation-toolbar')) return; const info = getSelectionInfo(); const toolbar = document.querySelector('.annotation-toolbar'); if (!info) { toolbar.style.display = 'none'; return; } // 把工具栏定位到选区上方 toolbar.style.display = 'flex'; toolbar.style.top = (info.rect.top + window.scrollY - 40) + 'px'; toolbar.style.left = (info.rect.left + window.scrollX) + 'px'; });e.target.closest('.annotation-toolbar')这行判断很关键。工具栏本身也在文档里,用户点工具栏按钮时 mouseup 也会冒泡到 document,如果不排除,工具栏会先隐藏再显示,闪一下。定位时加上window.scrollY和scrollX是因为getBoundingClientRect返回的是视口坐标,而style.top用的是文档坐标,页面滚动后不加偏移框就飘了。
3.2 批注输入与提交的完整链路
工具栏上有个「添加批注」按钮,点了之后弹输入框,用户写完点确认,这时候才真正执行高亮和存储。
// 提交批注:高亮选区 + 保存数据 function submitAnnotation(comment) { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); const selectedText = sel.toString(); // 先记录 XPath 定位信息,必须在 surroundContents 之前取 const anchor = { startXPath: getXPath(range.startContainer), startOffset: range.startOffset, endXPath: getXPath(range.endContainer), endOffset: range.endOffset }; const id = highlightSelection('#fff3b0'); if (!id) { alert('选区跨越了多个段落,请缩小范围后重试'); return; } annotations.push({ id, text: selectedText, comment, color: '#fff3b0', createdAt: new Date().toISOString(), anchor }); // 持久化到 localStorage,刷新不丢 localStorage.setItem('annotations', JSON.stringify(annotations)); renderAnnotationList(); // 右侧批注列表刷新 } // 获取节点的 XPath 路径 function getXPath(node) { if (node.nodeType === Node.TEXT_NODE) { // 文本节点要定位到它在父节点中的索引 const parent = node.parentNode; const idx = Array.from(parent.childNodes).indexOf(node) + 1; return getXPath(parent) + '/text()[' + idx + ']'; } if (node === document.body) return '/html/body'; const parent = node.parentNode; const idx = Array.from(parent.children).indexOf(node) + 1; return getXPath(parent) + '/' + node.tagName.toLowerCase() + '[' + idx + ']'; }顺序很重要:XPath 必须在surroundContents之前取。因为包裹之后 DOM 结构变了,原来的文本节点被移到了 mark 里面,再取 XPath 路径就多了一层,恢复时对不上。getXPath递归往上找,文本节点用text()[n]表示,元素节点用tagName[n]表示,这是 XPath 的标准写法。
3.3 刷新后恢复高亮的实现
页面重新加载后,从 localStorage 读出批注数组,逐条把 XPath 转回 Range 再重新包裹。
// 页面加载时恢复所有批注高亮 function restoreAnnotations() { const saved = localStorage.getItem('annotations'); if (!saved) return; annotations = JSON.parse(saved); annotations.forEach(anno => { try { const startNode = document.evaluate( anno.anchor.startXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null ).singleNodeValue; const endNode = document.evaluate( anno.anchor.endXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null ).singleNodeValue; if (!startNode || !endNode) { console.warn('批注定位失败,页面结构可能已变化:', anno.id); return; } const range = document.createRange(); range.setStart(startNode, anno.anchor.startOffset); range.setEnd(endNode, anno.anchor.endOffset); const mark = document.createElement('mark'); mark.style.backgroundColor = anno.color; mark.className = 'annotation-highlight'; mark.dataset.annotationId = anno.id; range.surroundContents(mark); } catch (e) { console.warn('恢复批注失败:', anno.id, e.message); } }); }document.evaluate的第四个参数XPathResult.FIRST_ORDERED_NODE_TYPE表示只取第一个匹配节点,比返回迭代器省事。每条恢复都包在 try-catch 里,因为只要有一条定位失败,不能让整个恢复流程中断。实际项目里我会在 catch 里把失败的批注标记为「失效」,在列表里灰掉,让用户知道这条批注的原文位置找不到了,而不是静默丢弃。
4. 避坑与排查:批注功能最容易翻车的五个地方
4.1 高亮后文字被拆成多个碎片
现象:给一段文字加批注后,再给相邻文字加第二条批注,发现第一条的 mark 被拆成了两半,背景色断断续续。
原因:surroundContents在选区边界落在已有 mark 内部时,会先把原 mark 拆开再包裹,导致一个逻辑批注对应多个 DOM 节点。
解决:在包裹前检查选区是否与已有 mark 重叠。如果重叠,要么合并成一条批注,要么提示用户「该区域已有批注」。简单判断方式是遍历range.cloneContents()里的 mark 元素,有就拦截。
4.2 刷新后高亮位置偏移
现象:保存批注时高亮位置正确,刷新页面后高亮跑到别的段落去了。
原因:XPath 依赖页面 DOM 的绝对索引。如果页面在批注保存后动态插入了广告位、通知条等元素,原有节点的索引全部后移,XPath 指向就偏了。
解决:给批注容器的根元素加固定 id,XPath 从该 id 开始算相对路径,而不是从/html/body算绝对路径。这样只要容器内部结构不变,外部插入多少元素都不影响。
4.3 移动端长按选不中文字
现象:桌面端划选正常,手机上长按文字弹不出批注工具栏。
原因:移动端浏览器的选区行为不同,长按触发的是系统菜单,mouseup事件在触摸设备上不一定按预期触发。而且移动端getBoundingClientRect在软键盘弹出时坐标会变。
解决:移动端改用touchend事件,并加 300ms 延迟等系统选区稳定。工具栏定位用visualViewport的偏移量修正,避免被软键盘顶飞。如果产品对移动端要求高,建议直接调系统原生的选择菜单,而不是自绘工具栏。
4.4 批注内容里的 HTML 被当代码执行
现象:用户在批注输入框里写了<img src=x onerror=alert(1)>,批注列表渲染时弹窗了。
原因:批注内容直接用了innerHTML渲染,没有转义。
解决:批注正文一律用textContent渲染,或者引入 DOMPurify 做净化。这是前端安全的基本功,任何用户输入回显的地方都不能裸用 innerHTML。批注场景尤其危险,因为批注内容往往来自多人协作,你没法保证每个输入者都是善意的。
4.5 大量批注时页面卡顿
现象:页面上超过 200 条批注后,滚动明显掉帧,添加新批注要等一两秒才响应。
原因:每条批注都创建了独立的 mark 节点和事件监听,DOM 节点数膨胀,加上mouseup里每次都遍历全部批注做重叠检测,复杂度是 O(n²)。
解决:重叠检测改用区间树或按段落分桶,把 O(n²) 降到接近 O(n)。渲染上,可视区域外的批注列表项用虚拟滚动,mark 节点本身没法虚拟化,但可以合并相邻的同色批注减少节点数。如果批注量真的很大,考虑用 Canvas 覆盖层画高亮,而不是改 DOM。
5. 进阶:把批注做成可协作、可导出的能力
单机版批注跑通之后,下一步通常是多人协作和导出。协作的核心是把annotations数组同步到后端,这里有个容易忽略的点:不同用户看到的页面结构可能因为权限差异而不同,XPath 在 A 用户那里有效,在 B 用户那里可能指向完全不同的节点。稳妥做法是后端存储时同时保存「选中文本的哈希」和「前后各 20 个字符的上下文」,恢复时先用 XPath 定位,定位失败就用文本上下文做模糊匹配,两者都失败才标记为失效。这个降级策略能覆盖绝大多数结构差异场景。
导出方面,如果要把批注和原文一起导出成带标注的文档,纯前端可以用html2canvas把页面截图,再把批注框画上去。但截图方案有个硬伤:文字不可选、不可搜索。更好的做法是导出时重新生成一份干净的 HTML,把 mark 标签和批注编号内联进去,批注正文以脚注形式附在文末。这样导出的文件在任何浏览器里打开都能看到高亮和对应说明。
// 导出带批注的 HTML 片段 function exportAnnotatedHTML() { const clone = document.querySelector('.doc-container').cloneNode(true); const marks = clone.querySelectorAll('.annotation-highlight'); marks.forEach((mark, index) => { const anno = annotations.find(a => a.id === mark.dataset.annotationId); if (!anno) return; // 在高亮文字后插入上标编号 const sup = document.createElement('sup'); sup.textContent = '[' + (index + 1) + ']'; sup.style.color = '#e67e22'; mark.after(sup); }); // 文末附批注列表 const footer = document.createElement('div'); footer.innerHTML = '<hr><h3>批注列表</h3>'; annotations.forEach((anno, index) => { const p = document.createElement('p'); // 用 textContent 防止 XSS p.textContent = '[' + (index + 1) + '] ' + anno.comment; footer.appendChild(p); }); clone.appendChild(footer); return clone.innerHTML; }这段导出逻辑里,mark.after(sup)把编号插在高亮块后面,读者能直接对应到文末的批注列表。p.textContent而不是innerHTML是第 4.4 条踩坑的直接应用——导出文件可能被分享给外部人员,更不能留 XSS 口子。
还有一个实际项目里绕不开的问题:批注的权限。谁能添加、谁能删除、谁能看到别人的批注,这些不该在前端判断,前端只负责根据后端返回的权限字段决定按钮显隐。我见过有项目把「是否可删除」的逻辑写在前端,结果用户改一下 localStorage 就能删别人的批注,这种翻车完全是设计阶段就能避免的。
从那以后我每次做批注类功能,都强制走一遍「选区跨段落、刷新恢复、XSS 注入、200 条压力」这四个测试用例,一个不过就不提交。这份资源把最核心的选中高亮和存储链路讲清楚了,拿过去改吧改吧就能接进自己的项目,省掉从零摸索 Selection API 的时间。希望帮到你。
本文还有配套的精品资源,点击获取