跟着 MDN 学 HTML day_32:(AbstractRange 抽象接口与 DOM 范围操作)

AbstractRange 是一个抽象接口,它是所有 DOM 范围类型的基础。范围(Range)是文档中一段连续内容的起点和终点的标识。理解 AbstractRange 及其派生接口,对于实现文本选择、内容复制、富文本编辑器等复杂功能至关重要。

一、AbstractRange 的基本概念

AbstractRange 是一个抽象接口,不能直接实例化。它定义了所有范围对象共有的只读属性。实际开发中,我们使用的是继承自它的 Range 和 StaticRange 两个具体接口。Range 支持动态修改,而 StaticRange 创建后不可变。

javascript 复制代码
// 演示 AbstractRange 的抽象特性
function demonstrateAbstractRange() {
  // 不能直接实例化 AbstractRange,会抛出错误
  try {
    const abstractRange = new AbstractRange();
    console.log('这行不会执行');
  } catch (error) {
    console.log('AbstractRange 无法直接实例化:', error.message);
  }
  
  // 正确的方式:使用 Range 或 StaticRange
  const range = document.createRange();  // 返回 Range 实例
  const staticRange = new StaticRange({
    startContainer: document.body,
    startOffset: 0,
    endContainer: document.body,
    endOffset: 0
  });
  
  console.log('range 是否是 AbstractRange 的实例:', range instanceof AbstractRange); // true
  console.log('staticRange 是否是 AbstractRange 的实例:', staticRange instanceof AbstractRange); // true
  console.log('range 的类型:', range.constructor.name); // Range
  console.log('staticRange 的类型:', staticRange.constructor.name); // StaticRange
}

// 检查范围对象的基本属性
function inspectRangeProperties() {
  const selection = window.getSelection();
  if (selection && selection.rangeCount > 0) {
    const range = selection.getRangeAt(0);
    
    // 这些属性都继承自 AbstractRange
    console.log('范围是否折叠:', range.collapsed);
    console.log('起始容器:', range.startContainer);
    console.log('起始偏移:', range.startOffset);
    console.log('结束容器:', range.endContainer);
    console.log('结束偏移:', range.endOffset);
  } else {
    console.log('页面上没有选中的内容');
  }
}

demonstrateAbstractRange();

理解 AbstractRange 的关键在于认识到它是一个"只读视图"。它描述了范围的位置信息,但不提供修改这些位置的方法。修改能力是由 Range 接口额外提供的。

二、AbstractRange 的实例属性详解

AbstractRange 定义了五个只读属性,用于描述范围的位置和状态。这些属性是所有范围对象共有的基础信息。

javascript 复制代码
// 全面演示 AbstractRange 的各个属性
function demonstrateRangeProperties() {
  // 准备测试用的 DOM 结构
  const container = document.getElementById('testContainer');
  if (!container) {
    const div = document.createElement('div');
    div.id = 'testContainer';
    div.innerHTML = '<p><strong>Hello</strong> <em>World</em>!</p>';
    document.body.appendChild(div);
  }
  
  const testDiv = document.getElementById('testContainer');
  const firstP = testDiv.querySelector('p');
  const textNode = firstP.firstChild; // <strong> 内的文本节点 "Hello"
  
  // 创建一个范围,选中 "Hello" 中的 "ell"
  const range = document.createRange();
  range.setStart(textNode, 1);  // 从索引 1 开始('e')
  range.setEnd(textNode, 4);    // 到索引 4 结束('l' 之后)
  
  // 演示 collapsed 属性
  console.log('范围是否折叠(start 和 end 相同):', range.collapsed); // false
  
  // 创建一个折叠的范围(光标位置)
  const collapsedRange = document.createRange();
  collapsedRange.setStart(textNode, 2);
  collapsedRange.setEnd(textNode, 2);
  console.log('折叠范围 collapsed:', collapsedRange.collapsed); // true
  
  // 演示 startContainer 和 startOffset
  console.log('起始容器节点类型:', range.startContainer.nodeType); // 3 (TEXT_NODE)
  console.log('起始容器内容:', range.startContainer.textContent); // "Hello"
  console.log('起始偏移位置:', range.startOffset); // 1
  
  // 演示 endContainer 和 endOffset
  console.log('结束容器节点类型:', range.endContainer.nodeType); // 3
  console.log('结束容器内容:', range.endContainer.textContent); // "Hello"
  console.log('结束偏移位置:', range.endOffset); // 4
  
  // 实用函数:获取范围内容的文本
  function getRangeText(range) {
    const fragment = range.cloneContents();
    const tempDiv = document.createElement('div');
    tempDiv.appendChild(fragment);
    return tempDiv.textContent;
  }
  
  console.log('选中的文本内容:', getRangeText(range)); // "ell"
}

