精细化控制dom-DOM2 Traversal and Range模块

75 阅读10分钟

DOM范围

DOM2在Document类型上定义了一个****createRange()方法,暴露在document对象上。使用这个方法可以创建一个DOM范围对象,

    let range = document.createRange();

PowerShell

这样创建了一个范围对象,关键属性有:

startContainer:范围起点所在的节点(选区中第一个子节点的父节点)​。

startOffset:范围起点在startContainer中的偏移量。如果startContainer是文本节点、注释节点或CData区块节点,则*startOffset: 指范围起点之前跳过的字符数;否则,表示范围中第一个节点的索引。

endContainer:范围终点所在的节点(选区中最后一个子节点的父节点)​。

endOffset:范围起点在endContainer中的偏移量(与startOffset中偏移量的含义相同)​。

commonAncestorContainer:文档中以startContainer和endContainer为后代的最深的节点。

简单选择

通过范围选择文档中某个部分最简单的方式,就是使用selectNode()或selectNodeContents()方法。这两个方法都接收一个节点作为参数,并将该节点的信息添加到调用它的范围。selectNode()方法选择整个节点,包括其后代节点,而selectNodeContents()只选择节点的后代。

<!DOCTYPE html>

<html lang="en">

  <head>

    <meta charset="UTF-8" />

    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <title>Document</title>

  </head>

  <body>

        <br><p id="p1"><b>Hello</b> world! </p>



    <script>

      let range1 = document.createRange(),

        range2 = document.createRange(),

        p1 = document.getElementById("p1");

      range1.selectNode(p1);

      range2.selectNodeContents(p1);

      console.log(range1);

      console.log(range2);

      console.log(document.body.childNodes);

      console.log(p1.childNodes);

    </script>

  </body>

</html>

HTML, XML

可以很明显的观察到差异

打印的数据有一个知识点,我们的html文档中一个或多个空格都会变成一个文本节点,除非我们把标签全部粘着写,这里p1的startOffset为2是因为前面有一个连写的br标签,同时br和父节点body中间有空格

endOffset的计算偏移量有个加1的概念,因为自身本身是一个节点(从父节点开始偏移,偏移了三个节点),

同样的p1.childNodes也能看出来,以自己为起点startOffset是0,endOffset是2(它从自身开始偏移,偏移了两个子节点).

在像上面这样选定节点或节点后代之后,还可以在范围上调用相应的方法,实现对范围中选区的更精细控制。

setStartBefore(refNode)​,把范围的起点设置到refNode之前,从而让refNode成为选区的第一个子节点。startContainer属性被设置为refNode.parentNode,而startOffset属性被设置为refNode在其父节点childNodes集合中的索引。

setStartAfter(refNode)​,把范围的起点设置到refNode之后,从而将refNode排除在选区之外,让其下一个同胞节点成为选区的第一个子节点。startContainer属性被设置为refNode.parentNode, startOffset属性被设置为refNode在其父节点childNodes集合中的索引加1。

setEndBefore(refNode)​,把范围的终点设置到refNode之前,从而将refNode排除在选区之外、让其上一个同胞节点成为选区的最后一个子节点。endContainer属性被设置为refNode. parentNode, endOffset属性被设置为refNode在其父节点childNodes集合中的索引。

setEndAfter(refNode)​,把范围的终点设置到refNode之后,从而让refNode成为选区的最后一个子节点。endContainer属性被设置为refNode.parentNode, endOffset属性被设置为refNode在其父节点childNodes集合中的索引加1。

调用这些方法时,所有属性都会自动重新赋值。不过,为了实现复杂的选区,也可以直接修改这些属性的值。

复杂选择

要创建复杂的范围,需要使用setStart()和setEnd()方法。这两个方法都接收两个参数:参照节点和偏移量。对setStart()来说,参照节点会成为startContainer,而偏移量会赋值给startOffset。对setEnd()而言,参照节点会成为endContainer,而偏移量会赋值给endOffset。

使用这两个方法,可以模拟selectNode()和selectNodeContents()的行为。比如:

let range1 = document.createRange(),

    range2 = document.createRange(),

    p1 = document.getElementById("p1"),

    p1Index = Array.from(p1.parentNode.childNodes).findIndex(node => node === p1);



range1.setStart(p1.parentNode, p1Index);

range1.setEnd(p1.parentNode, p1Index + 1);

range2.setStart(p1, 0);

range2.setEnd(p1, p1.childNodes.length);

JavaScript

虽然可以模拟selectNode()和selectNodeContents(),但setStart()和setEnd()真正的威力还是选择节点中的某个部分。假设我们想通过范围从前面示例中选择从"Hello"中的"llo"到" world! "中的"o"的部分。很简单,第一步是取得所有相关节点的引用,如下面的代码所示:

   let p1 = document.getElementById("p1"),

        helloNode = p1.firstChild.firstChild,

        worldNode = p1.lastChild;

      let range = document.createRange();

      range.setStart(helloNode, 2);

      range.setEnd(worldNode, 3);

