被忽视的宝藏:深入解读 createRangeFromPoint 的前世今生与实战技巧

13 阅读7分钟

被忽视的宝藏:深入解读 createRangeFromPoint 的前世今生与实战技巧

在浏览器选区的世界里,有一个被大多数人遗忘的 API,它低调却强大,今天让我们重新认识它。

引子:一个深夜的 Bug

凌晨两点,我盯着屏幕上闪烁的光标陷入了沉思。

产品经理提出的需求看似简单:“在用户点击的文本位置附近,自动插入一个批注气泡。” 这个功能在 Word 里司空见惯,但在 Web 上实现却让我踩遍了所有的坑。

window.getSelection() 只能获取用户主动选中的区域,而我们需要的是:根据一个坐标点,找到该位置所在的文本节点,并创建一个选区

就在我快要放弃的时候,一个陌生的 API 闯入了我的视野 —— document.caretRangeFromPoint(以及它的标准兄弟 document.createRangeFromPoint)。

一、历史的尘埃:createRangeFromPoint 的诞生

1.1 浏览器选区的“上古时代”

在 Web 发展的早期,操作文本选区是一件极其痛苦的事情。IE 浏览器独霸一方,拥有自己的 document.selection 对象;而 Netscape 以及后来的 Firefox 则遵循 W3C 标准,使用 window.getSelection()Range 对象。

这种分裂的局面持续了很久。开发者们不得不编写大量的兼容性代码:

// 那个年代的兼容代码
function getSelection() {
  if (window.getSelection) {
    return window.getSelection();
  } else if (document.selection) {
    return document.selection.createRange();
  }
  return null;
}

1.2 坐标转选区的需求涌现

随着 Web 应用越来越复杂,一个新的需求开始出现:如何根据鼠标点击的坐标,获取该位置所在的文本节点?

这个需求的应用场景太多了:

  • 富文本编辑器中的光标定位
  • 批注/评论系统的锚点定位
  • 阅读器中的高亮功能
  • 翻译插件中的单词识别

IE 浏览器很早就提供了 moveToPoint 方法,但在标准浏览器中,这个能力一直缺失。

1.3 createRangeFromPoint 的诞生

WebKit 内核率先在 document 对象上添加了 caretRangeFromPoint(x, y) 方法。这个方法接收一个坐标点,返回一个 Range 对象,该 Range 对象的起始和结束位置都位于该坐标点最近的文本插入位置。

后来,Mozilla 在 Firefox 中也添加了类似的方法,但命名为 createRangeFromPoint。两者的功能基本一致,只是命名上的差异。

// WebKit 系(Chrome, Safari, Edge)
const range = document.caretRangeFromPoint(x, y);

// Firefox
const range = document.createRangeFromPoint(x, y);

虽然这个 API 已经存在了十多年,但令人惊讶的是,即使在今天,仍有大量开发者对它一无所知。

二、核心概念:什么是 createRangeFromPoint

2.1 基本定义

createRangeFromPoint 是一个根据屏幕坐标获取光标位置 Range 对象的 API。

它的输入很简单:

  • x:相对于浏览器视口(viewport)的 X 坐标(像素)
  • y:相对于浏览器视口(viewport)的 Y 坐标(像素)

它的输出是一个 Range 对象,该对象的 startContainerendContainer 指向同一个文本节点,startOffsetendOffset 指向光标在该文本节点中的插入位置。

2.2 Range 对象简介

Range 是 DOM 操作中一个非常重要的概念。它表示文档中一段连续的内容片段。

一个 Range 包含以下关键属性:

  • startContainer:起始位置的节点
  • startOffset:起始位置的偏移量
  • endContainer:结束位置的节点
  • endOffset:结束位置的偏移量

createRangeFromPoint 返回的 Range 中,起始和结束是相同的,代表一个“折叠”的 Range(即光标位置)。

2.3 兼容性处理

由于不同浏览器的命名差异,通常需要编写一个兼容性函数:

function getRangeFromPoint(x, y) {
  let range = null;
  
  if (document.caretRangeFromPoint) {
    // WebKit 系
    range = document.caretRangeFromPoint(x, y);
  } else if (document.createRangeFromPoint) {
    // Firefox
    range = document.createRangeFromPoint(x, y);
  } else if (document.createRange) {
    // 降级方案:创建一个空的 Range
    range = document.createRange();
    range.selectNodeContents(document.body);
    range.collapse(true);
  }
  
  return range;
}

💡 注意:Safari 和 Chrome 使用 caretRangeFromPoint,Firefox 使用 createRangeFromPoint,两者功能一致。