// 处理文本节点偏移量的注意事项
function textNodeOffsetExample() {
  const element = document.createElement('div');
  element.innerHTML = 'Hello <span>World</span>!';
  document.body.appendChild(element);
  
  // 获取文本节点
  const textNode = element.firstChild; // "Hello " 文本节点
  console.log('文本节点内容:', textNode.textContent); // "Hello "
  console.log('文本节点长度:', textNode.length); // 6
  
  // 创建范围 - 正确获取文本节点内的偏移
  const range = document.createRange();
  range.setStart(textNode, 0);  // 从 "Hello " 的开头
  range.setEnd(textNode, 5);     // 到 "Hello" 的末尾(不包括空格)
  
  // 获取选中内容
  const selectedText = range.toString();
  console.log('选中的文本:', selectedText); // "Hello"
  
  // 清理
  element.remove();
}

demonstrateRangeProperties();
textNodeOffsetExample();

属性 collapsed 特别有用,它可以判断用户是否只是放置了光标而没有选中任何内容。startContainer 和 endContainer 可以是不同类型的节点,包括元素节点、文本节点和注释节点。

三、Range 与 StaticRange 的区别与选择

Range 和 StaticRange 是 AbstractRange 的两个具体实现。Range 是"活"的范围,会随着 DOM 的变化而自动调整;StaticRange 是"快照",创建后完全不受 DOM 变化的影响。

javascript 复制代码
// 对比 Range 和 StaticRange 的行为差异
function compareRangeAndStaticRange() {
  // 准备测试 DOM
  const container = document.createElement('div');
  container.id = 'comparisonContainer';
  container.innerHTML = '<p>原始内容</p>';
  document.body.appendChild(container);
  
  const originalP = container.querySelector('p');
  const textNode = originalP.firstChild;
  
  // 创建 Range(动态)
  const dynamicRange = document.createRange();
  dynamicRange.setStart(textNode, 0);
  dynamicRange.setEnd(textNode, 2);
  
  // 创建 StaticRange(静态快照)
  const staticRange = new StaticRange({
    startContainer: textNode,
    startOffset: 0,
    endContainer: textNode,
    endOffset: 2
  });
  
  console.log('=== 创建时的状态 ===');
  console.log('dynamicRange 内容:', dynamicRange.toString()); // "原始"
  console.log('staticRange 内容:', staticRange.toString());   // "原始"
  
  // 修改 DOM 内容
  textNode.textContent = '修改后的内容';
  
  console.log('=== DOM 修改后的状态 ===');
  console.log('dynamicRange 内容(自动更新):', dynamicRange.toString()); // "修改"
  console.log('staticRange 内容(保持不变):', staticRange.toString());   // "原始"
  
  // Range 可以通过方法修改端点
  dynamicRange.setEnd(textNode, 3);
  console.log('dynamicRange 修改后:', dynamicRange.toString()); // "修改后"
  
  // StaticRange 是只读的,不能修改
  try {
    staticRange.setEnd(textNode, 3);
    console.log('这行不会执行');
  } catch (error) {
    console.log('StaticRange 不可修改:', error.message);
  }
  
  // 清理
  container.remove();
}

