
简介一份演示前端页面添加文字批注功能的示例压缩包面向初中级前端开发者解决网页中选中文本、实时添加高亮批注、编辑与删除的交互需求。压缩包共10个文件以3个js脚本jQuery、artDialog及核心逻辑为主搭配2个css样式、2个png图片、2个db数据文件与1个html入口页整体仅80KB体积轻量目录清晰可直接在浏览器中查看运行效果。已有1223人学习浏览适合需要快速上手批注交互原理的开发者。内容完整演示了基于jQuery监听mouseup、mousedown、mousemove事件确定选区动态创建批注节点并插入DOM通过style.backgroundColor设置高亮颜色以及调用remove()移除批注的完整流程并处理选区边界情况同时包含皮肤样式、对话框组件与颜色选择思路可支持自定义高亮色并即时预览还能延伸至富文本编辑、localStorage持久化、撤销重做与响应式设计等方向为实际项目中构建批注系统提供可复用的代码范式与改造思路。1. 前端页面添加文字批注选中文字加背景色的难点在怎么记住选区前端页面添加文字批注听起来就是把文字选中、涂个背景色但拆开这份 rar 里的代码跑一遍才发现核心难点根本不在配色而在怎么记住用户选中的那一段。直接存文字页面里同样一句话出现两次就会标错位置存 DOM 节点引用刷新后节点早就不认得了。你需要的是把选区序列化成一条可持久化的节点路径再在页面加载时反查还原。这份资源正好把选中文本、背景色高亮、批注存储与还原这条闭环做齐了适合给文档详情页、合同预览页、长文阅读页加批注能力的开发者也适合想搞懂 Selection 与 Range 序列化逻辑的前端学习者。2. 批注功能的核心原理从 Selection 到可持久化的数据模型2.1 浏览器怎么描述一段被选中的文字在一个普通文本页面里用户拖选一段话后浏览器内部不是简单记录第几个字到第几个字而是记录了一对节点和偏移量。window.getSelection() 返回 Selection 对象其中最重要的结构是 Rangeconst selection window.getSelection(); if (selection.rangeCount 0) { const range selection.getRangeAt(0); console.log(range.startContainer); // 起始位置的 DOM 节点 console.log(range.startOffset); // 在节点内的偏移量 console.log(range.endContainer); // 结束位置的 DOM 节点 console.log(range.endOffset); // 结束节点内的偏移量 console.log(range.collapsed); // 是否只是光标没有拖出选区 console.log(range.toString()); // 选中的纯文本内容 }这里有两个新手最容易踩的点。第一startContainer 不一定是一个文本节点它也可能是 div、p 这类元素节点当它是元素节点时startOffset 表示第几个子元素而不是第几个字符。第二range.collapsed 为 true 时说明用户只是点了一下没有拖出选区这时给文字上背景色没有意义首先要过滤掉。我一般在封装选区获取时会先做一层守卫判断function getSelectedRange() { const selection window.getSelection(); if (!selection || selection.rangeCount 0) return null; const range selection.getRangeAt(0); if (range.collapsed) return null; return { startContainer: range.startContainer, startOffset: range.startOffset, endContainer: range.endContainer, endOffset: range.endOffset, selectedText: range.toString() }; }这段代码的逻辑很直白先确认页面确实有选区再确认选区不是单纯的光标然后把起止容器和偏移量原样返回。注意这里返回的不是第 N 个字符到第 M 个字符这种字符串索引因为整个页面存在多个文本节点字符串索引跨节点后完全对应不上而节点 偏移量的组合才是 DOM 树里唯一确定一个位置的精确描述。这个知识点在前端面试题里也经常被拿出来考很多候选人能答出 getSelection但说不清 startContainer 为什么可能是元素节点。2.2 为什么不能直接存 DOM 引用节点路径的序列化与还原拿到 startContainer、endContainer 之后如果直接把节点塞进 localStorage刷新页面再取出来会发现它变成了 null。原因很简单localStorage 只能存字符串节点对象经过 JSON.stringify 之后只剩下一堆 tagName、className 这类属性和 DOM 树本身没有任何引用关系反序列化出来的只是普通对象。要可持久化就必须把节点换成路径。常见做法是记录一条从 body 开始的子节点索引路径比如 body 的第 2 个子节点再往下第 1 个子节点……逐级定位到具体节点配合原始偏移量就能完整还原一段选区。function getNodePath(node, root document.body) { const path []; let current node; while (current current ! root) { const parent current.parentNode; if (!parent) break; const index Array.prototype.indexOf.call(parent.childNodes, current); path.unshift(index); current parent; } return path; } function getNodeByPath(path, root document.body) { let current root; for (const index of path) { if (!current.childNodes || !current.childNodes[index]) return null; current current.childNodes[index]; } return current; }getNodePath 从目标节点一路向上走每次记录自己在父节点 childNodes 列表里的下标直到 rootgetNodeByPath 是逆运算拿着路径数组从 root 往下逐层跳。选这套方案而不是 XPath是因为在纯前端场景里 childIndex 路径更好调试打印出来就是 [2, 0, 3] 这种数字数组出错时一眼能定位问题出在哪一层。而 XPath 字符串虽然表达能力更强但手写解析和维护成本都更高。这里有个必须记住的边界childNodes 包含文本节点、元素节点和注释节点而 querySelector 那套 CSS 选择器是拿不到文本节点的。很多人第一步想用CSS 选择器路径去定位节点结果在文本节点上报错最后绕回 childNodes 方案。这份 rar 里存的就是 childNodes 下标刚好覆盖文本节点这个选择算是在为后续还原的核心场景做铺垫。提示如果页面有弹层、侧边栏这类浮动结构childNodes 下标会受影响尽量把 root 收窄到正文容器不要用 body 根部。2.3 背景色高亮的两种渲染方案splitText 包裹与 overlay 覆盖有了路径和偏移量接下来是怎么把背景色画上去。市面上有两种主流做法。第一种把选中的文本节点拆开用 splitText 在选区边界切分把选区内容包进一个带 background-color 的 span。这种方案直白可控也是这份资源采用的方式。第二种在文字上方覆盖半透明色块先 getBoundingClientRect 算出坐标再把色块 absolute 定位上去。这种方案不改动原始 DOM但页面滚动、字体加载、窗口 resize 之后坐标全部失效要重新计算维护成本很高。我的经验是除非有不允许改动原文 DOM这种硬性要求否则一律走 splitText。覆盖层对动态内容和响应式布局极其脆弱换一次字体高亮位置就飘了用户立刻感知到splitText 虽然改了 DOM但改动是稳定的刷新、换字体都不会影响它。splitText 的实现核心是把一个文本节点按偏移量切成多个。写这段代码时要注意一个同节点陷阱——当起止点在同一个文本节点内时两次 split 的偏移量会互相影响必须先切末尾再切开头function wrapSelectedText(range, color #fff3b0) { const startNode range.startContainer; const endNode range.endContainer; if (startNode endNode startNode.nodeType Node.TEXT_NODE) { // 同一文本节点先按 endOffset 切一刀再按 startOffset 切偏移量才不会错 startNode.splitText(range.endOffset); startNode.splitText(range.startOffset); } else { if (endNode.nodeType Node.TEXT_NODE) { endNode.splitText(range.endOffset); } if (startNode.nodeType Node.TEXT_NODE) { startNode.splitText(range.startOffset); } } const fragment range.extractContents(); const mark document.createElement(span); mark.style.backgroundColor color; mark.className text-annotation-mark; mark.appendChild(fragment); range.insertNode(mark); return mark; }逻辑是先让选区边界彻底落地到文本节点的切开处再用 range.extractContents() 把选中内容抽出来装进带背景色的 span 里最后插回原位置。color 参数默认给了 #fff3b0这种浅黄在白色页面上的视觉干扰最小也是批注类产品最常选的底色。注意range 在 extractContents 之后内部状态会失效如果有后续操作要在 insertNode 之前把数据存好或者干脆重新按路径反查一次节点后面避坑章节会展开讲。3. 完整实现选中高亮、批注保存与刷新还原这份 rar 解压后目录结构大致是一个 HTML 承载演示页面一个 JS 文件放选区操作和批注逻辑一个 CSS 文件定义高亮样式外加一份说明文档。实际项目里可以把这个 JS 再拆成 selection、storage、render 三个模块但单文件在演示场景下更直接照着跑一遍就能看到完整效果。下面按功能链路拆开讲。3.1 选中添加背景色的实现把原理落到页面第一步是监听鼠标选中事件。常规做法是在 mouseup 里做一次延迟判断因为在跨节点拖选时mouseup 触发那一刻 selection 可能还没有稳定计算完。document.addEventListener(mouseup, (e) { if (e.button ! 0) return; // 只用鼠标左键 if (e.target.closest(.text-annotation-mark)) return; // 点在高亮上不重复加 setTimeout(() { const range getSelectedRange(); if (!range) return; const mark wrapSelectedText(range, #fff3b0); saveAnnotation(range, mark); }, 10); });setTimeout 延迟 10ms 是我试下来比较稳的取值太短的话某些浏览器还没算完选区太长用户会觉得有卡顿感。第二行判断很关键用户在高亮 span 上单击时同样会触发 mouseup不拦的话页面会莫名其妙多叠一层批注。这里用 closest 判断点击目标是不是高亮节点能兼容嵌套场景。生产环境还要考虑在已有批注内再次选中的场景——用户先高亮了 A 段又想在 A 段里再高亮一个词。如果完全禁止体验会很差如果允许就要接受批注重叠后续删除逻辑要做合并处理。这份资源默认是允许的代码里没有刻意去重我觉得这个选择合理因为限制太重反而容易让用户困惑。3.2 批注数据的序列化保存一条批注要保存四类信息选区路径、文本内容、颜色、创建时间。用前面封装的 getSelectedRange 和 getNodePath 组装成一条记录function saveAnnotation(range, mark) { if (!range.selectedText || !range.selectedText.trim()) return; const annotation { id: ann_ Date.now(), startPath: getNodePath(range.startContainer), startOffset: range.startOffset, endPath: getNodePath(range.endContainer), endOffset: range.endOffset, text: range.selectedText.slice(0, 200), color: mark.style.backgroundColor, createTime: Date.now() }; const list loadAnnotationList(); list.push(annotation); localStorage.setItem(page_annotations, JSON.stringify(list)); return annotation; } function loadAnnotationList() { const raw localStorage.getItem(page_annotations); if (!raw) return []; try { return JSON.parse(raw); } catch (e) { return []; } }id 用时间戳生成对单机演示够用如果同一毫秒创建两条会出现重复 id生产环境我会加一个随机后缀或交给后端生成。text 截断到 200 字是为了批注列表展示时不用渲染整段原文也避免 localStorage 越存越大。loadAnnotationList 里包了 try/catch是因为 localStorage 里的数据可能被手动改坏或者上一版代码写入了不同结构解析失败时返回空数组而不是让页面直接崩掉。3.3 刷新后还原批注刷新后还原是这套逻辑里最见真章的一步。流程是页面加载后读取批注列表对每一条用 startPath、endPath 找回起止节点再按偏移量构建新 Range执行 splitText 和高亮渲染。function restoreAnnotations() { const list loadAnnotationList(); list.forEach((item) { const startNode getNodeByPath(item.startPath); const endNode getNodeByPath(item.endPath); if (!startNode || !endNode) return; const range document.createRange(); range.setStart(startNode, item.startOffset); range.setEnd(endNode, item.endOffset); if (range.toString().trim() ! item.text.trim()) { console.warn(批注原文不匹配已跳过, item.id); return; } wrapSelectedText(range, item.color); }); } window.addEventListener(DOMContentLoaded, restoreAnnotations);这里有个前置条件getNodePath 和 getNodeByPath 必须基于同一个根节点。默认都是 document.body调用一致就不会错位。如果正文内容在某个容器里最好显式传入同一个容器比如 .article-content这样路径会短很多也不容易受页面其他动态元素的干扰。另外我要强调一下 text 校验那一行它不仅用于展示更是还原时的校验器。页面内容改版后路径可能还能对上但语义已经不是原来的句子了用保存时的文本和当前 Range 的文本做比对不一致就跳过避免出现高亮标错了话的尴尬。线上内容频繁迭代的场景这一行能救回不少脏数据。3.4 删除批注与颜色更新删除批注的难点在于拆掉高亮 span 的同时保留里面的文本。我的做法是遍历所有高亮 span找到 id 对应的那一个用 insertBefore 把 span 的子节点逐个挪到 span 前面最后移除 span 本身function removeAnnotation(id) { const list loadAnnotationList().filter(item item.id ! id); localStorage.setItem(page_annotations, JSON.stringify(list)); document.querySelectorAll(.text-annotation-mark).forEach((mark) { if (mark.dataset.annId id) { const parent mark.parentNode; while (mark.firstChild) { parent.insertBefore(mark.firstChild, mark); } parent.removeChild(mark); } }); }注意高亮 span 在添加时就要把 id 写进 dataset否则删除时找不到对应关系。还要考虑重叠批注的情况两个批注有交集时删除其中一个 span可能会把另一个批注里的文本节点切断视觉上像少了一个字。这是因为之前 splitText 把文本节点切碎了遇到这种情况我会在删除后做一次相邻文本节点的拼接把同一 parent 下相邻的 text node 合并恢复原有的文本连续性。颜色更新相对简单找到高亮 span改 style.backgroundColor同时更新列表里对应批注的 color 字段。不用重新切分文本节点也不需要重算路径所以复杂度比删除低一个量级。4. 避坑指南选区和渲染链路上的四个高频翻车现场4.1 换行处选中文字还原后高亮错位现象用户在段落换行的位置选中一段文字添加批注后刷新页面高亮区域多了一个字或者整体向后偏移了一个字符。原因换行在很多页面里由或块级元素边界造成跨节点选区还原时如果 startContainer 或 endContainer 是元素节点而不是文本节点setStart 的 offset 会被解释成第几个子元素跟保存时的第几个字符语义不一致。解决在保存批注之前把选区边界强制归一化到文本节点上。常见做法是写一个 normalizeRange 工具startContainer 不是文本节点时递归找到该元素下第一个文本节点并把 start 位置下探到那个节点function normalizeRange(range) { if (range.startContainer.nodeType ! Node.TEXT_NODE) { const textNode findFirstTextNode(range.startContainer); if (textNode) range.setStart(textNode, 0); } return range; } function findFirstTextNode(node) { if (node.nodeType Node.TEXT_NODE) return node; for (const child of node.childNodes) { const found findFirstTextNode(child); if (found) return found; } return null; }这个下探操作不复杂但很多人不知道有这种边界处理。保存前 normalize还原前同样 normalize两端规则一致错位问题就消失了。我一般会把 normalizeRange 集成到 getSelectedRange 和 restoreAnnotations 的公共入口里保证所有选区都走同一条规约逻辑而不是在每处调用时想起来才加。4.2 批注刚加上就被其他脚本的 innerHTML 覆盖现象高亮背景色已经渲染出来了页面某个异步操作又重绘了正文区域比如对容器重新赋值 innerHTML高亮 span 连同批注一起消失但 localStorage 里数据还在。原因innerHTML 重新赋值等于重建 DOM之前插入的高亮节点全被销毁页面没有重新触发还原逻辑于是视觉上批注就丢了。解决把还原函数做成可重复调用的方法每次内容重绘完成后主动调用 restoreAnnotations()。如果不想在各个异步回调里手动埋调用点可以用 MutationObserver 监听正文区域发现高亮标记被移除就重新还原const observer new MutationObserver((mutations) { const lostMark mutations.some(m { return Array.from(m.removedNodes).some(n { return n.nodeType 1 n.querySelector n.querySelector(.text-annotation-mark); }); }); if (lostMark) restoreAnnotations(); }); observer.observe(articleRoot, { childList: true, subtree: true });这里一定要做防抖或节流MutationObserver 触发频率很高restoreAnnotations 里全是 DOM 操作不做控制会闪烁甚至可能形成删除→还原→再触发的循环。我一般会用 300ms 的 debounce确认操作真的停下来才执行还原。4.3 高亮 span 样式污染其他文本节点现象添加批注后某些文字的字体、行高发生变化或者高亮区域里的文字拖选不了。原因span 插进文本节点之间后作为一个新的 inline 元素会被页面里一些宽泛的全局选择器命中比如 p span、.content span 这类规则如果设置了 display 或 line-height就会作用到高亮 span 上导致布局变化。也有同事在加高亮时顺手设了 user-select: none结果用户再也拖选不了被批注的文字这属于典型的手滑。解决给高亮 span 一个独立且受限的样式定义用带前缀的类名规避全局选择器.text-annotation-mark { background-color: #fff3b0; border-radius: 2px; box-shadow: 0 0 0 1px rgba(255, 243, 176, 0.35); user-select: text; cursor: pointer; }box-shadow 用来做一个轻微描边视觉上比直接铺纯色块更有批注感user-select: text 确保高亮区域还能被再次拖选。不要把 span 设成 inline-block它会打断文字排版。如果发现线上有全局样式穿透就再给 span 加一条更具体的覆盖规则写在页面加载之后的样式表里优先级更高的同时也不影响其他通用样式。4.4 内容改版后批注定位到语义完全不同的句子现象加批注之后内容改版同一位置换成了新文案刷新后高亮标在了一句语义无关的话上用户侧体验极差。原因基于 DOM 路径的选区本质上是位置定位不关心那里原本是不是用户批注的那句话。只要路径和偏移量能对上它就会标过去哪怕那里的字已经完全变了。解决还原时校验保存的 selectedText 与当前位置的文本是否一致不一致就跳过并打印提示if (range.toString().trim() ! item.text.trim()) { console.warn(批注 ${item.id} 原文已变更已跳过还原); return; }这个方法不是万能的如果同一句话在附近位置重复出现文本校验也能通过但标错位置的概率已经小很多。对内容频繁迭代的页面更稳的方案是给每个被批注的句子加一个业务主键比如数据行的 id而不是只依赖页面路径但这个需求往往要改后端数据模型纯前端方案能做到校验 跳过已经是性价比最高的兜底。5. 进阶localStorage 版本化、iframe 嵌套与双还原的优化细节5.1 localStorage 的多页面隔离与版本校验直接拿 localStorage 存批注功能验证阶段没问题上生产就遇到两件事同一域名下多页面互相污染代码迭代后旧数据结构和新代码不匹配。我的习惯是存储 key 带路径外面包版本号const storageKey page_annotations_ location.pathname; const storageValue { version: 1, updatedAt: Date.now(), annotations: loadAnnotationList() }; localStorage.setItem(storageKey, JSON.stringify(storageValue));读取时先看 version不匹配就走数据迁移或直接忽略纯前端也能给后续迭代留一条退路。5.2 iframe 嵌套页面里怎么取选区页面是 iframe 嵌套时iframe 内部页面的选区属于子页面的 window父页面直接 window.getSelection() 拿不到。同源 iframe 可以这样绑定const iframe document.querySelector(#content-frame); iframe.addEventListener(load, () { const innerDoc iframe.contentDocument; innerDoc.addEventListener(mouseup, (e) { const range getSelectedRangeInWindow(iframe.contentWindow); if (range) wrapSelectedText(range, #fff3b0); }); });跨域 iframe 拿不到 contentDocument只能靠 postMessage 让内页自己处理。动手前先确认 iframe 是否同源否则 debug 一天都找不到原因。5.3 密集批注下的双还原技巧密集批注最隐蔽的问题在于还原顺序第一条批注的 splitText 会改变部分节点的 childNodes 下标使后续批注按旧路径反查时对不上。我连续执行两次 restoreAnnotations第一次切分文本节点第二次拿文本校验兜底对得上的继续渲染。这个双还原看起来有点玄学实际在批注密集场景下能明显减少漏标和错标function restoreWithRetry() { requestAnimationFrame(() { restoreAnnotations(); requestAnimationFrame(restoreAnnotations); }); }从那以后我每次做选区标注类功能都强制让还原逻辑保持可重入并保留两轮执行的能力。这个习惯救过我多次尤其在异步字体加载场景下第一轮还原时字体还没就位第二轮字体就位后位置就正了。希望这份拆解能帮到你动手前先把路径规约、文本节点归一化和双还原这几个点定下来后面踩的坑会少很多。本文还有配套的精品资源点击获取