正确做法是监听mouseup获取选区Range,用mark包裹纯文本节点;跨节点需TreeWalker遍历切分处理;清除时应还原DOM结构而非仅删标签,多场景高亮须语义隔离。

点击任意文本片段,用 Range 和 mark 精准包裹
直接操作 innerHTML 替换会撕开标签、触发 XSS、误匹配子串——这是最常翻车的路径。正确做法是捕获用户真实选区,只动纯文本节点。
核心逻辑:监听 mouseup(非 click,因 click 可能发生在非文本区域),调用 window.getSelection() 获取 Range,再用 document.createElement('mark') 包裹该范围:
document.addEventListener('mouseup', () => {
const selection = window.getSelection();
if (!selection.rangeCount || selection.toString().trim() === '') return;
<p>const range = selection.getRangeAt(0);
const mark = document.createElement('mark');
range.surroundContents(mark); // 注意:range 必须完全在单个文本节点内
});⚠️ 容易踩的坑:
-
surroundContents要求 range 完全落在一个文本节点中;若跨节点(比如选中了段落开头到下一个<strong></strong>里的字),会抛错InvalidStateError - 移动端需额外监听
touchend,且要加setTimeout延迟执行,否则 selection 还没更新 - 高亮后再次点击同一段文本,
surroundContents会报错——得先移除已有mark,或改用extractContents+ 重建
处理跨元素选区:用 TreeWalker 遍历文本节点再分段包裹
用户拖选经常横跨 <p></p>、<em></em>、<code> 多个元素,此时 surroundContents 失效。必须手动拆解选区,逐段处理。
立即学习“前端免费学习笔记(深入)”;
关键步骤:用 document.createTreeWalker(element, NodeFilter.SHOW_TEXT) 拿到所有文本节点 → 对每个节点判断其内容是否与 selection 重叠 → 重叠部分用 splitText 切开 → 插入 mark。
示例要点:
将 Markdown 文章转换为微信公众号兼容的纯 HTML 格式。当用户要求"转成微信格式"、"生成公众号文章 HTML"、"排版到微信草稿箱"、"微信粘贴格式"时触发。输出带内联样式的纯 HTML,微信编辑器可直接渲染,无需额外适配。支持标题、段落、列表、粗体、行内代码、表格、引用块、代码块、配图的完整转换。
- 别用
textContent做全局匹配——它抹平结构,无法定位原始位置 - 对每个文本节点,用
node.textContent和range.startOffset/endOffset计算重叠起止索引 - 调用
node.splitText(startIndex)和nextSibling.splitText(endIndex - startIndex)切出目标片段 - 包裹时确保
mark是新创建的,避免重复插入或样式污染
清除高亮不能只删 mark 标签,得还原 DOM 结构
直接 el.querySelectorAll('mark').forEach(m => m.replaceWith(m.textContent)) 看似简单,但会丢掉原始空白、换行和实体编码(如 & → &)。
更稳妥的方式是:在包裹前用 range.cloneContents() 缓存原始片段,或记录每个 mark 的父节点 + 位置索引,清除时用 insertBefore 把文本插回原位。
实际建议:
- 给每个动态插入的
mark加唯一data-highlight-id,方便按 ID 清除 - 清除操作应批量进行,避免多次 reflow;可用
DocumentFragment中转 - 若页面含
contenteditable,清除后要手动恢复 focus 和 selection,否则光标丢失
多关键词搜索高亮和点击高亮混用时,顺序和嵌套必须隔离
如果页面既有“用户点击选中高亮”,又有“搜索框输入后自动高亮”,两者共存极易冲突:mark 嵌套、样式覆盖、清除逻辑打架。
解决方案是语义分离:
- 点击高亮用
class="user-highlight",搜索高亮用class="search-highlight",CSS 分开定义 - DOM 层面不嵌套:插入前先检查目标区域是否已有其他类型
mark,有则跳过或合并文本内容,不新建标签 - 清除时按 class 区分,避免“搜完词点一下,把之前手动高亮全干掉了”
- 长关键词(如
#JavaScript)必须优先匹配,否则#Java先被包住,再包外层会生成非法嵌套
真正麻烦的不是写代码,而是处理那些看不见的边界:选区跨 iframe、跨 Shadow DOM、含零宽空格、或用户双击选中单词却带了末尾标点。这些情况没法靠一套正则兜底,得靠 Range + 节点遍历一层层啃。


