// 性能对比演示
async function performanceComparison() {
  const container = document.createElement('div');
  container.innerHTML = '<div id="perfTest">' + '内容 '.repeat(1000) + '</div>';
  document.body.appendChild(container);
  
  const targetNode = container.querySelector('#perfTest');
  const textNode = targetNode.firstChild;
  
  // 测试 Range 创建性能
  console.time('Range 创建');
  for (let i = 0; i < 1000; i++) {
    const range = document.createRange();
    range.setStart(textNode, 0);
    range.setEnd(textNode, 10);
    const content = range.cloneContents();
  }
  console.timeEnd('Range 创建');
  
  // 测试 StaticRange 创建性能
  console.time('StaticRange 创建');
  for (let i = 0; i < 1000; i++) {
    const staticRange = new StaticRange({
      startContainer: textNode,
      startOffset: 0,
      endContainer: textNode,
      endOffset: 10
    });
    const content = staticRange.cloneContents();
  }
  console.timeEnd('StaticRange 创建');
  
  container.remove();
}

// 选择建议
function selectionGuidelines() {
  console.log('=== 选择 Range 还是 StaticRange ===');
  console.log('使用 Range 的场景:');
  console.log('1. 需要动态调整范围端点(如拖拽选择)');
  console.log('2. 范围内容会随着 DOM 变化自动更新');
  console.log('3. 需要调用 range 的修改方法如 setStart/setEnd');
  console.log('4. 实现富文本编辑器的选区操作');
  
  console.log('使用 StaticRange 的场景:');
  console.log('1. 只需要一次性读取范围内容');
  console.log('2. 对性能有较高要求的批量操作');
  console.log('3. 需要在 Web Worker 中处理范围数据');
  console.log('4. 创建后不再需要修改范围');
}

compareRangeAndStaticRange();
performanceComparison();
selectionGuidelines();

Range 适合需要与用户交互、动态更新的场景。StaticRange 则更适合一次性读取、对性能敏感的场景。StaticRange 的另一个优势是可以在 Web Worker 中使用,而 Range 依赖 DOM 环境。

四、创建和操作范围的完整示例

虽然 AbstractRange 本身不提供操作方法,但通过 Range 接口我们可以执行创建、修改、删除等各种范围操作。

javascript 复制代码
// 创建范围的各种方式
function createRangesDemos() {
  const container = document.createElement('div');
  container.innerHTML = `
    <div id="demoArea">
      <h2>标题内容</h2>
      <p>这是第一个段落,包含<strong>加粗文字</strong>和普通文字。</p>
      <p>第二个段落,<em>斜体文字</em>在这里。</p>
      <ul>
        <li>列表项一</li>
        <li>列表项二</li>
      </ul>
    </div>
  `;
  document.body.appendChild(container);
  
  const demoArea = document.getElementById('demoArea');
  const h2 = demoArea.querySelector('h2');
  const firstP = demoArea.querySelector('p');
  const strong = firstP.querySelector('strong');
  const strongText = strong.firstChild;
  const pTextNode = firstP.childNodes[1]; // 第一个段落中的文本节点
  
  // 方式1:使用 setStart 和 setEnd
  const range1 = document.createRange();
  range1.setStart(strongText, 0);
  range1.setEnd(strongText, 2);
  console.log('范围1(选中"加粗"中的"加"字):', range1.toString()); // "加粗" 的前两个字符
  
  // 方式2:使用 setStartBefore 和 setEndAfter
  const range2 = document.createRange();
  range2.setStartBefore(strong);
  range2.setEndAfter(strong);
  console.log('范围2(整个strong元素):', range2.toString()); // "加粗文字"
  
  // 方式3:使用 selectNode 和 selectNodeContents
  const range3 = document.createRange();
  range3.selectNode(firstP);
  console.log('范围3(整个段落元素):');
  console.log('起始容器:', range3.startContainer.nodeName);
  console.log('结束容器:', range3.endContainer.nodeName);
  
  const range4 = document.createRange();
  range4.selectNodeContents(firstP);
  console.log('范围4(段落内容,不包括标签):', range4.toString());
  
  // 方式4:使用 setStartAfter 和 setEndBefore
  const range5 = document.createRange();
  range5.setStartAfter(h2);
  range5.setEndBefore(demoArea.querySelector('ul'));
  console.log('范围5(从标题后到列表前):', range5.toString());
  
  // 获取范围中选中的文本
  function getSelectedText() {
    const selection = window.getSelection();
    if (selection.rangeCount > 0) {
      return selection.getRangeAt(0).toString();
    }
    return '';
  }
  
  container.remove();
}