三、实战演练:从入门到精通

3.1 最简单的使用:获取点击位置的文字

<div id="article" style="padding: 20px; line-height: 1.6;">
  <p>当阳光穿过树叶的缝隙,洒在地面上,形成斑驳的光影。这是秋天最美好的时刻,微风中带着桂花的香气。</p>
  <p>远处的钟声悠悠传来,惊起了一群白鸽。它们在空中盘旋,像极了飘落的雪花。</p>
</div>

<script>
  const article = document.getElementById('article');
  
  article.addEventListener('click', (e) => {
    const range = getRangeFromPoint(e.clientX, e.clientY);
    
    if (range && range.startContainer.nodeType === Node.TEXT_NODE) {
      const textNode = range.startContainer;
      const offset = range.startOffset;
      
      // 获取点击位置前后的文字
      const text = textNode.textContent;
      const before = text.substring(Math.max(0, offset - 10), offset);
      const after = text.substring(offset, offset + 10);
      
      console.log(`点击位置附近的文字:...${before}|${after}...`);
    }
  });
</script>

3.2 进阶:在点击位置插入内容

这是批注功能的核心实现:

function insertAnnotationAtPoint(x, y, annotationHTML) {
  const range = getRangeFromPoint(x, y);
  
  if (!range) return null;
  
  // 创建一个临时标记节点
  const marker = document.createElement('span');
  marker.setAttribute('data-annotation', 'true');
  marker.style.backgroundColor = '#ffeb3b';
  marker.style.cursor = 'pointer';
  
  try {
    // 在 Range 位置插入节点
    range.insertNode(marker);
    
    // 在标记节点中插入批注内容
    marker.innerHTML = annotationHTML;
    
    return marker;
  } catch (e) {
    console.error('插入失败:', e);
    return null;
  }
}

// 使用示例
document.addEventListener('click', (e) => {
  if (e.target.closest('.annotation-btn')) {
    insertAnnotationAtPoint(e.clientX, e.clientY, '这是一个批注✨');
  }
});

3.3 高级:实现精确的文字高亮

结合 createRangeFromPointRange 的扩展方法,可以实现精确的文字高亮:

class TextHighlighter {
  constructor() {
    this.highlights = [];
  }
  
  // 根据坐标点获取选中的文字
  getWordAtPoint(x, y) {
    const range = getRangeFromPoint(x, y);
    if (!range || range.startContainer.nodeType !== Node.TEXT_NODE) return null;
    
    const textNode = range.startContainer;
    const offset = range.startOffset;
    const text = textNode.textContent;
    
    // 扩展选区到完整的单词
    let start = offset;
    let end = offset;
    
    // 向左扩展
    while (start > 0 && /\w/.test(text[start - 1])) {
      start--;
    }
    
    // 向右扩展
    while (end < text.length && /\w/.test(text[end])) {
      end++;
    }
    
    if (start === end) return null;
    
    return {
      textNode,
      start,
      end,
      word: text.substring(start, end)
    };
  }
  
  // 高亮单词
  highlightWordAtPoint(x, y) {
    const wordInfo = this.getWordAtPoint(x, y);
    if (!wordInfo) return null;
    
    const { textNode, start, end, word } = wordInfo;
    
    // 创建高亮范围
    const range = document.createRange();
    range.setStart(textNode, start);
    range.setEnd(textNode, end);
    
    // 用高亮节点包裹
    const highlightSpan = document.createElement('mark');
    highlightSpan.style.backgroundColor = '#ffd700';
    highlightSpan.style.transition = 'all 0.2s';
    
    range.surroundContents(highlightSpan);
    
    this.highlights.push(highlightSpan);
    
    return { word, element: highlightSpan };
  }
  
  // 清除所有高亮
  clearHighlights() {
    this.highlights.forEach(span => {
      const parent = span.parentNode;
      parent.replaceChild(document.createTextNode(span.textContent), span);
    });
    this.highlights = [];
  }
}

// 使用
const highlighter = new TextHighlighter();
document.addEventListener('dblclick', (e) => {
  const result = highlighter.highlightWordAtPoint(e.clientX, e.clientY);
  if (result) {
    console.log(`高亮单词:${result.word}`);
  }
});

四、性能优化:让 API 飞起来

4.1 问题:频繁调用带来的性能损耗

createRangeFromPoint 本身是一个轻量级的 API,但在高频事件(如 mousemove)中调用时,仍然可能带来性能问题。

// ❌ 糟糕的做法
document.addEventListener('mousemove', (e) => {
  const range = getRangeFromPoint(e.clientX, e.clientY);
  // 每次鼠标移动都执行
  updateUI(range);
});

