第52节 DOM2的Traversal遍历及Range范围模块-JavaScript-王唯

本内容是《Web前端开发之Javascript视频》的课件,请配合大师哥《Javascript》视频课程学习。

深度优先遍历和广度优先遍历:

深度优先遍历和广度优先遍历是两种基础的算法,这两种算法在遍历DOM树会应用到;深度优先遍历就是自上而下的遍历搜索,而广度优先遍历则是逐层遍历;

深度优先遍历(Depth-First Traversal DFS):

第52节DOM2的Traversal遍历及Range范围模块-JavaScript-王唯

深度优先遍历(Depth-First Traversal DFS)

初始状态是图中所有顶点均未被访问,则从某个顶点v出发,首先访问该顶点,然后访问该顶点的一个未被访问的邻接点,再从该邻接点,访问它的未被访问的一个邻接点,以此类推,直接最后一个邻接点没有未被访问的邻接点;若此时尚有其他顶点未被访问到,则另选一个未被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止;如,结构:

<html>
    <head>
        <title>Example</title>
    </head>
    <body>
        <p><b>Hello</b> world!</p>
    </body>
</html>

图示,以document为根节点的DOM树进行深度优先遍历的先后顺序;

第52节DOM2的Traversal遍历及Range范围模块-JavaScript-王唯

深度优先遍历,采用的是堆栈的形式, 即先进后出,其有回溯的操作,如:

function deepTraversal(root){
    if(root == null)
        return [];
    var result = []; // 存放遍历结果的数组
    var nodeStack = []; // 暂存元素的栈
    nodeStack.push(root);
    while(nodeStack.length > 0){
        var n = nodeStack.pop();
        result.push(n);
        var children = n.children;
        for(var i=children.length - 1; i>=0; i--){
            nodeStack.push(children[i]);
        }
    }
    return result;
}
console.log(deepTraversal(div));

或使用递归的方式来实现,如:

function deepSearch(node){
    console.log(node.tagName);
    if(node.children.length){
        Array.from(node.children).forEach(function(el){
            deepSearch(el);
        });
    }
}
deepSearch(div);

广度优先遍历(Breadth-First-Search BFS):

第52节DOM2的Traversal遍历及Range范围模块-JavaScript-王唯

广度优先遍历(Breadth-First-Search BFS)

从某顶点v出发,在访问了v之后依次访问v的各个未曾访问过的邻接点,然后分别从这些邻接点出发依次访问它们的邻接点,并使得“先被访问的顶点的邻接点先于后被访问的顶点的邻接点被访问“,直至图中所有已被访问的顶点的邻接点都被访问到;如果此时图中尚有顶点未被访问,则需要另选一个未曾被访问过的顶点作为新的起始点,重复上述过程,直至图中所有顶点都被访问到为止;

广度优先则采用的是队列的形式, 即先进先出;其和深度优先遍历不同,它更像是横向扫描,每次都先遍历当前元素的所有子元素,然后再遍历子元素的所有子元素,以此类推;如:

function breadthTraversal(root){
    if(root == null)
        return [];
    var result = [];
    var nodeQueue = [];
    nodeQueue.push(root);
    while(nodeQueue.length > 0){
        var n = nodeQueue.shift();  // 弹出第一个
        result.push(n);
        var children = n.children;
        for(var i=0; i<children.length; i++){
            nodeQueue.push(children[i]);
        }
    }
    return result;
}
console.log(breadthTraversal(div));
// 或者
function wideSearch(node){
    var queue = [];
    while(node){
        console.log(node.tagName);
        Array.from(node.children).forEach(function(el){
            queue.push(el);
        });
        node = queue.shift();
    }
}
wideSearch(div);

DOM2级遍历模块:

定义了两个用于顺序遍历DOM树的类型:NodeIterator和TreeWalker;这两个类型能够基于给定的节点对DOM树执行深度优先(depth-first)的遍历操作;

在使用DOM2级遍历模块时,最好检测浏览器的支持能力,如:

var supportsTraversals = document.implementation.hasFeature("Traversal", "2.0");
var supportsNodeIterator = (typeof document.createNodeIterator == "function");
var supportsTreeWalker = (typeof document.createTreeWalker == "function");

NodeIterator:

该类型表示一个遍历DOM子树中节点列表的成员的迭代器,节点将按照文档顺序返回;NodeIterator类型可以使用document.createNodeIterator()方法创建;该方法3个参数:

  • root:必选,想要作为搜索起点的树中的节点;
  • whatToShow:可选,表示要访问哪些节点的数字代码;
  • filter:可选,是一个NodeFilter对象,或者一个表示应该接受还是拒绝某种特定节点的函数;
var div = document.getElementById("div1");
var iterator = document.createNodeIterator(div);
console.log(iterator);  // NodeIterator

但是IE需要第4个参数,entityReferenceExpansion:布尔值,表示是否要扩展实体引用;该参数在HTML页面中没有用,因此,一般设置为false;如:

var iterator = document.createNodeIterator(div,-1,null,false);