// 修改范围的操作
function modifyRangeDemo() {
  const container = document.createElement('div');
  container.innerHTML = '<p id="modifyTest">这是一段测试文本,用于演示范围修改。</p>';
  document.body.appendChild(container);
  
  const p = document.getElementById('modifyTest');
  const textNode = p.firstChild;
  
  let range = document.createRange();
  range.setStart(textNode, 0);
  range.setEnd(textNode, 6);
  console.log('原始范围:', range.toString()); // "这是一段测试"
  
  // 修改起始位置
  range.setStart(textNode, 3);
  console.log('修改起始位置后:', range.toString()); // "段测试"
  
  // 修改结束位置
  range.setEnd(textNode, 6);
  console.log('修改结束位置后:', range.toString()); // "段测"
  
  // 折叠范围(将端点对齐)
  range.collapse(true); // true 表示折叠到起点
  console.log('折叠后是否 collapsed:', range.collapsed); // true
  console.log('折叠后的位置:', range.startOffset); // 3
  
  // 重新设置范围
  range.setStart(textNode, 0);
  range.setEnd(textNode, 10);
  
  // 删除范围内容
  const beforeDelete = range.toString();
  range.deleteContents();
  console.log('删除内容后剩余:', p.textContent);
  
  // 从范围创建新的 DOM 节点
  const newRange = document.createRange();
  newRange.setStart(textNode, 0);
  newRange.setEnd(textNode, 2);
  const fragment = newRange.cloneContents();
  console.log('克隆的内容:', fragment.textContent);
  
  container.remove();
}

createRangesDemos();
modifyRangeDemo();

掌握这些创建和修改范围的方法,可以实现文本高亮、部分内容提取、智能粘贴等高级功能。

五、跨节点范围的深入理解

范围的一个重要特性是可以跨越多个节点,甚至跨越不同的层级。理解 DOM 树结构对于正确创建复杂范围至关重要。

javascript 复制代码
// 跨节点范围的高级示例
function crossNodeRangeDemo() {
  // 构建复杂的 DOM 结构
  const container = document.createElement('div');
  container.className = 'article';
  container.innerHTML = `
    <section>
      <h2>第一章:<span>引言</span></h2>
      <p>这是一个<strong>非常重要</strong>的概念,需要<em>深入理解</em>。</p>
      <p>第二段内容,包含<a href="#">链接</a>和普通文本。</p>
    </section>
  `;
  document.body.appendChild(container);
  
  const section = container.querySelector('section');
  const h2 = section.querySelector('h2');
  const h2TextNode = h2.firstChild; // "第一章:" 文本节点
  const span = section.querySelector('span');
  const spanTextNode = span.firstChild; // "引言" 文本节点
  
  const firstP = section.querySelector('p');
  const strong = firstP.querySelector('strong');
  const strongTextNode = strong.firstChild; // "非常重要"
  const em = firstP.querySelector('em');
  const emTextNode = em.firstChild; // "深入理解"
  
  // 创建一个从 "章:" 之后到 "理解" 之前的范围
  const complexRange = document.createRange();
  complexRange.setStart(h2TextNode, 2);  // "第一章:" 中,从索引2之后("章"之后)
  complexRange.setEnd(emTextNode, 2);    // "深入理解" 中,只取前两个字
  
  console.log('跨节点范围的内容:', complexRange.toString());
  // 预期输出包含: "引言这是一个非常重要"
  
  // 提取范围内容并查看结构
  const fragment = complexRange.cloneContents();
  
  // 分析提取出的文档片段结构
  function analyzeFragment(fragment) {
    const walker = document.createTreeWalker(
      fragment,
      NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
    );
    
    const nodes = [];
    let node;
    while (node = walker.nextNode()) {
      nodes.push({
        type: node.nodeType === 1 ? 'Element' : 'Text',
        name: node.nodeName,
        content: node.textContent?.substring(0, 20)
      });
    }
    return nodes;
  }
  
  console.log('提取的片段结构:', analyzeFragment(fragment));
  // 结果会显示为了保持结构完整性而自动补充的节点
  
  // 演示范围折叠时的 DOM 树关系
  function getCommonAncestor(range) {
    let startNode = range.startContainer;
    let endNode = range.endContainer;
    
    while (startNode !== endNode) {
      if (startNode.compareDocumentPosition(endNode) & Node.DOCUMENT_POSITION_FOLLOWING) {
        startNode = startNode.parentNode;
      } else {
        endNode = endNode.parentNode;
      }
    }
    return startNode;
  }
  
  const commonAncestor = getCommonAncestor(complexRange);
  console.log('范围的公共祖先节点:', commonAncestor.nodeName); // 应该是 section 或 p
  
  container.remove();
}