4.2 优化一:防抖与节流

// ✅ 使用节流
const throttle = (fn, delay) => {
  let lastTime = 0;
  return (...args) => {
    const now = Date.now();
    if (now - lastTime >= delay) {
      fn(...args);
      lastTime = now;
    }
  };
};

const handleMouseMove = throttle((e) => {
  const range = getRangeFromPoint(e.clientX, e.clientY);
  if (range) {
    updateUI(range);
  }
}, 50);

document.addEventListener('mousemove', handleMouseMove);

4.3 优化二:虚拟坐标检测

在某些场景下,我们不需要每次鼠标移动都精确计算,可以采用“懒检测”策略:

class LazyPointDetector {
  constructor() {
    this.lastRange = null;
    this.lastPosition = { x: 0, y: 0 };
  }
  
  getRangeAtPoint(x, y, threshold = 10) {
    // 如果移动距离小于阈值,返回缓存的 Range
    const dx = Math.abs(x - this.lastPosition.x);
    const dy = Math.abs(y - this.lastPosition.y);
    
    if (dx < threshold && dy < threshold && this.lastRange) {
      return this.lastRange;
    }
    
    // 更新缓存
    this.lastRange = getRangeFromPoint(x, y);
    this.lastPosition = { x, y };
    
    return this.lastRange;
  }
}

const detector = new LazyPointDetector();

4.4 优化三:使用 requestAnimationFrame

对于动画或拖拽场景,使用 requestAnimationFrame 可以保证性能:

let ticking = false;

document.addEventListener('mousemove', (e) => {
  if (!ticking) {
    requestAnimationFrame(() => {
      const range = getRangeFromPoint(e.clientX, e.clientY);
      if (range) {
        updateUI(range);
      }
      ticking = false;
    });
    ticking = true;
  }
});

五、应用场景:不只是批注

5.1 场景一:在线翻译插件

当用户悬停或选中单词时,即时显示翻译:

class TranslationTooltip {
  constructor() {
    this.tooltip = null;
    this.initTooltip();
  }
  
  initTooltip() {
    this.tooltip = document.createElement('div');
    this.tooltip.style.position = 'absolute';
    this.tooltip.style.background = '#333';
    this.tooltip.style.color = '#fff';
    this.tooltip.style.padding = '4px 8px';
    this.tooltip.style.borderRadius = '4px';
    this.tooltip.style.fontSize = '12px';
    this.tooltip.style.pointerEvents = 'none';
    this.tooltip.style.zIndex = '10000';
    document.body.appendChild(this.tooltip);
  }
  
  async showTranslation(x, y) {
    const range = getRangeFromPoint(x, y);
    if (!range || range.startContainer.nodeType !== Node.TEXT_NODE) {
      this.hide();
      return;
    }
    
    // 获取点击位置的单词
    const word = this.getWordAtRange(range);
    if (!word) return;
    
    // 模拟翻译 API 调用
    const translation = await this.fetchTranslation(word);
    
    // 定位 tooltip
    this.tooltip.textContent = translation;
    this.tooltip.style.left = `${x + 10}px`;
    this.tooltip.style.top = `${y + 10}px`;
    this.tooltip.style.display = 'block';
  }
  
  getWordAtRange(range) {
    const textNode = range.startContainer;
    const offset = range.startOffset;
    const text = textNode.textContent;
    
    // 扩展单词边界
    let start = offset;
    let end = offset;
    while (start > 0 && /[a-zA-Z\u4e00-\u9fa5]/.test(text[start - 1])) start--;
    while (end < text.length && /[a-zA-Z\u4e00-\u9fa5]/.test(text[end])) end++;
    
    return text.substring(start, end);
  }
  
  hide() {
    this.tooltip.style.display = 'none';
  }
}

const translator = new TranslationTooltip();
document.addEventListener('mouseup', (e) => {
  translator.showTranslation(e.clientX, e.clientY);
});

5.2 场景二:富文本编辑器的光标定位

在自定义的富文本编辑器中,实现点击定位光标:

class RichEditor {
  constructor(editorElement) {
    this.editor = editorElement;
    this.editor.contentEditable = true;
  }
  
  // 根据点击坐标设置光标
  setCursorAtPoint(x, y) {
    const range = getRangeFromPoint(x, y);
    if (!range) return false;
    
    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
    
    return true;
  }
  