whatToShow参数:是可选的无符号的长整型,用于筛选特定类型节点,其是通过应用一个或多个过滤器(NodeFilter)来确定要访问哪些类型节点;该参数的值是在NodeFilter类型中的常量属性定义的位掩码:

  • NodeFilter.SHOW_ALL:值为4294967295,显示所有类型的节点;
  • NodeFilter.SHOW_ELEMENT:值为1,显示元素节点;
  • NodeFilter.SHOW_ATTRIBUTE:值为2,显示特性节点;由于DOM结构原因,实际上不能使用这个值;
  • NodeFilter.SHOW_TEXT:值为4,显示文本节点;
  • NodeFilter.SHOW_CDATA_SECTION:值为8,显示CDATA节点,对HTML页面无用
  • NodeFilter.SHOW_ENTITY_REFERENCE:值为16,显示实体引用节点,对HTML页面没有用;
  • NodeFilter.SHOW_ENTITYE:值为32,显示实体节点,对HTML无用;
  • NodeFilter.SHOW_PROCESSING_INSTRUCTION:值为64,显示处理指令节点,对HTML页面没有用;
  • NodeFilter.SHOW_COMMENT:值为128,显示注释节点;
  • NodeFilter.SHOW_DOCUMENT:值为256,显示文档节点;
  • NodeFilter.SHOW_DOCUMENT_TYPE:值为512,显示文档类型节点;
  • NodeFilter.SHOW_DOCUMENT_FRAGMENT:值为1024,显示文档片段节点;
  • NodeFilter.SHOW_NOTATION:值为2048,显示符号节点,对HTML无用;
var div = document.getElementById("div1");
var iterator = document.createNodeIterator(div,NodeFilter.SHOW_ALL,null,false); // 所有节点,包括文本节点
var iterator = document.createNodeIterator(div,NodeFilter.SHOW_ELEMENT,null,false);  // Element类型节点
var node = iterator.nextNode();
while(node != null){
    console.log(node.tagName);
    node = iterator.nextNode();
}

除了NodeFilter.SHOW_ALL之外,可以使用按位或和按位非~操作符来组合多个选项;

var whatToShow = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT;

filter参数:用来指定自定义的NodeFilter对象,或者指定一个功能类似节点过滤器(node filter)的函数;

每个NodeFilter对象拥有且只有一个方法,即acceptNode(),该方法会对从根节点开始到子树中的每个节点都调用一次,哪种类型的节点需要进入迭代节点列表等待调用则取决于whatToShow参数;在acceptNode()方法中如果应该返回给定的节点,其返回常量NodeFilter.FILTER_ACCEPT,值为1,否则返回常量NodeFilter.FILTER_REJECT,值为2或NodeFilter.FILTER_SKIP,值为3;由于NodeFilter是一个抽象的类型,因此不能直接创建它的实例,只要创建一个包含acceptNode()方法的对象,然后将这个对象传入createNodeIterator()中即可,或者创建一个类似的函数,或者不使用传入null,如:

var filter = {
    acceptNode: function(node){
        // return node.tagName.toLowerCase() == "p" ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
        // return node.tagName.toLowerCase() == "p" ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
        return node.tagName.toLowerCase() == "p" ? 1 : 2;
    }
};
// 或者直接使用一个类似的函数
// var filter = function(node){
//     return node.tagName.toLowerCase() == "p" ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
// }
var iterator = document.createNodeIterator(div,NodeFilter.SHOW_ELEMENT,filter,false); // p
// var iterator = document.createNodeIterator(div,NodeFilter.SHOW_ELEMENT,null,false); // p

NodeIterator对象属性:

  • root:只读,返回一个Node,代表创建 NodeIterator 时指定的根节点;
  • whatToShow:只读,返回一个无符号长整型,是一个由描述必须呈现的Node类型的常量构成的位掩码;
  • filter:只读,返回一个用来选择相关节点的 NodeFilter;
console.log(iterator.root);
console.log(iterator.whatToShow);
console.log(iterator.filter);

NodeIterator对象方法:

其拥有两个主要方法:nextNode()和previousNode();nextNode()方法:在每一个iterator中有一个内部指针,nextNode()方法会先返回指针指向的节点,再把指针移向下一个节点;在创建NodeIterator对象时,这个内部指针指向根节点,所以第一次调用nextNode()会返回根节点;当遍历到DOM子树的最后一个节点时,nextNode()返回null;

<div id="div1">
    <p><b>Hello</b> world!</p>
    <ul>
        <li>List item 1</li>
        <li>List item 2</li>
        <li>List item 3</li>
    </ul>
</div>
<script>
var div = document.getElementById("div1");
var iterator = document.createNodeIterator(div, NodeFilter.SHOW_ELEMENT, null, false);
var node = iterator.nextNode();
console.log(node);  // <div id="div1">...</div>
node = iterator.nextNode();
console.log(node);  // <p>...</p>
node = iterator.nextNode();
console.log(node);  // <b>...</b>
while(node != null){
    console.log(node.tagName);
    node = iterator.nextNode();
}
</script>

previousNode()方法类似,用于先把当前指针移向上一个节点,然后返回该节点;当遍历到DOM子树的最前一个节点,且previousNode()返回根节点之后,再次调用previousNode()会返回null;

var node = iterator.nextNode();
console.log(node);  // <div id="div1">...</div>
console.log(iterator.nextNode()); // <p>...</p>
console.log(iterator.previousNode()); // <p>...</p>

由于nextNode()是先返回指针指向的节点再把指针移向下一个节点,而previousNode()是先向上移动当前指针,而后再返回的指针指向的节点,所以它们指向的是同一个节点,是相等的;

console.log(iterator.nextNode() === iterator.previousNode()); // true

由于nextNode()和previousNode()方法都基于NodeIterator在DOM树中的内部指针工作,所以DOM树的变化会反映在遍历的结果中;

// 在创建iterator之后再添加节点
var h2 = document.createElement("h2");
div.insertBefore(h2, div.firstChild);

2.TreeWalker:

TreeWalker 对象用于表示文档子树中的节点和它们的位置;其是NodeIterator的一个更高级的版本;除了包括nextNode()和previousNode()在内的相同的功能之外,这个类型还提供了用于在不同方向上遍历DOM树的方法;

使用document.createTreeWalker()方法创建TreeWalker对象;该方法接受4个参数,与createNodeIterator()方法相同:作为遍历起点的根节点、要显示的节点类型、过滤器和一个表示是否扩展实体引用的布尔值;