// 处理范围边界的实用函数
function rangeBoundaryUtilities() {
  // 获取范围起始位置的前一个字符
  function getPreviousCharacter(range) {
    const container = range.startContainer;
    const offset = range.startOffset;
    
    if (container.nodeType === Node.TEXT_NODE && offset > 0) {
      return container.textContent[offset - 1];
    }
    
    if (offset > 0) {
      const previousNode = container.childNodes[offset - 1];
      if (previousNode && previousNode.nodeType === Node.TEXT_NODE) {
        return previousNode.textContent.slice(-1);
      }
    }
    
    return null;
  }
  
  // 获取范围结束位置的后一个字符
  function getNextCharacter(range) {
    const container = range.endContainer;
    const offset = range.endOffset;
    
    if (container.nodeType === Node.TEXT_NODE && offset < container.length) {
      return container.textContent[offset];
    }
    
    if (offset < container.childNodes.length) {
      const nextNode = container.childNodes[offset];
      if (nextNode && nextNode.nodeType === Node.TEXT_NODE) {
        return nextNode.textContent[0];
      }
    }
    
    return null;
  }
  
  // 判断范围是否完全在一个元素内
  function isRangeWithinElement(range, element) {
    return element.contains(range.startContainer) && 
           element.contains(range.endContainer);
  }
  
  console.log('实用函数已定义,可以在实际选择中使用');
}

crossNodeRangeDemo();
rangeBoundaryUtilities();

理解范围在 DOM 树中的表现是掌握复杂范围操作的关键。当范围跨越节点边界时,提取的内容会自动补全必要的父节点结构以保持文档的完整性。

六、实际应用场景示例

范围 API 在实际开发中有众多应用场景,从简单的文本复制到复杂的富文本编辑器。

javascript 复制代码
// 实现文本高亮功能
function textHighlighter() {
  class TextHighlighter {
    constructor(containerId) {
      this.container = document.getElementById(containerId);
      this.highlights = [];
    }
    
    highlightCurrentSelection(className = 'highlight') {
      const selection = window.getSelection();
      if (selection.isCollapsed) {
        console.log('没有选中任何文本');
        return null;
      }
      
      const range = selection.getRangeAt(0);
      
      // 创建一个高亮 span
      const span = document.createElement('span');
      span.className = className;
      
      // 用高亮 span 包裹选中的内容
      range.surroundContents(span);
      
      // 保存高亮信息
      const highlightInfo = {
        element: span,
        text: range.toString(),
        timestamp: Date.now()
      };
      this.highlights.push(highlightInfo);
      
      // 清除选区
      selection.removeAllRanges();
      
      return highlightInfo;
    }
    
    removeAllHighlights() {
      this.highlights.forEach(highlight => {
        const parent = highlight.element.parentNode;
        while (highlight.element.firstChild) {
          parent.insertBefore(highlight.element.firstChild, highlight.element);
        }
        parent.removeChild(highlight.element);
      });
      this.highlights = [];
    }
    
    extractHighlightedText() {
      return this.highlights.map(h => h.text);
    }
  }
  
  // 创建测试界面
  const container = document.createElement('div');
  container.id = 'highlighterDemo';
  container.innerHTML = `
    <div style="border:1px solid #ccc; padding:10px; margin:10px">
      <p>这是一段可以高亮的文本。你可以选中任意文字,然后点击高亮按钮。</p>
      <p>第二个段落同样支持高亮功能,<strong>加粗文字</strong>也可以被高亮。</p>
      <button id="highlightBtn">高亮选中文本</button>
      <button id="clearBtn">清除所有高亮</button>
      <button id="extractBtn">提取高亮文本</button>
      <div id="extractedResult" style="margin-top:10px; background:#f0f0f0"></div>
    </div>
  `;
  document.body.appendChild(container);
  
  const highlighter = new TextHighlighter('highlighterDemo');
  
  // 注意:需要使用实际的 container 元素,这里简化演示逻辑
  document.getElementById('highlightBtn')?.addEventListener('click', () => {
    const style = document.createElement('style');
    style.textContent = '.highlight { background-color: yellow; }';
    if (!document.querySelector('style[data-highlight]')) {
      style.setAttribute('data-highlight', 'true');
      document.head.appendChild(style);
    }
    
    const selection = window.getSelection();
    if (!selection.isCollapsed) {
      const range = selection.getRangeAt(0);
      const span = document.createElement('span');
      span.className = 'highlight';
      try {
        range.surroundContents(span);
        selection.removeAllRanges();
        console.log('高亮成功');
      } catch (e) {
        console.log('无法高亮跨元素的选择:', e.message);
      }
    }
  });
}

