深入掌握Range对象在前端开发中的实战应用

小殿福 前端 阅读 2,267
赞 15 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年做了一个富文本编辑器的定制需求,客户要求支持“高亮选中文字并打标签”——比如选中一段话,点个按钮,这段文字就变成黄色背景,还能保存标签类型。一开始我以为用 contenteditable + 正则匹配就行,结果发现根本不行:用户选中的可能是跨标签的内容,比如从一个段落末尾选到下一个段落开头,正则完全没法处理。

深入掌握Range对象在前端开发中的实战应用

后来翻 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.startContainerrange.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 会丢失,不重置的话用户下一步操作就懵了
  • 性能问题藏在细节里:单次操作看不出,但累积起来就是卡顿

最终效果基本满足需求,但仍有两个小问题没解决:

  1. 如果用户选中包含 <img><br> 等 void 元素,高亮会中断(因为 extractContents 会跳过它们)
  2. 撤销(undo)功能没做,因为 Range 操作很难和历史记录联动,暂时靠浏览器自带的 Ctrl+Z 顶着

不过客户说“能用就行”,这两个问题影响不大。说实话,如果现在重做,我会考虑用 ProseMirror 或 Quill 这类专业库,而不是自己硬刚 Range API。但这次经历让我对浏览器的选区机制理解深了一层,值了。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的 Range 处理方案,欢迎评论区交流!

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论