var div = document.getElementById("div1");
var filter = function(node){
    return (node.tagName.toLowerCase() == "li") ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
};
// 或根据class查找
// var filter = function(node){
//     return (node.className == "myclass") ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
// };
var walker = document.createTreeWalker(div, NodeFilter.SHOW_ELEMENT, filter, false);
var node = walker.nextNode();
console.log(node);
while(node != null){
    console.log(node.tagName);
    node = walker.nextNode();
}

NodeIterator与TreeWalker的不同点:

filter对象返回的值有所不同:在使用NodeIterator对象时,NodeFilter.FILTER_SKIP和NodeFilter.FILTER_REJECT的作用相用:跳过指定的节点;但在用TreeWalker对象时,FILTER_SKIP会跳过相应节点继续前进到子树中的下一个节点,而FILTER_REJECT则会跳过相应节点及该节点的整个子树;

内部指针不同:TreeWalker中也有一个内部指针,但其在创建时并不指向根节点,而是指向第一个子节点;并且nextNode()方法是先向下移动指针,再返回该指针指向的节点,previousNode()方法与NodeIterator是一致的,所以在TreeWalker中,nextNode() != previousNode();

console.log(walker.nextNode() === walker.previousNode()); // false

TreeWalker除了和NodeIterator具有相同的属性外,还有一个可写的属性currentNode,指向当前的指针所在的节点;在创建TreeWalker时,该属性指向根节点;通过设置这个属性可以修改遍历继续进行的起点;

var div = document.getElementById("div1");
var walker = document.createTreeWalker(div, NodeFilter.SHOW_ELEMENT,null,false);
var node = walker.nextNode();
console.log(node);  // p
console.log(node === walker.currentNode);  // true
walker.currentNode = document.body;
console.log(walker.currentNode);
console.log(walker.firstChild());  // div

TreeWalker最强大的地方在于能够在DOM结构中沿任何方向移动,其提供5个不同方向的API:

  • parentNode():遍历到当前节点“可见“的祖先节点;
  • firstChild():遍历到当前节点“可见“的第一个子节点;
  • lastChild():遍历到当前节点“可见“的最后一个子节点;
  • nextSibling():遍历到当前节点“可见“的下一个同辈节点;
  • previousSibling():遍历到当前节点“可见“的上一个同辈节点;

注意,这些方法遍历的元素是受whatToShow和filter参数的影响;

var div = document.getElementById("div1");
var walker = document.createTreeWalker(div, NodeFilter.SHOW_ELEMENT,null,false);
console.log(walker.firstChild());    // 转到<p>
console.log(walker.nextSibling());   // 转到<ul>
var node = walker.firstChild(); //转到第一个<li>
while (node !=null){
    console.log(node.tagName);
    node = walker.nextSibling();
}

在进行一系列指针移动后,可以使用currentNode属性重置它的当前节点指向根节点,以便获取第一个子元素;

// var rootnode = walker.currentNode;  // 刚创建时,该属性指向根节点
var rootnode = div;
// 遍历
while(walker.nextNode()){
    console.log(walker.currentNode.tagName);
}
console.log(walker.currentNode);
walker.currentNode = rootnode;
console.log(walker.currentNode);  // <div>...</div>

在遍历TreeWalker的返回结果时,也可以使用标准DOM元素的属性和方法,因为TreeWalker的返回值不仅仅返回了过滤后的节点,还包括这些节点在整个文档中的关系,如:

var mydiv = document.getElementById("mydiv");
var walker = document.createTreeWalker(mydiv, NodeFilter.SHOW_ELEMENT,null,false);
console.log(mydiv.childNodes.length); // 5
console.log(walker.currentNode.childNodes.length);  // 5
console.log(walker.currentNode.getElementsByTagName('*').length);  // 6
var nodes = walker.currentNode.getElementsByTagName('*');
for(var i=0,len=nodes.length; i<len; i++){
    console.log(nodes[i]);
}

与NodeIterator相比,TreeWalker类型在遍历DOM时拥有更大的灵活性;

有某些时候,与某些常见核心的获取元素的API很类似的,但性能不一样,如:

// 与querySelector比较
console.time('query');
var collect = document.querySelector('div>div#header');
console.log(collect);
console.timeEnd('query');
console.time("iterator");
var filter = function(node){
    var condition = node.parentNode.tagName === 'DIV' &&
                    node.id == 'header' && node.tagName === 'DIV';
    return condition ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
};
var iterator = document.createNodeIterator(document, NodeFilter.SHOW_ELEMENT, filter, false);
var node = iterator.nextNode();
console.log(node);
console.timeEnd("iterator");

模拟getElementById()和getElementsByTagName()方法:

Document.prototype.getElementById = function(id){
    var filter = function(node){
        return node.id == id ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
    };
    var iterator = document.createNodeIterator(document, NodeFilter.SHOW_ELEMENT, filter, false);
    var node = iterator.nextNode();
    return node;
};
Document.prototype.getElementsByTagName = function(tagName){
    var filter = function(node){
        return node.tagName.toLowerCase() == tagName ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
    };
    var htmlcollection = [];
    var iterator = document.createNodeIterator(document.NodeFilter.SHOW_ELEMENT, filter, false);
    var node = iterator.nextNode();
    while(node != null){
        htmlcollection.push(node);
        node = iterator.nextNode();
    }
    htmlcollection.__proto__ = HTMLCollection.prototype;
    return htmlcollection;
};

Range范围:

为了能更方便的控制页面,DOM2级遍历和范围模块定义了范围(rang)接口,该接口表示一个包含节点与文本节点的一部分的文档片段,也就是范围;在常规的DOM操作不能更有效的修改文档时,使用范围往往可以达到目的;