  // 在光标位置插入内容
  insertAtCursor(html) {
    const selection = window.getSelection();
    if (!selection.rangeCount) return;
    
    const range = selection.getRangeAt(0);
    range.deleteContents();
    
    const fragment = range.createContextualFragment(html);
    range.insertNode(fragment);
    
    // 移动光标到插入内容之后
    range.collapse(false);
    selection.removeAllRanges();
    selection.addRange(range);
  }
}

const editor = new RichEditor(document.getElementById('editor'));
editor.editor.addEventListener('click', (e) => {
  editor.setCursorAtPoint(e.clientX, e.clientY);
});

5.3 场景三:代码编辑器的语法提示

在代码编辑器中,根据鼠标位置显示语法提示:

class CodeEditor {
  constructor(codeMirrorInstance) {
    this.cm = codeMirrorInstance;
    this.suggestions = [];
  }
  
  async showSuggestions(event) {
    const { clientX, clientY } = event;
    
    // 获取坐标对应的位置对象
    const coords = { left: clientX, top: clientY };
    const pos = this.cm.coordsChar(coords, 'page');
    
    // 获取当前 token
    const token = this.cm.getTokenAt(pos);
    const currentWord = token.string;
    
    if (currentWord.length < 2) {
      this.hideSuggestions();
      return;
    }
    
    // 获取语法提示
    const suggestions = await this.fetchCodeSuggestions(currentWord);
    
    // 在鼠标位置显示提示框
    this.renderSuggestions(suggestions, clientX, clientY);
  }
  
  hideSuggestions() {
    // 隐藏提示框
  }
}

5.4 场景四:电子书阅读器的批注系统

完整的批注实现,支持选中和点击两种模式:

class AnnotationSystem {
  constructor() {
    this.annotations = [];
    this.currentMode = 'click'; // 'click' or 'select'
    this.init();
  }
  
  init() {
    document.addEventListener('click', (e) => {
      if (this.currentMode === 'click') {
        this.createClickAnnotation(e);
      }
    });
    
    document.addEventListener('mouseup', (e) => {
      if (this.currentMode === 'select') {
        this.createSelectAnnotation(e);
      }
    });
  }
  
  createClickAnnotation(event) {
    const range = getRangeFromPoint(event.clientX, event.clientY);
    if (!range || range.startContainer.nodeType !== Node.TEXT_NODE) return;
    
    const annotation = {
      id: Date.now(),
      type: 'point',
      position: { x: event.clientX, y: event.clientY },
      range: this.serializeRange(range),
      content: '',
      createdAt: new Date()
    };
    
    this.showAnnotationForm(annotation, event.clientX, event.clientY);
  }
  
  createSelectAnnotation(event) {
    const selection = window.getSelection();
    if (selection.isCollapsed) return;
    
    const range = selection.getRangeAt(0);
    const annotation = {
      id: Date.now(),
      type: 'range',
      text: selection.toString(),
      range: this.serializeRange(range),
      content: '',
      createdAt: new Date()
    };
    
    this.showAnnotationForm(annotation, event.clientX, event.clientY);
    this.highlightRange(range);
  }
  
  serializeRange(range) {
    // 序列化 Range 对象,用于存储
    return {
      startContainerPath: this.getNodePath(range.startContainer),
      startOffset: range.startOffset,
      endContainerPath: this.getNodePath(range.endContainer),
      endOffset: range.endOffset
    };
  }
  
  getNodePath(node) {
    // 获取节点在 DOM 树中的路径
    const path = [];
    while (node && node !== document.body) {
      const parent = node.parentNode;
      if (parent) {
        const index = Array.from(parent.childNodes).indexOf(node);
        path.unshift(index);
      }
      node = parent;
    }
    return path;
  }
  
  showAnnotationForm(annotation, x, y) {
    // 显示批注输入框
    const form = document.createElement('div');
    form.style.position = 'absolute';
    form.style.left = `${x}px`;
    form.style.top = `${y + 20}px`;
    form.style.background = 'white';
    form.style.border = '1px solid #ccc';
    form.style.borderRadius = '8px';
    form.style.padding = '12px';
    form.style.boxShadow = '0 2px 10px rgba(0,0,0,0.1)';
    form.style.zIndex = '1000';
    
    const textarea = document.createElement('textarea');
    textarea.placeholder = '添加批注...';
    textarea.style.width = '200px';
    textarea.style.height = '80px';
    
    const button = document.createElement('button');
    button.textContent = '保存';
    button.onclick = () => {
      annotation.content = textarea.value;
      this.saveAnnotation(annotation);
      form.remove();
    };
    
    form.appendChild(textarea);
    form.appendChild(button);
    document.body.appendChild(form);
  }
  
  saveAnnotation(annotation) {
    this.annotations.push(annotation);
    console.log('批注已保存:', annotation);
    // 这里可以将批注发送到后端
  }
  