// 实现文本锚点链接(滚动到指定位置)
function textAnchorDemo() {
  class TextAnchor {
    static createAnchorFromSelection(anchorId) {
      const selection = window.getSelection();
      if (selection.isCollapsed) {
        throw new Error('请先选中要锚定的文本');
      }
      
      const range = selection.getRangeAt(0);
      
      // 创建锚点元素
      const anchor = document.createElement('span');
      anchor.id = anchorId;
      anchor.className = 'text-anchor';
      
      try {
        range.surroundContents(anchor);
        return anchorId;
      } catch (e) {
        // 如果选中内容跨节点,使用备用方案
        const fragment = range.cloneContents();
        const tempDiv = document.createElement('div');
        tempDiv.appendChild(fragment);
        anchor.textContent = tempDiv.textContent;
        range.deleteContents();
        range.insertNode(anchor);
        return anchorId;
      }
    }
    
    static scrollToAnchor(anchorId, behavior = 'smooth') {
      const anchor = document.getElementById(anchorId);
      if (anchor) {
        anchor.scrollIntoView({ behavior, block: 'center' });
        // 添加临时高亮效果
        anchor.style.transition = 'background-color 0.5s';
        anchor.style.backgroundColor = '#ffff99';
        setTimeout(() => {
          anchor.style.backgroundColor = '';
        }, 2000);
        return true;
      }
      return false;
    }
  }
  
  // 添加演示样式
  const style = document.createElement('style');
  style.textContent = '.text-anchor { display: inline-block; }';
  document.head.appendChild(style);
  
  console.log('TextAnchor 类已定义,可以使用 TextAnchor.createAnchorFromSelection() 创建锚点');
}

textHighlighter();
textAnchorDemo();

范围 API 是实现这些功能的基础。surroundContents 方法可以用新元素包裹范围内容,提取和插入操作可以实现复杂的文本处理功能。

七、浏览器兼容性与注意事项

AbstractRange 及其派生接口在现代浏览器中支持良好。Chrome 90、Firefox 69、Safari 14.1 及以上版本完全支持。

javascript 复制代码
// 兼容性检测和降级方案
function rangeCompatibilityCheck() {
  const compat = {
    abstractRange: typeof AbstractRange !== 'undefined',
    range: typeof Range !== 'undefined',
    staticRange: typeof StaticRange !== 'undefined',
    rangeMethods: {
      surroundContents: false,
      cloneContents: false,
      deleteContents: false,
      extractContents: false
    }
  };
  
  // 检测 Range 方法
  if (typeof Range !== 'undefined') {
    const testRange = document.createRange();
    compat.rangeMethods.surroundContents = typeof testRange.surroundContents === 'function';
    compat.rangeMethods.cloneContents = typeof testRange.cloneContents === 'function';
    compat.rangeMethods.deleteContents = typeof testRange.deleteContents === 'function';
    compat.rangeMethods.extractContents = typeof testRange.extractContents === 'function';
  }
  
  console.log('兼容性检测结果:', compat);
  return compat;
}