可以使用Document对象的createRange()方法创建Range,也可以使用Selection对象的getRangeAt()方法获取Range;

标准浏览器都支持DOM范围;IE以专有方式实现了自己的范围特性;

DOM中的范围:

DOM2级在Docuemnt类型中定义了createRang()方法,用于创建范围;在创建范围之前最好使用hasFeature()方法或者直接检测该方法,以确定浏览器是否支持范围,如:

var supportsRange = document.implementation.hasFeature("Range", "2.0");
var alsoSupportsRange = (typeof document.createRange == "function");
// 创建范围
var range = document.createRange();
console.log(range); 

新创建的范围直接与创建它的文档关联在一起,不能用于其他文档;

创建范围之后,就可以使用它在后台选择文档中的特定部分;而创建范围并设置了其位置之后,还可以针对范围的内容执行多种操作,从而实现对底层DOM树的更精细的控制;

Range对象属性:

  • startContainer:只读,返回包含范围起点的节点(即选区中第一个节点的父节点);
  • endContainer:只读,返回包含范围终点的节点(即选区中最后一个节点的父节点)
  • startOffset:只读,返回范围在startContainer中起点的偏移量;
  • endOffset:只读,返回范围在endContainer中终点的偏移量(与startOffset遵循相同取值规则)
  • commonAncestorContainer:只读,返回完整包含startContainer和endContainer共同的祖先节点在文档树中位置最深的那个节点;
  • collapsed:只读,返回一个表示Range的起始位置和终止位置是否相同的布尔值;

在把范围放到文档*特中**定的位置时,以上属性都会被自动赋值;

Range对象方法:

定位方法:

  • setStart(startNode, startOffset):设置Range的起点,如果起始节点类型是Text, Comment, or CDATASection之一, 那么startOffset指的是从起始节点算起字符的偏移量,对于其他Node类型节点, startOffset是指从起始结点开始算起子节点的偏移量,其值为不小于0的整数;如果设置的起始位点在结束点之下(在文档中的位置),将会导致选区折叠,起始点和结束点都会被设置为指定的起始位置;
  • setEnd(endNode, endOffset):设置Range的终点,遵循setStart()一样的规则;
  • 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;
  • selectNode(refNode):使Range包含某个节点及其内容,Range的起始和结束节点的父节点与 refNode 的父节点相同;
  • selectNodeContents(refNode):使Range包含某个节点的内容,startOffset为0,endOffset则是引用节点包含的字符数或子节点个数;
  • collapse():将Range折叠至其端点(boundary points,起止点,指起点或终点,下同)之一

在调用以上方法时,所有属性会自动设置;要想创建复杂的范围选区,也可以直接指定这些属性的值;

编辑方法:可以从Range中获得节点,改变Range的内容;

  • cloneContents():返回一个包含Range中所有节点的文档片段;
  • deleteContents():从文档中移除Range包含的内容;
  • extractContents(): 把Range的内容从文档树移动到一个文档片段中;
  • insertNode():在Range的起点插入一个节点;
  • surroundContents():将Range的内容移动到一个新的节点中;

其他方法:

  • compareBoundaryPoints():比较两个Range的端点;
  • cloneRange():返回拥有和原Range相同的端点的克隆Range对象;
  • detach():将Range从使用状态中释放;
  • toString():把Range的内容作为字符串返回;

用DOM范围实现简单选择:

使用范围选择文档中的一部分,最简单的方式是使用selectNode()或selectNodeContents();这两个方法都接受一个参数:即一个DOM节点,然后使用该节点中的信息来填充范围;其中,selectNode()方法选择整个节点,包括其子节点,而selectNodeContents()方法则只选择节点的子节点;如:

<p id="myp"><b>零点程序员</b>zeronetwork</p>
<script>
var range1 = document.createRange();
var range2 = document.createRange();
var myp = document.getElementById("myp");
console.log(range1);
console.log(range2);
range1.selectNode(myp);
range2.selectNodeContents(myp);
console.log(range1);
console.log(range2);
</script>

第52节DOM2的Traversal遍历及Range范围模块-JavaScript-王唯

在调用selectNodeContent()时,startContainer、endContainer和commonAncestorContainer等于传入的节点,即<p>元素;而starOffset属性始终为0,因为范围从给定节点的第一个子节点开始,endOffset等于子节点的数量,为2;

示例,选择或取消选择一个段落,如:

<p id="txt">使用以下的按钮可以选择或取消选择本段落</p>
<p><button id="selectBtn">选取</button><button id="deselectBtn">取消</button></p>
<script>
var p = document.getElementById("txt");
var selectBtn = document.getElementById("selectBtn");
var deselectBtn = document.getElementById("deselectBtn");
selectBtn.addEventListener("click",function(e){
    var selection = window.getSelection();
    selection.removeAllRanges();  // 清除所有选择
    // 选择段落
    var range = document.createRange();
    range.selectNodeContents(p);
    selection.addRange(range);
},false);
deselectBtn.addEventListener("click",function(e){
    var selection = window.getSelection();
    selection.removeAllRanges();
},false);
</script>

用DOM范围实现复杂选择:

要创建复杂的范围就使用setStart()和setEnd()方法,两者的作用分别为设置Range的起点和终点;这两个方法都接受两个参数:一个参照节点和一个偏移量值;对setStart()来说,参照节点会变成startContainer,而偏移量值会变成startOffset;对于setEnd()来说:参照节点会变成endContainer,而偏移量会变成endOffset;

选择节点中的一部分:

比如要选择”零点程序员”的”程序员”到”zeronetwork”的”o”;

第52节DOM2的Traversal遍历及Range范围模块-JavaScript-王唯