  highlightRange(range) {
    // 高亮选中的范围
    const span = document.createElement('span');
    span.style.backgroundColor = '#fff3cd';
    span.style.cursor = 'pointer';
    span.setAttribute('data-annotation-id', Date.now());
    
    try {
      range.surroundContents(span);
    } catch (e) {
      console.warn('无法高亮跨节点范围');
    }
  }
}

const annos = new AnnotationSystem();

六、常见陷阱与解决方案

6.1 陷阱一:点击空白区域返回 null

当点击的位置没有文本节点时,createRangeFromPoint 可能返回 null

解决方案

function getSafeRangeFromPoint(x, y) {
  const range = getRangeFromPoint(x, y);
  if (!range) {
    // 返回一个安全的默认 Range
    const fallbackRange = document.createRange();
    fallbackRange.selectNodeContents(document.body);
    fallbackRange.collapse(true);
    return fallbackRange;
  }
  return range;
}

6.2 陷阱二:跨节点边界处理

surroundContents 方法要求 Range 不能跨节点边界,否则会抛出异常。

解决方案

function safeSurroundContents(range, element) {
  try {
    range.surroundContents(element);
    return true;
  } catch (e) {
    // 跨节点边界时,采用逐节点包裹策略
    const contents = range.extractContents();
    element.appendChild(contents);
    range.insertNode(element);
    return false;
  }
}

6.3 陷阱三:滚动偏移量计算

当页面滚动时,clientX/clientY 相对于视口,而某些场景需要相对于文档的坐标。

解决方案

function getRangeFromDocumentPoint(docX, docY) {
  const x = docX - window.scrollX;
  const y = docY - window.scrollY;
  return getRangeFromPoint(x, y);
}

6.4 陷阱四:iframe 中的坐标转换

在处理 iframe 中的内容时,坐标系统需要转换。

解决方案

function getRangeInIframe(iframe, pageX, pageY) {
  const rect = iframe.getBoundingClientRect();
  const x = pageX - rect.left - window.scrollX;
  const y = pageY - rect.top - window.scrollY;
  
  const iframeDoc = iframe.contentDocument;
  if (iframeDoc.caretRangeFromPoint) {
    return iframeDoc.caretRangeFromPoint(x, y);
  }
  return null;
}

七、未来展望

7.1 CSSOM View Module

W3C 的 CSSOM View Module 正在标准化 caretPositionFromPoint 方法,它将提供更精确的光标位置信息:

// 未来的标准 API
const caretPosition = document.caretPositionFromPoint(x, y);
console.log(caretPosition.offsetNode, caretPosition.offset);

7.2 与 Web Components 的集成

随着 Web Components 的普及,createRangeFromPoint 在 Shadow DOM 中的行为也需要关注:

function getRangeInShadowRoot(x, y, shadowRoot) {
  // 获取坐标对应的元素
  const element = shadowRoot.elementsFromPoint(x, y)[0];
  if (!element) return null;
  
  // 在 Shadow DOM 内部递归查找
  return getRangeFromPoint(x, y);
}

结语

createRangeFromPoint 是一个小而美的 API,它或许不像 React、Vue 那样耀眼,但在文本操作的细分领域,它无可替代。

从最初 IE 的 moveToPoint,到 WebKit 的 caretRangeFromPoint,再到 Firefox 的 createRangeFromPoint,这个 API 见证了一个时代的变迁。如今,它依然是构建富文本编辑器、翻译工具、批注系统的基石。

希望这篇文章能帮助你重新认识这个被忽视的宝藏。下次当你需要根据坐标定位文本时,别忘了还有这样一位老朋友在等着你。


本文首发于掘金,作者:木兮

如果你觉得这篇文章对你有帮助,欢迎点赞、评论、转发!


附录:完整兼容性封装

/**
 * 获取坐标点的光标 Range 对象
 * @param {number} x - 相对于视口的 X 坐标
 * @param {number} y - 相对于视口的 Y 坐标
 * @returns {Range|null}
 */
function getCaretRangeFromPoint(x, y) {
  if (typeof document.caretRangeFromPoint !== 'undefined') {
    return document.caretRangeFromPoint(x, y);
  }
  
  if (typeof document.createRangeFromPoint !== 'undefined') {
    return document.createRangeFromPoint(x, y);
  }
  
  if (typeof document.createRange !== 'undefined') {
    // 降级方案:返回 body 开头的 Range
    const range = document.createRange();
    range.selectNodeContents(document.body);
    range.collapse(true);
    return range;
  }
  
  return null;
}