JavaScript

因为选区起点在"Hello"中的字母"e"之后,所以要给setStart()传入helloNode和偏移量2("e"后面的位置,"H"的位置是0)​。要设置选区终点,则要给setEnd()传入worldNode和偏移量3,即不属于选区的第一个字符的位置,也就是"r"的位置3(位置0是一个空格)​。

操作范围

创建范围之后,浏览器会在内部创建一个文档片段节点,用于包含范围选区中的节点。为操作范围的内容,选区中的内容必须格式完好。在前面的例子中,因为范围的起点和终点都在文本节点内部,并不是完好的DOM结构,所以无法在DOM中表示。不过,范围能够确定缺失的开始和结束标签,从而可以重构出有效的DOM结构,以便后续操作。

仍以前面例子中的范围来说,范围发现选区中缺少一个开始的标签,于是会在后台动态补上这个标签,同时还需要补上封闭"He"的结束标签,结果会把DOM修改为这样:

    <p><b>He</b><b>llo</b> world! </p>

HTML, XML

而且," world! "文本节点会被拆分成两个文本节点,一个包含" wo",另一个包含"rld! "。最终的DOM树,以及范围对应的文档片段如图所示。

这样创建了范围之后,就可以使用很多方法来操作范围的内容。​(注意,范围对应文档片段中的所有节点,都是文档中相应节点的指针。​)

deleteContents():这个方法会从文档中删除范围包含的节点。

    let p1 = document.getElementById("p1"),

        helloNode = p1.firstChild.firstChild,

        worldNode = p1.lastChild,

        range = document.createRange();

    range.setStart(helloNode, 2);

    range.setEnd(worldNode, 3);

    range.deleteContents();

JavaScript

执行结束后dom会变化

    <p><b>He</b>rld! </p>

HTML, XML

extractContents():从文档中剪切出范围对应的文档片段并返回

    let p1 = document.getElementById("p1"),

        helloNode = p1.firstChild.firstChild,

        worldNode = p1.lastChild,

        range = document.createRange();

    range.setStart(helloNode, 2);

    range.setEnd(worldNode, 3);

    let fragment=range.extractContents();

    p1.parentNode.appendChild(fragment);

JavaScript

fragment 常用场景便是做批量的dom插入,由于他的特性只会插入自己的子树,所以最后的html如下

    <p><b>He</b>rld! </p>

    <b>llo</b> wo

HTML, XML

如果不想把范围从文档中移除,也可以使用cloneContents()创建一个副本,然后把这个副本插入到文档其他地方。

    let p1 = document.getElementById("p1"),

        helloNode = p1.firstChild.firstChild,

        worldNode = p1.lastChild,

        range = document.createRange();

    range.setStart(helloNode, 2);

    range.setEnd(worldNode, 3);

    letfragment=range.cloneContents();

    p1.parentNode.appendChild(fragment);

JavaScript

这个方法跟extractContents()很相似,因为它们都返回文档片段。主要区别是cloneContents()返回的文档片段包含范围中节点的副本,而非实际的节点。执行上面操作之后,HTML页面会变成这样:

    <p><b>Hello</b> world! </p>

    <b>llo</b> wo

HTML, XML

此时关键是要知道,为保持结构完好而拆分节点的操作,只有在调用前述方法时才会发生。在DOM被修改之前,原始HTML会一直保持不变(之前只是各种选中操作,真正操作范围后才会影响dom)。

范围插入

insertNode():可以在范围选区的开始位置插入一个节点

例如,假设我们想在前面例子中的HTML中插入如下HTML:

    <span style="color: red">Inserted text</span>

HTML, XML

    let p1 = document.getElementById("p1"),

        helloNode = p1.firstChild.firstChild,

        worldNode = p1.lastChild,

        range = document.createRange();

    range.setStart(helloNode, 2);

    range.setEnd(worldNode, 3);

    letspan=document.createElement("span");

    span.style.color="red";

    span.appendChild(document.createTextNode("Insertedtext"));

    range.insertNode(span);
    

JavaScript

结果:

    <p id="p1"><b>He<span style="color: red">Inserted text</span>llo</b> world</p>

HTML, XML

注意,正好插入到"Hello"中的"llo"之前,也就是范围选区的前面。同时,也要注意原始的HTML并没有添加或删除元素,因为这里并没有使用之前提到的方法。使用这个技术可以插入有用的信息,比如在外部链接旁边插入一个小图标。

除了向范围中插入内容,还可以使用surroundContents()方法插入包含范围的内容。这个方法接收一个参数,即包含范围内容的节点。调用这个方法时,后台会执行如下操作:

(1)提取出范围的内容;

(2)在原始文档中范围之前所在的位置插入给定的节点;

(3)将范围对应文档片段的内容添加到给定节点。

这种功能适合在网页中高亮显示某些关键词,做富文本的时候常用比如:

     let p1 = document.getElementById("p1"),

        helloNode = p1.firstChild.firstChild,

        worldNode = p1.lastChild,

        range = document.createRange();

    range.selectNode(helloNode);

    letspan=document.createElement("span");

    span.style.backgroundColor="yellow";

    range.surroundContents(span);

JavaScript

     <p><b><span style="background-color:yellow">Hello</span></b> world! </p>

HTML, XML

这里有一个关键点:   range.extractContents() 或   range.cloneContents()方法去操作范围浏览器都会自动对范围片段进行补全,但是surroundContents()的规范是dom树中预期位置完全兼容,那么 为了插入元素,范围中必须包含完整的DOM结构。如果范围中包含部分选择的非文节点,这个操作会失败并报错。另外,如果给定的节点是Document、DocumentType或DocumentFragment类型,也会导致抛出错误。

范围折叠

如果范围并没有选择文档的任何部分,则称为折叠(collapsed)​。折叠范围有点类似文本框:如果文本框中有文本,那么可以用鼠标选中以高亮显示全部文本。这时候,如果再单击鼠标,则选区会被移除,光标会落在某两个字符中间。而在折叠范围时,位置会被设置为范围与文档交界的地方,可能是范围选区的开始处,也可能是结尾处。下图展示了范围折叠时会发生什么。

折叠范围可以使用collapse()方法,这个方法接收一个参数:布尔值,表示折叠到范围哪一端。true表示折叠到起点,false表示折叠到终点。要确定范围是否已经被折叠,可以检测范围的collapsed属性:

    range.collapse(true);             // 折叠到起点

    console.log(range.collapsed);   // 输出true

JavaScript

测试范围是否被折叠,能够帮助确定范围中的两个节点是否相邻。例如有以下HTML代码:

    <p id="p1">Paragraph 1</p><p

    id="p2">Paragraph 2</p>

HTML, XML

如果事先并不知道标记的结构(比如自动生成的标记)​,则可以像下面这样创建一个范围:

    let p1 = document.getElementById("p1"),

        p2 = document.getElementById("p2"),

        range = document.createRange();

    range.setStartAfter(p1);

    range.setStartBefore(p2);

    console.log(range.collapsed);   // true

JavaScript

在这种情况下,创建的范围是折叠的,因为p1后面和p2前面没有任何内容。

范围比较

如果有多个范围,则可以使用compareBoundaryPoints()方法确定范围之间是否存在公共的边界(起点或终点)​。这个方法接收两个参数:要比较的范围和一个常量值,表示比较的方式。这个常量参数包括:

❑ Range.START_TO_START(0)​,比较两个范围的起点;

❑ Range.START_TO_END(1)​,比较第一个范围的起点和第二个范围的终点;

❑ Range.END_TO_END(2)​,比较两个范围的终点;

❑ Range.END_TO_START(3)​,比较第一个范围的终点和第二个范围的起点。

compareBoundaryPoints()方法在第一个范围的边界点位于第二个范围的边界点之前时返回-1,在两个范围的边界点相等时返回0,在第一个范围的边界点位于第二个范围的边界点之后时返回1。来看下面的例子:

       let range1 = document.createRange();

    let range2 = document.createRange();

    let p1 = document.getElementById("p1");

    range1.selectNodeContents(p1);

    range2.selectNodeContents(p1);

    range2.setEndBefore(p1.lastChild);

    console.log(range1.compareBoundaryPoints(Range.START_TO_START, range2));   // 0

    console.log(range1.compareBoundaryPoints(Range.END_TO_END, range2));        // 1

JavaScript

在这段代码中,两个范围的起点是相等的,因为它们都是selectNodeContents()默认返回的值。因此,比较二者起点的方法返回0。不过,因为range2的终点被使用setEndBefore()修改了,所以导致range1的终点位于range2的终点之后(见下图)​,结果这个方法返回了1。

复制范围

调用范围cloneRange()方法可以复制范围。这个方法会创建调用它的范围的副本:调用范围的cloneRange()方法可以复制范围。这个方法会创建调用它的范围的副本:

清理

在使用完范围之后,最好调用detach()方法把范围从创建它的文档中剥离。调用detach()之后,就可以放心解除对范围的引用,以便垃圾回收程序释放它所占用的内存。下面是一个例子:

    range.detach();   // 从文档中剥离范围

    range = null;     // 解除引用

JavaScript

这两步是最合理的结束使用范围的方式。剥离之后的范围就不能再使用了。