var myp = document.getElementById("myp");
var cnNode=myp.firstChild.firstChild;
var enNode = myp.lastChild;
var range = document.createRange();
range.setStart(cnNode, 2);
range.setEnd(enNode, 5);
console.log(range);
range.deleteContents();

可以使用这两个方法模仿selectNode()和selectNodeContents()方法;

var range1 = document.createRange();
var range2 = document.createRange();
var myp = document.getElementById("myp");
// 确定节点在其父节点的childNodes集合中的索引
var pIndex = -1;
for(var i=0,len=myp.parentNode.childNodes.length; i<len; i++){
    if(myp.parentNode.childNodes[i] == myp){
        pIndex = i;
        break;
    }
}
range1.setStart(myp.parentNode, pIndex);
range1.setEnd(myp.parentNode, pIndex + 1);
range2.setStart(myp, 0);
range2.setEnd(myp, myp.childNodes.length);
console.log(range1);
console.log(range2);

setStartBefore(refNode)将“起点”设置到refNode前、setStartAfter(refNode)将“起点”设置到refNode后、setEndBefore(refNode)将“结束点”设置到refNode前、setEndAfter(refNode)将“结束点”设置到refNode后;使用这四个方法设置的“起点”或“结束点”的父节点与refNode的父节点是同一个元素;

<div id="container">
    <h2>Web前端开发</h2>
    <p>zeronetwork</p>
    <p>零点程序员</p>
</div>
<script>
var container = document.getElementById("container");
var range = document.createRange();
range.setStartAfter(container.firstElementChild);
range.setEndBefore(container.lastElementChild);
console.log(range.toString());
range.deleteContents();
</script>

操作DOM范围中的内容:

在创建范围时,内部会为这个范围创建一个文档片段;范围所属的全部节点都被添加到了这个文档片段中;为了创建这个文档片段,范围内容的格式必须正确有效;

如:前例会被修改为:

<p id=“p1”><b>零点</b><b>程序员</b> zeronetwork</p>

zeronetwork也被拆分为两个文本节点,一个为”zero”,一个为”network”;

第52节DOM2的Traversal遍历及Range范围模块-JavaScript-王唯

注:表示范围的内部文档片段中的所有节点,都只是指向文档中相应节点的指针;

deleteContents():从文档中删除范围所包含的内容,没有返回值;

<div id="mydiv">
    <h2>Web前端开发</h2>
    <p id="myp"><b>零点程序员</b>zeronetwork</p>
</div>
<script>
var myp = document.getElementById("myp");
var cnNode=myp.firstChild.firstChild;
var enNode = myp.lastChild;
var range = document.createRange();
range.setStart(cnNode, 2);
range.setEnd(enNode, 5);
range.deleteContents(); 
console.log(myp);  // <p><b>零点</b>network</p>
</script>

extractContents()也会从文档中移除范围选区,但其会返回范围的DocumentFragment文档片段;利用该返回值,可以将范围的内容插入到文档中的其他地方;

var fragment = range.extractContents();
myp.parentNode.appendChild(fragment);

注意:节点使用DOM事件添加的事件*听器侦**在提取时不会保留,HTML属性事件会原样保留, id属性也会被保留;

cloneContents()方法:创建范围的一个副本;

var fragment = range.cloneContents();
myp.parentNode.appendChild(fragment);

这个方法与extractContents()方法非常类似,也返回DocumentFragment文档片段,区别在于,cloneContents()返回的文档片段包含的是范围中的副本,而不是实际的节点,原来的节点不受影响;

注意:节点绑定的事件*听器侦**在克隆过程中不会被复制,HTML属性事件会被复制,id属性也会被克隆,所以这可能会导致无效的文档结构;

插入DOM范围中的内容:insertNode()方法:可以向范围选区的开始处插入一个节点;如:

range.setStart(cnNode, 2);
range.setEnd(enNode, 5);
// 插入span
var span = document.createElement("span");
span.style.color = "red";
span.appendChild(document.createTextNode("大师哥王唯"));
range.insertNode(span);

注:如果将新节点添加到一个文本节点, 则该节点在插入点处被拆分,插入发生在两个文本节点之间;

surroundContents()方法:可以环绕范围插入内容;该方法接受一个参数:即环绕范围内容的节点;在环绕范围插入内容时,后台会执行下列步骤:

提取出范围中的内容(类似执行extractContent())将给定节点插入到文档中原来范围所在的位置上将文档片段的内容添加到给定节点中;

var myp = document.getElementById("myp");
var cnNode=myp.firstChild.firstChild;
var enNode = myp.lastChild;
var range = document.createRange();
// 因为部分选择了cnNode,会抛出”The Range has partially selected a non-Text node.”错误,
// 即 "区域已部分选择了非文本节点"错误;
// 虽然自动添加了<b>,还会出错
// range.setStart(cnNode, 2);
// range.setStart(myp,0); // 使用这一句代替上一句就可以
// range.setEnd(enNode, 5);
// 或者
range.selectNode(cnNode);
var span = document.createElement("span");
span.style.backgroundColor = "yellow";
range.surroundContents(span);

注:为了环线范围插入节点,范围必须包含整个DOM选区,不能仅仅包含选中的DOM节点;

折叠DOM范围:

所谓折叠范围,是指在范围中未选择文档的任何部分;一个折叠的Range 是空的,不包含内容,表示DOM树中的一个点,在折叠范围时,其位置会落在文档中的两个部分之间,可能是范围选区的开始位置,也可能是结束位置;

第52节DOM2的Traversal遍历及Range范围模块-JavaScript-王唯

如图,第一个是原始范围,第二个是折叠到开始位置,第三个是折叠到结束位置;