// 跨节点 surroundContents 的替代方案
function safeSurroundContents(range, wrapperElement) {
  try {
    // 尝试直接使用 surroundContents
    range.surroundContents(wrapperElement);
    return true;
  } catch (e) {
    if (e.name === 'InvalidStateError') {
      console.log('范围跨越多个节点边界,使用替代方案');
      
      // 替代方案:提取内容,删除原内容,插入包装后的内容
      const fragment = range.extractContents();
      wrapperElement.appendChild(fragment);
      range.insertNode(wrapperElement);
      return true;
    }
    throw e;
  }
}

// 创建 StaticRange 时的注意事项
function createStaticRangeSafely(startContainer, startOffset, endContainer, endOffset) {
  // 验证参数有效性
  function isValidOffset(node, offset) {
    if (node.nodeType === Node.TEXT_NODE) {
      return offset >= 0 && offset <= node.length;
    }
    return offset >= 0 && offset <= node.childNodes.length;
  }
  
  if (!isValidOffset(startContainer, startOffset) || 
      !isValidOffset(endContainer, endOffset)) {
    throw new Error('无效的范围偏移量');
  }
  
  // 检查 StaticRange 是否可用
  if (typeof StaticRange !== 'undefined') {
    return new StaticRange({
      startContainer,
      startOffset,
      endContainer,
      endOffset
    });
  }
  
  // 降级方案:使用 Range 并冻结
  const range = document.createRange();
  range.setStart(startContainer, startOffset);
  range.setEnd(endContainer, endOffset);
  
  // 返回一个只读代理
  return new Proxy(range, {
    set(target, prop) {
      if (prop === 'startContainer' || prop === 'endContainer' || 
          prop === 'startOffset' || prop === 'endOffset') {
        throw new Error('此范围是只读的');
      }
      target[prop] = arguments[1];
      return true;
    },
    get(target, prop) {
      const value = target[prop];
      if (typeof value === 'function') {
        if (prop === 'setStart' || prop === 'setEnd' || prop === 'setStartBefore' ||
            prop === 'setEndBefore' || prop === 'setStartAfter' || prop === 'setEndAfter' ||
            prop === 'selectNode' || prop === 'selectNodeContents') {
          throw new Error('此范围是只读的');
        }
        return value.bind(target);
      }
      return value;
    }
  });
}

rangeCompatibilityCheck();

使用范围 API 时需要注意:处理文本节点时偏移量不能超过节点长度;surroundContents 方法对跨节点范围有限制;StaticRange 创建后完全不可变;删除范围内容后需要小心处理 DOM 引用。


想要解锁更多HTML 核心标签实战、前端零基础入门干货、开发避坑全指南吗?
持续关注,后续将更新CSS 布局实战、JavaScript 交互基础、全站导航开发等硬核内容,带你从新手快速进阶,轻松搞定前端开发!

相关推荐
十子木1 小时前
设置把所有终端移动到最前端的快捷键
前端
陈老老老板1 小时前
Bright Data Web Scraping 实战:用 MCP + Dify 构建 eBay 商品详情采集 AI 工作流(2026)
前端·人工智能
一渊之隔1 小时前
uniapp蓝牙搜索连接展示蓝牙设备包含信号显示
前端·网络·uni-app·bluetooth
Cisyam^2 小时前
Bright Data Web Scraper 实战:构建 TikTok 与 LinkedIn Web Scraping 自动化 Skill(2026)
运维·前端·自动化
李剑一2 小时前
开箱即用!Vue3+TS 视频组件完整代码,自动提取视频第一帧做封面。妈妈再也不用担心我手动截封面了
前端
阿赛工作室2 小时前
PageAgent的价值和使用示例
javascript·html5
开开心心就好2 小时前
支持音视频图片文档的格式转换器
人工智能·学习·游戏·决策树·音视频·动态规划·语音识别
盐多碧咸。。2 小时前
echarts折线图矩形选择 框选图表
前端·javascript·echarts
羽沢312 小时前
Canvas学习一
前端·css·学习·canvas