深入掌握Range对象在前端开发中的实战应用
项目初期的技术选型
去年做了一个富文本编辑器的定制需求,客户要求支持“高亮选中文字并打标签”——比如选中一段话,点个按钮,这段文字就变成黄色背景,还能保存标签类型。一开始我以为用 contenteditable + 正则匹配就行,结果发现根本不行:用户选中的可能是跨标签的内容,比如从一个段落末尾选到下一个段落开头,正则完全没法处理。
后来翻 MDN 才知道浏览器原生提供了 Range 对象,专门用来表示文档中的一个区域。这玩意儿其实很老了,但平时写业务很少用到,这次算是被逼着啃下来。核心思路就是:用户选中文本时,用 getSelection().getRangeAt(0) 拿到 Range,然后基于这个 Range 做高亮包裹。
核心代码就这几行
最开始的实现非常朴素,直接在 Range 上调用 surroundContents:
function highlightSelection() {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const span = document.createElement('span');
span.className = 'highlight';
span.style.backgroundColor = 'yellow';
try {
range.surroundContents(span);
} catch (e) {
console.warn('无法包裹选区', e);
}
}
本地测试没问题,但一上真实内容就炸了。用户随便选个跨段落的文本,控制台就报错:Failed to execute 'surroundContents' on 'Range': The Range has partially selected a non-Text node. 翻译成人话就是:你选中的区域包含了不完整的元素节点(比如只选了 <p> 的一半),surroundContents 不干。
最大的坑:surroundContents 的限制
折腾了半天才发现,surroundContents 要求 Range 必须完全包含在同一个文本节点内,或者所有边界都对齐到文本节点边界。但用户哪管你这些?他们随手一拖,可能从一个 <strong> 中间开始,到另一个 <em> 结束。这时候必须手动拆分 Range 边界。
我查了下资料,发现得用 range.cloneContents() 先把内容复制出来,再清空原 Range,最后插入新包裹。但这样会丢失原始 DOM 结构,而且光标位置会乱。更稳妥的做法是递归遍历 Range 内的节点,逐个包裹文本节点。这活儿比想象中麻烦多了。
后来参考了 Medium 的编辑器实现,改用 document.execCommand?算了,这 API 已经废弃了,不能依赖。最后还是硬着头皮自己写拆分逻辑。
自己造轮子:安全包裹 Range
核心思路是:先判断 Range 是否“可包裹”,如果不行,就手动拆分起始和结束节点。具体做法是:
- 用
range.startContainer和range.endContainer判断是否是文本节点 - 如果不是,就用
splitText把文本节点在选中位置切开 - 对非文本节点,则递归处理其子节点
但实际写起来特别绕,尤其是处理嵌套标签的时候。我最后采用了简化方案:只处理纯文本场景,遇到复杂结构就提示用户“请重新选择”。但客户不答应,说竞品都能做到……
咬牙重写,终于搞定了一个能处理大部分情况的版本。关键代码如下:
function safeSurround(range, wrapper) {
// 如果 Range 跨多个节点,先提取内容
const fragment = range.extractContents();
const wrapperClone = wrapper.cloneNode(false);
wrapperClone.appendChild(fragment);
range.insertNode(wrapperClone);
// 修复光标位置(重要!)
const newRange = document.createRange();
newRange.selectNode(wrapperClone);
newRange.collapse(false); // 移到末尾
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(newRange);
}
这里用 extractContents 替代 surroundContents,虽然会破坏原有 DOM,但至少不会报错。而且通过重建 Range 并重置光标,避免了用户操作后光标消失的问题。亲测有效,但要注意:如果原始内容有事件监听器,会被 extractContents 清掉,需要额外处理(我们项目里没这个问题,所以偷懒了)。
性能问题差点翻车
功能上线后,测试反馈:在长文章里连续高亮十几次,页面会卡顿。我一开始以为是 DOM 操作太频繁,后来用 Performance 面板一看,发现每次高亮都会触发整个文档的重排(reflow)。
原因很简单:每次调用 insertNode 都会改变 DOM 结构,浏览器要重新计算布局。解决办法是批量操作——把多次高亮合并成一次 DOM 修改。但用户是逐个点击高亮的,没法预知后续操作。
最后妥协方案:加个防抖,500ms 内的高亮操作合并执行。虽然不能根治,但日常使用已经不卡了。代码大概这样:
let pendingHighlights = [];
let highlightTimer = null;
function queueHighlight() {
pendingHighlights.push(currentRange);
if (highlightTimer) clearTimeout(highlightTimer);
highlightTimer = setTimeout(() => {
applyAllHighlights(pendingHighlights);
pendingHighlights = [];
}, 500);
}
当然,更好的做法是用虚拟 DOM diff 或者文档片段(DocumentFragment)批量插入,但考虑到项目时间紧,这个方案够用了。
回顾与反思
这次用 Range 对象,踩了几个经典坑:
- 别信
surroundContents能处理一切:它只在理想条件下工作,真实用户的选择千奇百怪 - 光标位置必须手动维护:DOM 操作后 selection 会丢失,不重置的话用户下一步操作就懵了
- 性能问题藏在细节里:单次操作看不出,但累积起来就是卡顿
最终效果基本满足需求,但仍有两个小问题没解决:
- 如果用户选中包含
<img>或<br>等 void 元素,高亮会中断(因为 extractContents 会跳过它们) - 撤销(undo)功能没做,因为 Range 操作很难和历史记录联动,暂时靠浏览器自带的 Ctrl+Z 顶着
不过客户说“能用就行”,这两个问题影响不大。说实话,如果现在重做,我会考虑用 ProseMirror 或 Quill 这类专业库,而不是自己硬刚 Range API。但这次经历让我对浏览器的选区机制理解深了一层,值了。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的 Range 处理方案,欢迎评论区交流!

暂无评论