使用collapse()方法来折叠范围;该方法接受一个布尔值参数,表示要折叠到范围的哪一端:true表示折叠到范围起点,false为终点,默认为false;折叠后的Range为空,不包含任何内容,要确定范围是否已折叠,可以检查collapsed属性,它返回一个Boolean值表示是否起始点和结束点是同一个位置,如果返回true表示Range的起始位置和结束位置重合,false表示不重合;

var range = document.createRange();
range.setStart(cnNode, 2);
range.setEnd(enNode, 5);
console.log(range.collapsed);  // false
console.log(range);  // startOffset:2 endOffset:5
range.collapse(true);  // 折叠到起点
console.log(range.collapsed);  // true
console.log(range);  // startOffset:2 endOffset:2

检测某个范围是否处于折叠状态,可以确定范围中的两个节点是否紧密相邻,如:

<p id="p1">零点程序员</p><p id="p2">zeronetwork</p>
<script>
var p1 = document.getElementById("p1");
var p2 = document.getElementById("p2");
var range = document.createRange();
range.setStartAfter(p1);
range.setEndBefore(p2);
console.log(range.collapsed);  // true
</script>

比较DOM范围:

在有多个范围的情况下,可以使用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;

var range1 = document.createRange();
var range2 = document.createRange();
var myp = document.getElementById("myp");
range1.selectNodeContents(myp);
range2.selectNodeContents(myp);
range2.setEndBefore(myp.lastChild);  // range2的终点被设置在 zeronetwork前
console.log(range1.compareBoundaryPoints(Range.START_TO_START, range2)); // 0
console.log(range1.compareBoundaryPoints(Range.END_TO_END, range2));  // 1

复制DOM范围:

使用cloneRange()方法复制范围;该方法会创建调用它的范围的一个副本;新创建的范围与原来的范围包含相同的属性,而修改它的端点不会影响原来的范围;

var newRange = range.cloneRange();

清理DOM范围:

在使用完范围后,最好调用detach()([dɪˈtætʃ]分离,拆卸)方法,以便从创建范围的文档中分离出该范围,以解除对范围的引用,从而让垃圾回收机制回收其内存;

range.detach();  // 从文档中分离
range = null; // 解除引用

一旦分离范围,就不能再恢复使用了;

另外在标准与IE浏览器中也分别实现了其专用的API,由于这两部分的API并没有纳入标准,存在很大的兼容性问题,所以在这里就不再累述了;

Selection:

在html5中,每一个浏览器窗口都会有一个Selection对象,代表用户鼠标在页面中所选取的区域,而判定用户在文档中选择了哪些文本是非常重要的,例如对于这些选择的文本进行某些操作;Selection对象所对应的是用户所选择的ranges(区域),俗称拖蓝;

术语:

  • anchor:选中区域的起点,也代表鼠标按下瞬间,光标所在的位置;
  • focus:选中区域的结束点,也就是鼠标松开瞬间,光标所在的位置;
  • range:选区连续的部分,指的是选区的起始到末尾的范围,就是Range对象;一个范围包括整个节点,也可以包含节点的一部分,例如文本节点的一部分;
  • editing host:可编辑元素;

从 anchor 和 focus 的位置能够反推用户选择文字的方向:

从右往左选择一段文字,那么 anchor(起始点)在focus(结束点)的右边;

从左往右选择一段文字,那么 anchor(起始点)在focus(结束点)的左边;

点击段落中某一个位置,而不是选择一段文字,那么 anchor(起始点)和 focus(结束点)的位置重叠;

创建Selection对象:

使用window.getSelection()方法或document.getSelection()方法获取selection对象;在低版本的IE中采用 document.selection获得;

var selection = window.getSelection();
var selection = document.selection; // 已经不支持了
function getSelectedText(){
    if(window.getSelection)
        return window.getSelection();
    else if(document.selection)
        return document.selection;
}

用户和脚本都可以创建选中区;用户创建选中区的办法是拖曳文档的一部分,脚本创建选中区的办法是在文本区域或类似对象上调用select()方法;

<p><input type="text" id="txtInput" value="Web前端课程" /></p>
<script>
var txtInput = document.getElementById("txtInput");
txtInput.select();
</script>

有了选中区后,就可以使用window.getSelection()方法获取当前选中区,然后再利用其他API或使用Range对象对选中区执行操作;

如:

<p><a href="javascript:var q=window.getSelection().toString();void window.open('https://cn.bing.com/search?q=' + q);">零点程序员</a></p>

从文本框或textarea中获取选取的文本:

<textarea cols="50" rows="15">Web前端开发的课程包括HTML、CSS和Javascript</textarea>    
<input type="button" value="选择字后点击我看看" onclick="console.log(window.getSelection().toString())">
// 或
var txtInput = document.getElementById("txtInput");
txtInput.select();
txtInput.onselect = function(e){
    // var val = txtInput.value.substring(txtInput.selectionStart, txtInput.selectionEnd);
    var val = window.getSelection().toString();
    console.log(val);
}

使用Range对象处理,如:

var mydiv = document.getElementById("mydiv");
mydiv.addEventListener("mouseup", function(e){
    var selection = window.getSelection();
    var range = selection.getRangeAt(0);
    console.log(range.toString());
    var span = document.createElement("span");
    span.style.backgroundColor = "yellow";
    range.surroundContents(span);
});

一个文档同一时间只能有一个选中区;选中区的类型决定了其中为空或者包含文本或元素块;尽管空的选中区不包含任何内容,仍然可以用它作为文档中的位置标志;

Selection对象属性:

anchorNode:只读,返回该选区起点所在的节点;

  • anchorOffset:只读,返回一个数字,表示起点在anchorNode的偏移量;如果anchorNode是文本节点,那么返回的就是从该文字节点的第一个字开始,直到被选中的第一个字之间的字数;如果anchorNode 是一个元素,那么返回的就是在选区第一个节点之前的同级节点总数;
  • focusNode:只读,返回该选区结束点所在的节点;
  • focusOffset:只读,返回一个数字,表示结束点在focusNode中的偏移量;如果focusNode是文本节点,那么选区末尾未被选中的第一个字,在该文字节点中是第几个字(从0开始计),就返回它;如果focusNode是一个元素,那么返回的就是在选区末尾之后第一个节点之前的同级节点总数;
  • isCollapsed:只读,返回一个布尔值,用于判断选区的起点和结束点是否重合;
  • rangCount:只读,返回Selection中包含range的数量,一般只有一个,按Ctrl可以选多个;
var mydiv = document.getElementById("mydiv");
mydiv.addEventListener("mouseup", function(e){
    var selection = window.getSelection();
    console.log(selection.anchorNode);
    console.log(selection.anchorOffset);
    console.log(selection.focusNode);
    console.log(selection.focusOffset);
    // 当单击没有选择时,返回true
    console.log(selection.isCollapsed);
console.log(selection.rangeCount);
console.log(selection.type); // Range
});

Selection对象方法:

getRangeAt(index):返回选区包含的指定区域(Range)的引用;从当前selection中根据index下标获取range对象,其中index值代表该range对象的序号,从0开始,如果该数值被错误的赋予了大于或等于rangeCount的数字,将会产生错误;

var selection = window.getSelection();
var range = selection.getRangeAt(0);

每一个Selection对象都有一个或者多个Range对象,每一个range对象代表用户鼠标所选取范围内的一段连续区域;

var btn = document.getElementById("btn");
btn.addEventListener("click", getRange, false);
function getRange(e){
    var html, output = document.getElementById("output");
    var selection = document.getSelection();
    if(selection.rangeCount > 0){
        html = "选取了" + selection.rangeCount + "段内容<br/>";
        for(var i=0; i<selection.rangeCount; i++){
            var range = selection.getRangeAt(i);
            html += "第" + (i+1) + "段内容为:" + range + "<br/>";
        }
        output.innerHTML = html;
    }
}

collapse(parentNode, offset):将开始点和结束点合并(折叠)到指定节点(parentNode)相应指定位置(offset),如:

var mydiv = document.getElementById("mydiv");
mydiv.contentEditable = "true";
window.getSelection().collapse(mydiv,0);

这个方法也可以起到取消选取的效果,如:

<button onclick="select()">选取</button>
<button onclick="unselect()">取消</button>
<script>
var range = document.createRange();
function select(){
    var div = document.getElementById("mydiv");
    range.selectNode(div);
    console.log(range.toString());
}
function unselect(){
    range.collapse(false);
    console.log(range.toString());
}
</script>

extend(node, offset):将结束点移动到指定节点(node)的位置(offset),起点不动,新的section是从起点到结束点的区域,与方向无关;可选offset为在node中的偏移量;

<div id="mydiv">
    <h2>Web前端开发</h2>
    <p>zeronetwork</p>
    <p>零点程序员</p>
</div>
<button id="btn">点击</button>
<script>
var mydiv = document.getElementById("mydiv");
var btn = document.getElementById("btn");
btn.addEventListener("click",function(e){
    var selection = window.getSelection();
    if(selection.rangeCount > 0)
        selection.extend(mydiv,2);
},false);

modify(alter, direction, granularity):该方法可以通过简单的文本命令来修改当前选区或光标位置,或扩大或缩小selection的大小;参数:alter:改变类型,值:move是移动光标位置,extend改变当前选区大小;direction:调整选区的方向,值:forward | backword根据选区内容的语言书写方向来调整,left | right明确指明一个调整方向;granularity:可选,移动的单位或尺寸(也就是调整的距离的颗粒度),可选值为:character、word、sentence、line、paragraph、lineboundary、sentenceboundary、paragraphboundary、documentboundary;

// 在上例中添加
// selection.modify("extend", "forward","word");
selection.modify("extend", "forward","character");
console.log(selection);
collapseToStart():取消当前选区,并把光标定位在原选区的最开始处;
collapseToEnd():取消当前选区,并把光标定位在原选区的最末尾处;
// selection.collapseToStart();
selection.collapseToEnd();
console.log(selection);

selectAllChidren(parentNode):将某个指定节点的子节点框入选区,并取消之前的选中区域;

var myp = document.getElementById("myp")
var selection = window.getSelection();
selection.selectAllChildren(myp);
console.log(selection.toString());

addRange(range):向选区(Selection)中添加一个区域(Range);

var mydiv = document.getElementById("mydiv");
var range = document.createRange();
range.selectNode(mydiv);
var selection = document.getSelection();
selection.addRange(range);
console.log(selection.toString());
// 或
var strongs = document.getElementsByTagName("strong");
var selection = window.getSelection();
if(selection.rangeCount > 0) selection.removeAllRanges();
for(var i=0; i<strongs.length; i++){
    var range = document.createRange();
    range.selectNode(strongs[i]);
    selection.addRange(range);
}
// 除了FF有多个range,其他只有一个
console.log(selection.toString());

removeRange(range):从当前selection中移除range;

selection.removeRange(range);
console.log(selection.toString()); // 只有一个,返回空""
// 或如果有多个
console.log(selection.rangeCount);  // 在FF下,有多个,其他为1个
if(selection.rangeCount > 1){
    for(var i=0; i<selection.rangeCount; i++)
        selection.removeRange(selection.getRangeAt(i));
}
console.log(selection.rangeCount); // 在FF下,只剩下一个

removeAllRanges():移除selection中所有range对象,取消所有的选择,执行后anchorNode和focusNode被设置为null,不存在任何被选中的内容;

如:使用脚本实现剪贴板:

<button id="btnCopy">复制</button>
<script>
var btnCopy = document.getElementById("btnCopy");
btnCopy.addEventListener("click",function(e){
    var range = document.createRange();
    var mydiv = document.getElementById("mydiv");
    range.selectNode(mydiv);
    var selection = window.getSelection();
    if(selection.rangeCount > 0) selection.removeAllRanges();
    // 将要复制的区域的Range对象添加到selection中
    selection.addRange(range);
    document*ex.e**cCommand("copy",false);
},false);
</script>

toString():返回selection纯文本,不包含标签;

containesNode(aNode, aPartlyContained):判断一个节点是否是selection的一部分,aNode是要验证的节点,aPartlyContainered:true只个布尔值,当为true时,selection包含节点aNode的一部分或全部该方法返回true,当为false时,selection完全包含节点aNode时,才返回true;

console.log(window.getSelection().containsNode(document.body, true));

deleteFromDocument():从文档中删除选区中的内容;

示例,添加下划线,如:

<style>
#editor{width: 80%; height: 250px; border: 1px solid; padding: 10p;}
</style>
<div id="editor" contenteditable="true">
    <p>Web前端开发课程包括HTML、CSS、Javascript等</p>
</div>
<button id="btn">添加下划线</button>
<script>
var editor = document.getElementById("editor");
var btn = document.getElementById("btn");
var selection = null, range = null;
editor.addEventListener("mouseup", function(e){
    saveSelection();
},false);
btn.addEventListener("click",function(e){
    restoreSelection();
    document*ex.e**cCommand("underline", false, null);
    saveSelection();
},false);
function saveSelection(){
    selection = document.getSelection();
    range = selection.getRangeAt(0);
}
function restoreSelection(){
    var selection = window.getSelection();
    if(selection.rangeCount > 0)
        selection.removeAllRanges();
    if(!range)
        range = document.createRange();
    selection.addRange(range);
}
</script>

输入性控件的相关属性和方法:

  • selectionStart属性:输入性元素的selection起点位置,可读写;
  • selectionEnd属性:输入性元素的selection结束点位置,可读写;
  • setSelectionRange(start, end):设置输入性元素selectionStart和selectionEnd的值;
  • setRangeText(replacement, start, end, selectionMode):替换选区文本;
<input type="text" id="txtInput" value="Web前端开发zeronetwork" />
<script>
var txtInput = document.getElementsByTagName("input")[0];
txtInput.setSelectionRange(3,7);
console.log(txtInput.selectionStart);
console.log(txtInput.selectionEnd);
txtInput.setRangeText("王唯");
txtInput.setRangeText("王唯",4,4);
txtInput.addEventListener("mouseup", function(e){
    console.log(e.target.selectionStart);
    console.log(e.target.selectionEnd);
    console.log(window.getSelection().toString());
},false);
</script>

Selection对象的这些方法都极为实用,它们利用了DOM范围来管理选区;由于可以直接操作选择文本的DOM表现,因此访问DOM与使用富文本编辑的execCommand()相比,能够对富文本编辑器进行更加细化的控制;

<div contenteditable="true">请选中这里的文字</div>
<div><input type="button" value="加粗" onclick="Bold();" /></div>
<script type="text/javascript">
function Bold(){
    var selection = window.getSelection();
    document*ex.e**cCommand("Bold",false);
}
</script>

封装一下对象,实现复制:

var ClipBoard = {
    __isSupport: function(){
        var supportsRange = document.implementation.hasFeature("Range","2.0");
        var alsoSupportsRange = (typeof document.createRange == "function");
        var supportsSelection = (typeof window.getSelection == "function");
        var supportsExecCommand = (typeof document*ex.e**cCommand == "function");
        var result = supportsRange && alsoSupportsRange && supportsSelection && supportsExecCommand;
        if(!result)
            console.log("浏览器不支持范围复制");
        return result;
    },
    __copy: function(selector, type){
        var self = this;
        var node, range, selection;
        if(!self.__isSupport())
            return;
        node = document.querySelector(selector);
        range = document.createRange();
        if(type == "node")
            range.selectNode(node);
        else if(type == "nodeContents")
            range.selectNodeContents(node);
        selection = window.getSelection();
        if(selection.rangeCount > 0)
            selection.removeAllRanges();
        selection.addRange(range);
        document*ex.e**cCommand("Copy");
        range.detach();
        range = null;
    },
    node: function(selector){
        var self = this;
        self.__copy(selector, "node");
    },
    nodeContents: function(selector){
        var self = this;
        self.__copy(selector, "nodeContents");
    }
};
// 应用
var btnCopy = document.getElementById("btnCopy");
btnCopy.addEventListener("click",function(e){
    // ClipBoard.node("#mydiv");
    ClipBoard.nodeContents("#mydiv");
},false);

Selection对象事件:

selectstart 事件在用户开始一个新的选择时候触发;

document.addEventListener("selectstart", function(e){
    console.log("开始选择了");
},false);

selectionchange 事件在文档上的当前文本选择被改变时触发;

document.addEventListener("selectionchange", function(e){
    console.log(window.getSelection().toString());
},false);

select事件:选择某些文本时会触发事件,该事件不适用于所有语言的所有元素,一般应用在表单控制上,例如用户选择文本框内容或文件框执行select()方法时;

var txtInput = document.getElementsByTagName("input")[0];
txtInput.select();
txtInput.addEventListener("select", function(e){
    console.log(e.target.value);
    var value = e.target.value.substring(e.target.selectionStart, e.target.selectionEnd);
    console.log(value);
},false);

第52节DOM2的Traversal遍历及Range范围模块-JavaScript-王唯