红宝书之第十六章:DOM2 和 DOM3

433 阅读31分钟

DOM 的演进

DOM2 和 DOM3 Core 模块的目标是扩展 DOM API,满足 XML 的所有需求并提供更好的错误处理和特性检测。

XML 命名空间(了解)

XML 命名空间可以实现在一个格式规范的文档中混用不同的 XML 语言,而不必担心元素命名冲突。严格来讲,XML 命名空间在 XHTML 中才支持,HTML 并不支持。作为了解

其他变化

DocumentType 的变化

DocumentType 新增了 3 个属性:publicId、systemId 和 internalSubset。

publicId、systemId 属性表示文档类型声明中有效但无法使用 DOM1 API 访问的数据。

<!DOCTYPE html PUBLIC "-// W3C// DTD XHTML 1.0 Strict// EN" 
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd" 
[<!ELEMENT name (#PCDATA)>] >

publicId 是"-// W3C// DTD XHTML 1.0 Strict// EN",

systemId 是"www.w3.org/TR/xhtml1/D…

internalSubset 是"<!ELEMENT name (#PCDATA)>"。

通常在网页中很少需要访问这些信息。

Document 的变化

importNode()这个方法的目的是从其他文档获取一个节点并导入到新文档,以便将其插入新文档。

importNode()方法跟 cloneNode()方法类似,同样接收两个参数:要复制的节点和表示是否同时复制子树的布尔值,返回结果是适合在当前文档中使用的新节点。

let newNode = document.importNode(oldNode, true); // 导入节点及所有后代
document.body.appendChild(newNode);

这个方法在 HTML 中使用得并不多,在 XML 文档中的使用会更多一些

DOM2 View 给 Document 类型增加了新属性 defaultView,是一个指向拥有当前文档的窗口(或窗格)的指针。IE8 及更早版本支持等价的 parentWindow 属性,Opera 也支持这个属性。因此要确定拥有文档的窗口,可以使用以下代码:

let parentWindow = document.defaultView || document.parentWindow;

DOM2 Core 还针对 document.implementation 对象增加了两个新方法:createDocumentType()和 createDocument()。

前者用于创建 DocumentType 类型的新节点,接收 3 个参数:文档类型名称、publicId 和 systemId。

比如,以下代码可以创建一个新的 HTML 4.01 严格型文档:

let doctype = document.implementation.createDocumentType("html", 
 "-// W3C// DTD HTML 4.01// EN", 
 "http://www.w3.org/TR/html4/strict.dtd");

createDocument()接 收 3 个参数:文档元素的namespaceURI、文档元素的标签名和文档类型。

比如,下列代码可以创建一个空的 XML 文档:

let doc = document.implementation.createDocument("", "root", null);

要创建一个 XHTML 文档,可以使用以下代码:

let doctype = document.implementation.createDocumentType("html", 
 "-// W3C// DTD XHTML 1.0 Strict// EN", 
 "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"); 
let doc = document.implementation.createDocument("http://www.w3.org/1999/xhtml", 
 "html", doctype);

这里使用了适当的命名空间和文档类型创建一个新 XHTML文档。这个文档只有一个文档元素,其他一切都需要另行添加。

DOM2 HTML 模块也为 document.implamentation 对象添加了 createHTMLDocument()方法。使用这个方法可以创建一个完整的 HTML 文档,包含、、和元素。这个方法只接收一个参数,即新创建文档的标题(放到元素中),返回一个新的 HTML 文档。

比如:

let htmldoc = document.implementation.createHTMLDocument("New Doc"); 
console.log(htmldoc.title); // "New Doc" 
console.log(typeof htmldoc.body); // "object"

createHTMLDocument()方法创建的对象是 HTMLDocument 类型的实例,因此包括该类型所有相关的方法和属性,包括 title 和 body 属性。

Node 的变化

DOM3 新增了两个用于比较节点的方法:isSameNode()和 isEqualNode()。

这两个方法都接收一个节点参数,如果这个节点与参考节点相同或相等,则返回 true。节点相同,意味着引用同一个对象;节点相等,意味着节点类型相同,拥有相等的属性(nodeName、nodeValue 等),而且 attributes和 childNodes 也相等(即同样的位置包含相等的值)。

来看一个例子:

let div1 = document.createElement("div"); 
div1.setAttribute("class", "box"); 
let div2 = document.createElement("div");
div2.setAttribute("class", "box");
console.log(div1.isSameNode(div1)); // true 
console.log(div1.isEqualNode(div2)); // true 
console.log(div1.isSameNode(div2)); // false

这里创建了包含相同属性的两个

元素。这两个元素相等,但不相同。

DOM3 也增加了给 DOM 节点附加额外数据的方法。setUserData()方法接收 3 个参数:键、值、处理函数,用于给节点追加数据。

可以像下面这样把数据添加到一个节点:

document.body.setUserData("name", "Nicholas", function() {});

然后,可以通过相同的键再取得这个信息,比如:

let value = document.body.getUserData("name");

setUserData()的处理函数会在包含数据的节点被复制、删除、重命名或导入其他文档的时候执行,可以在这时候决定如何处理用户数据。处理函数接收 5 个参数:表示操作类型的数值(1 代表复制,2 代表导入,3 代表删除,4 代表重命名)、数据的键、数据的值、源节点和目标节点。删除节点时,源节点为 null;除复制外,目标节点都为 null。

let div = document.createElement("div"); 
div.setUserData("name", "Nicholas", function(operation, key, value, src, dest) { 
 if (operation == 1) { 
 dest.setUserData(key, value, function() {}); } 
}); 
let newDiv = div.cloneNode(true); 
console.log(newDiv.getUserData("name")); // "Nicholas"

这里先创建了一个

元素,然后给它添加了一些数据,包含用户的名字。在使用 cloneNode()复制这个元素时,就会调用处理函数,从而将同样的数据再附加给复制得到的目标节点。然后,在副本节点上调用 getUserData()能够取得附加到源节点上的数据。

内嵌窗格的变化

DOM2 HTML 给 HTMLIFrameElement(即,内嵌窗格)类型新增了一个属性,叫contentDocument。这个属性包含代表子内嵌窗格中内容的 document 对象的指针。下面的例子展示了如何使用这个属性:

let iframe = document.getElementById("myIframe"); 
let iframeDoc = iframe.contentDocument;

contentDocument 属性是 Document 的实例,拥有所有文档属性和方法,因此可以像使用其他HTML 文档一样使用它。还有一个属性 contentWindow,返回相应窗格的 window 对象,这个对象上有一个 document 属性。所有现代浏览器都支持 contentDocument 和 contentWindow 属性。

样式

HTML 中的样式有 3 种定义方式:外部样式表(通过元素)、文档样式表(使用元素)和元素特定样式(使用 style 属性)。

存取元素样式

任何支持 style 属性的 HTML 元素在 JavaScript 中都会有一个对应的 style 属性。这个 style 属性是 CSSStyleDeclaration 类型的实例,其中包含通过 HTML style 属性为元素设置的所有样式信息,但不包含通过层叠机制从文档样式和外部样式中继承来的样式。

因为 CSS 属性名使用连字符表示法,所以在 JavaScript 中这些属性必须转换为驼峰大小写形式。

CSS 属性JavaScript 属性
background-imagestyle.backgroundImage
colorstyle.color
displaystyle.display
font-familystyle.fontFamily

大多数属性名会这样直接转换过来。但有一个 CSS 属性名不能直接转换,它就是 float。因为float 是 JavaScript 的保留字,所以不能用作属性名。DOM2 Style 规定它在 style 对象中对应的属性应该是 cssFloat。

任何时候,只要获得了有效 DOM 元素的引用,就可以通过 JavaScript 来设置样式。来看下面的例子:

let myDiv = document.getElementById("myDiv"); 
// 设置背景颜色
myDiv.style.backgroundColor = "red"; 
// 修改大小
myDiv.style.width = "100px"; 
myDiv.style.height = "200px"; 
// 设置边框
myDiv.style.border = "1px solid black";

像这样修改样式时,元素的外观会自动更新。

通过 style 属性设置的值也可以通过 style 对象获取。比如下面的 HTML:

<div id="myDiv" style="background-color: blue; width: 10px; height: 25px"></div>

这个元素 style 属性的值可以像这样通过代码获取:

console.log(myDiv.style.backgroundColor); // "blue" 
console.log(myDiv.style.width); // "10px" 
console.log(myDiv.style.height); // "25px"

如果元素上没有 style 属性,则 style 对象包含所有可能的 CSS 属性的空值。

DOM 样式属性和方法

  • cssText,包含 style 属性中的 CSS 代码。
  • length,应用给元素的 CSS 属性数量。
  • parentRule,表示 CSS 信息的 CSSRule 对象(下一节会讨论 CSSRule 类型)。
  • getPropertyCSSValue(propertyName),返回包含 CSS 属性 propertyName 值的 CSSValue对象(已废弃)。
  • getPropertyPriority(propertyName),如果 CSS 属性 propertyName 使用了!important则返回"important",否则返回空字符串。
  • getPropertyValue(propertyName),返回属性 propertyName 的字符串值。
  • item(index),返回索引为 index 的 CSS 属性名。
  • removeProperty(propertyName),从样式中删除 CSS 属性 propertyName
  • setProperty(propertyName, value, priority),设置 CSS 属性 propertyName 的值为valuepriority 是"important"或空字符串。

如果一个元素通过 style 属性设置了边框,而赋给 cssText属性的值不包含边框,则元素的边框会消失。下面的例子演示了 cssText 的使用:

myDiv.style.cssText = "width: 25px; height: 100px; background-color: green"; 
console.log(myDiv.style.cssText);

设置 cssText 是一次性修改元素多个样式最快捷的方式,因为所有变化会同时生效。

计算样式

style 对象中包含支持 style 属性的元素为这个属性设置的样式信息,但不包含从其他样式表层叠继承的同样影响该元素的样式信息。

DOM2 Style在document.defaultView上增加了getComputedStyle()方法。这个方法接收两个参数:要取得计算样式的元素和伪元素字符串;

如果不需要查询伪元素,则第二个参数可以传 null。getComputedStyle()方法返回一个 CSSStyleDeclaration对象(与 style 属性的类型一样),包含元素的计算样式。

假设有如下 HTML 页面:

<!DOCTYPE html> 
<html> 
<head> 
   <title>Computed Styles Example</title> 
   <style type="text/css"> 
     #myDiv { 
     background-color: blue; 
     width: 100px; 
     height: 200px; 
     } 
 	</style> 	
</head> 
<body> 
 	<div id="myDiv" style="background-color: red; border: 1px solid black"></div> 
</body> 
</html>

下面的代码从这个元素获取了计算样式:

let myDiv = document.getElementById("myDiv"); 
let computedStyle = document.defaultView.getComputedStyle(myDiv, null); 
console.log(computedStyle.backgroundColor); // "red" 
console.log(computedStyle.width); // "100px" 
console.log(computedStyle.height); // "200px" 
console.log(computedStyle.border); // "1px solid black"(在某些浏览器中)

在取得这个元素的计算样式时,得到的背景颜色是"red",宽度为"100px",高度为"200px"。背景颜色不是"blue",因为元素样式覆盖了它。

border 属性不一定返回样式表中实际的 border 规则(某些浏览器会)。这种不一致性是因浏览器解释简写样式的方式造成的,比如 border 实际上会设置一组别的属性。在设置 border 时,实际上设置的是 4 条边的线条宽度、颜色和样式(border-left-width、border-top-color、border-bottom-style 等)。因此,即使 computedStyle.border 在所有浏览器中都不会返回值computedStyle.borderLeftWidth 也一定会返回值

注意 浏览器虽然会返回样式值,但返回值的格式不一定相同。比如,Firefox 和 Safari 会把所有颜色值转换为 RGB 格式(如红色会变成 rgb(255,0,0)),而 Opera 把所有颜色转换为十六进制表示法(如红色会变成#ff0000)。因此在使用 getComputedStyle()时一定要多测试几个浏览器。

关于计算样式要记住一点,在所有浏览器中计算样式都是只读的,不能修改 getComputedStyle()方法返回的对象。而且,计算样式还包含浏览器内部样式表中的信息。因此有默认值的 CSS 属性会出现在计算样式里。

操作样式表

CSSStyleSheet 类型表示 CSS 样式表,包括使用元素和通过元素定义的样式表。注意,这两个元素本身分别是 HTMLLinkElement 和 HTMLStyleElement。CSSStyleSheet 类型是一个通用样式表类型,可以表示以任何方式在 HTML 中定义的样式表。另外,元素特定的类型允许修改HTML 属性,而 CSSStyleSheet 类型的实例则是一个只读对象(只有一个属性例外)。

CSSStyleSheet类型继承StyleSheet,后者可用作非CSS样式表的基类。以下是CSSStyleSheet从 StyleSheet 继承的属性

  • disabled,布尔值,表示样式表是否被禁用了(这个属性是可读写的,因此将它设置为 true会禁用样式表)。
  • href,如果是使用包含的样式表,则返回样式表的 URL,否则返回 null。
  • media,样式表支持的媒体类型集合,这个集合有一个 length 属性和一个 item()方法,跟所有 DOM 集合一样。同样跟所有 DOM 集合一样,也可以使用中括号访问集合中特定的项。如果样式表可用于所有媒体,则返回空列表。
  • ownerNode,指向拥有当前样式表的节点,在 HTML 中要么是元素要么是元素(在 XML 中可以是处理指令)。如果当前样式表是通过@import 被包含在另一个样式表中,则这个属性值为 null。
  • parentStyleSheet,如果当前样式表是通过@import 被包含在另一个样式表中,则这个属性指向导入它的样式表。
  • title,ownerNode 的 title 属性。
  • type,字符串,表示样式表的类型。对 CSS 样式表来说,就是"text/css"。
  • cssRules,当前样式表包含的样式规则的集合。
  • ownerRule,如果样式表是使用@import 导入的,则指向导入规则;否则为 null。
  • deleteRule(index),在指定位置删除 cssRules 中的规则。
  • insertRule(rule, index),在指定位置向 cssRules 中插入规则。
let sheet = null; 
for (let i = 0, len = document.styleSheets.length; i < len; i++) { 
 sheet = document.styleSheets[i]; 
 console.log(sheet.href); 
}

以上代码输出了文档中每个样式表的 href 属性(元素没有这个属性)

CSS 规则

CSSRule 类型表示样式表中的一条规则。这个类型也是一个通用基类,很多类型都继承它,但其中最常用的是表示样式信息的 CSSStyleRule,以下是 CSSStyleRule 对象上可用的属性。

  • cssText,返回整条规则的文本。这里的文本可能与样式表中实际的文本不一样,因为浏览器内部处理样式表的方式也不一样。Safari 始终会把所有字母都转换为小写。
  • parentRule,如果这条规则被其他规则(如@media)包含,则指向包含规则,否则就是 null
  • parentStyleSheet,包含当前规则的样式表。
  • selectorText,返回规则的选择符文本。这里的文本可能与样式表中实际的文本不一样,因为浏览器内部处理样式表的方式也不一样。这个属性在 Firefox、Safari、Chrome 和 IE 中是只读的,在 Opera 中是可以修改的。
  • style,返回 CSSStyleDeclaration 对象,可以设置和获取当前规则中的样式。
  • type,数值常量,表示规则类型。对于样式规则,它始终为 1。

在这些属性中,使用最多的是 cssText、selectorText 和 style。

多数情况下,使用 style 属性就可以实现操作样式规则的任务了。这个对象可以像每个元素上的style 对象一样,用来读取或修改规则的样式。比如下面这个 CSS 规则:

div.box { 
   background-color: blue; 
   width: 100px; 
   height: 200px; 
}

假设这条规则位于页面中的第一个样式表中,而且是该样式表中唯一一条 CSS 规则,则下列代码可以获取它的所有信息:

let sheet = document.styleSheets[0]; 
let rules = sheet.cssRules || sheet.rules; // 取得规则集合
let rule = rules[0]; // 取得第一条规则
console.log(rule.selectorText); // "div.box" 
console.log(rule.style.cssText); // 完整的 CSS 代码
console.log(rule.style.backgroundColor); // "blue" 
console.log(rule.style.width); // "100px" 
console.log(rule.style.height); // "200px"

使用这些接口,可以像确定元素 style 对象中包含的样式一样,确定一条样式规则的样式信息。与元素的场景一样,也可以修改规则中的样式,如下所示:

let sheet = document.styleSheets[0]; 
let rules = sheet.cssRules || sheet.rules; // 取得规则集合
let rule = rules[0]; // 取得第一条规则
rule.style.backgroundColor = "red"

注意,这样修改规则会影响到页面上所有应用了该规则的元素。如果页面上有两个

元素有"box"类,则这两个元素都会受到这个修改的影响。

创建规则

DOM 规定,可以使用 insertRule()方法向样式表中添加新规则。这个方法接收两个参数:规则的文本和表示插入位置的索引值。下面是一个例子:

sheet.insertRule("body { background-color: silver }", 0); // 使用 DOM 方法

这个例子插入了一条改变文档背景颜色的规则。这条规则是作为样式表的第一条规则(位置 0)插入的,顺序对规则层叠是很重要的。

删除规则

支持从样式表中删除规则的 DOM 方法是 deleteRule(),它接收一个参数:要删除规则的索引。要删除样式表中的第一条规则,可以这样做:

sheet.deleteRule(0); // 使用 DOM 方法

与添加规则一样,删除规则并不是 Web 开发中常见的做法。考虑到可能影响 CSS 层叠的效果,删除规则时要慎重。

元素尺寸

偏移尺寸

offsetHeight,元素在垂直方向上占用的像素尺寸,包括它的高度、水平滚动条高度(如果可见)和上、下边框的高度。

offsetLeft,元素左边框外侧距离包含元素左边框内侧的像素数。

offsetTop,元素上边框外侧距离包含元素上边框内侧的像素数。

offsetWidth,元素在水平方向上占用的像素尺寸,包括它的宽度、垂直滚动条宽度(如果可见)和左、右边框的宽度。

function getElementLeft(element) { 
 let actualLeft = element.offsetLeft; 
 let current = element.offsetParent; 
 while (current !== null) { 
 actualLeft += current.offsetLeft; 
 current = current.offsetParent; 
 } 
 return actualLeft; 
}

function getElementTop(element) { 
 let actualTop = element.offsetTop; 
 let current = element.offsetParent; 
 while (current !== null) { 
 actualTop += current.offsetTop; 
 current = current.offsetParent; 
 } 
 return actualTop; 
}

这两个函数使用 offsetParent 在 DOM 树中逐级上溯,将每一级的偏移属性相加,最终得到元素的实际偏移量。

客户端尺寸

元素的客户端尺寸(client dimensions)包含元素内容及其内边距所占用的空间。客户端尺寸只有两个相关属性:clientWidth 和 clientHeight。其中,clientWidth 是内容区宽度加左、右内边距宽度,clientHeight 是内容区高度加上、下内边距高度。

客户端尺寸实际上就是元素内部的空间,因此不包含滚动条占用的空间。这两个属性最常用于确定浏览器视口尺寸,即检测 document.documentElement 的 clientWidth 和 clientHeight。这两个属性表示视口(或元素)的尺寸。

注意:与偏移尺寸一样,客户端尺寸也是只读的,而且每次访问都会重新计算。

滚动尺寸

最后一组尺寸是滚动尺寸(scroll dimensions),提供了元素内容滚动距离的信息。有些元素,比如无须任何代码就可以自动滚动,而其他元素则需要使用 CSS 的 overflow 属性令其滚动。滚动尺寸相关的属性有如下 4 个。

  •  scrollHeight,没有滚动条出现时,元素内容的总高度。
  •  scrollLeft,内容区左侧隐藏的像素数,设置这个属性可以改变元素的滚动位置。
  •  scrollTop,内容区顶部隐藏的像素数,设置这个属性可以改变元素的滚动位置。
  •  scrollWidth,没有滚动条出现时,元素内容的总宽度。

scrollWidth 和 scrollHeight 可以用来确定给定元素内容的实际尺寸。例如,元素是浏览器中滚动视口的元素。因此,document.documentElement.scrollHeight 就是整个页面垂直方向的总高度。

scrollWidth 和 scrollHeight 与 clientWidth 和 clientHeight 之间的关系在不需要滚动的文档上是分不清的。如果文档尺寸超过视口尺寸,则在所有主流浏览器中这两对属性都不相等,scrollWidth 和 scollHeight 等于文档内容的宽度,而 clientWidth 和 clientHeight 等于视口的大小。

scrollLeft 和 scrollTop 属性可以用于确定当前元素滚动的位置,或者用于设置它们的滚动位置。元素在未滚动时,这两个属性都等于 0。如果元素在垂直方向上滚动,则 scrollTop 会大于 0,表示元素顶部不可见区域的高度。如果元素在水平方向上滚动,则 scrollLeft 会大于 0,表示元素左侧不可见区域的宽度。因为这两个属性也是可写的,所以把它们都设置为 0 就可以重置元素的滚动位置

下面这个函数检测元素是不是位于顶部,如果不是则把它滚动回顶部:

function scrollToTop(element) { 
   if (element.scrollTop != 0) { 
   		element.scrollTop = 0; 
   } 
}

这个函数使用 scrollTop 获取并设置值。

确定元素尺寸

浏览器在每个元素上都暴露了 getBoundingClientRect()方法,返回一个 DOMRect 对象,包含6 个属性:left、top、right、bottom、height 和 width。这些属性给出了元素在页面中相对于视口的位置。

遍历

DOM2 Traversal and Range 模块定义了两个类型用于辅助顺序遍历 DOM 结构。这两个类型—— NodeIterator 和 TreeWalker——从某个起点开始执行对 DOM 结构的深度优先遍历。

NodeIterator

NodeIterator 类型是两个类型中比较简单的,可以通过 document.createNodeIterator()方法创建其实例。这个方法接收以下 4 个参数。

  •  root,作为遍历根节点的节点。
  •  whatToShow,数值代码,表示应该访问哪些节点。
  •  filter,NodeFilter 对象或函数,表示是否接收或跳过特定节点。
  •  entityReferenceExpansion,布尔值,表示是否扩展实体引用。这个参数在 HTML 文档中没有效果,因为实体引用永远不扩展。

whatToShow 参数是一个位掩码,通过应用一个或多个过滤器来指定访问哪些节点。这个参数对应的常量是在 NodeFilter 类型中定义的。

  •  NodeFilter.SHOW_ALL,所有节点。
  •  NodeFilter.SHOW_ELEMENT,元素节点。
  •  NodeFilter.SHOW_ATTRIBUTE,属性节点。由于 DOM 的结构,因此实际上用不上。
  •  NodeFilter.SHOW_TEXT,文本节点。
  •  NodeFilter.SHOW_CDATA_SECTION,CData 区块节点。不是在 HTML 页面中使用的。
  •  NodeFilter.SHOW_ENTITY_REFERENCE,实体引用节点。不是在 HTML 页面中使用的。
  •  NodeFilter.SHOW_ENTITY,实体节点。不是在 HTML 页面中使用的。
  •  NodeFilter.SHOW_PROCESSING_INSTRUCTION,处理指令节点。不是在 HTML 页面中使用的。
  •  NodeFilter.SHOW_COMMENT,注释节点。
  •  NodeFilter.SHOW_DOCUMENT,文档节点。
  •  NodeFilter.SHOW_DOCUMENT_TYPE,文档类型节点。
  •  NodeFilter.SHOW_DOCUMENT_FRAGMENT,文档片段节点。不是在 HTML 页面中使用的。
  •  NodeFilter.SHOW_NOTATION,记号节点。不是在 HTML 页面中使用的。

这些值除了 NodeFilter.SHOW_ALL 之外,都可以组合使用。比如,可以像下面这样使用按位或操作组合多个选项:

let whatToShow = NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT;

createNodeIterator()方法的 filter 参数可以用来指定自定义 NodeFilter 对象,或者一个作为节点过滤器的函数。NodeFilter 对象只有一个方法 acceptNode(),如果给定节点应该访问就返回 NodeFilter.FILTER_ACCEPT,否则返回 NodeFilter.FILTER_SKIP。因为 NodeFilter 是一个抽象类型,所以不可能创建它的实例。只要创建一个包含 acceptNode()的对象,然后把它传给createNodeIterator()就可以了。

以下代码定义了只接收

元素的节点过滤器对象:

let filter = { 
 acceptNode(node) { 
 	return node.tagName.toLowerCase() == "p" ?
    NodeFilter.FILTER_ACCEPT : 
	 	NodeFilter.FILTER_SKIP; 
 	} 
};
let iterator = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT, 
 filter, false);

filter 参数还可以是一个函数,与 acceptNode()的形式一样,如下面的例子所示:

let filter = function(node) { 
 return node.tagName.toLowerCase() == "p" ? 
 NodeFilter.FILTER_ACCEPT : 
 NodeFilter.FILTER_SKIP; 
}; 
let iterator = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT, 
 filter, false);

通常,JavaScript 会使用这种形式,因为更简单也更像普通 JavaScript 代码。如果不需要指定过滤器,则可以给这个参数传入 null。

要创建一个简单的遍历所有节点的 NodeIterator,可以使用以下代码:

let iterator = document.createNodeIterator(document, NodeFilter.SHOW_ALL, 
 null, false);

NodeIterator 的两个主要方法是 nextNode()和 previousNode()。nextNode()方法在 DOM子树中以深度优先方式进前一步,而 previousNode()则是在遍历中后退一步。创建 NodeIterator对象的时候,会有一个内部指针指向根节点,因此第一次调用 nextNode()返回的是根节点。当遍历到达 DOM 树最后一个节点时,nextNode()返回 null。previousNode()方法也是类似的。当遍历到达DOM 树最后一个节点时,调用 previousNode()返回遍历的根节点后,再次调用也会返回 null。

以下面的 HTML 片段为例:

<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>

假设想要遍历

元素内部的所有元素,那么可以使用如下代码:

let div = document.getElementById("div1"); 
let iterator = document.createNodeIterator(div, NodeFilter.SHOW_ELEMENT, 
 null, false); 
let node = iterator.nextNode(); 
while (node !== null) { 
 console.log(node.tagName); // 输出标签名
 node = iterator.nextNode(); 
}

这个例子中第一次调用 nextNode()返回

元素。因为 nextNode()在遍历到达 DOM 子树末尾时返回 null,所以这里通过 while 循环检测每次调用 nextNode()的返回值是不是 null。以上代码执行后会输出以下标签名:

DIV 
P 
B 
UL 
LI 
LI 
LI

如果只想遍历

  • 元素,可以传入一个过滤器,比如:

    let div = document.getElementById("div1"); 
    let filter = function(node) { 
     return node.tagName.toLowerCase() == "li" ? 
     NodeFilter.FILTER_ACCEPT : 
     NodeFilter.FILTER_SKIP; 
    }; 
    let iterator = document.createNodeIterator(div, NodeFilter.SHOW_ELEMENT, 
     filter, false);
    let node = iterator.nextNode(); 
    while (node !== null) { 
     console.log(node.tagName); // 输出标签名
     node = iterator.nextNode(); 
    }
    

    在这个例子中,遍历只会输出

  • 元素的标签。

    nextNode()和 previousNode()方法共同维护 NodeIterator 对 DOM 结构的内部指针,因此修改 DOM 结构也会体现在遍历中。

    TreeWalker

    TreeWalker 是 NodeIterator 的高级版。除了包含同样的 nextNode()、previousNode()方法,TreeWalker 还添加了如下在 DOM 结构中向不同方向遍历的方法。

    •  parentNode(),遍历到当前节点的父节点。
    •  firstChild(),遍历到当前节点的第一个子节点。
    •  lastChild(),遍历到当前节点的最后一个子节点。
    •  nextSibling(),遍历到当前节点的下一个同胞节点。
    •  previousSibling(),遍历到当前节点的上一个同胞节点。

    TreeWalker 对象要调用 document.createTreeWalker()方法来创建,这个方法接收与document.createNodeIterator()同样的参数:作为遍历起点的根节点、要查看的节点类型、节点过滤器和一个表示是否扩展实体引用的布尔值。因为两者很类似,所以 TreeWalker 通常可以取代NodeIterator,比如:

    let div = document.getElementById("div1"); 
    let filter = function(node) { 
     return node.tagName.toLowerCase() == "li" ? 
     NodeFilter.FILTER_ACCEPT : 
     NodeFilter.FILTER_SKIP; 
    };
    let walker = document.createTreeWalker(div, NodeFilter.SHOW_ELEMENT, 
     filter, false); 
    let node = iterator.nextNode(); 
    while (node !== null) { 
     console.log(node.tagName); // 输出标签名
     node = iterator.nextNode(); 
    }
    

    不同的是,节点过滤器(filter)除了可以返回 NodeFilter.FILTER_ACCEPT 和 NodeFilter. FILTER_SKIP,还可以返回 NodeFilter.FILTER_REJECT。在使用 NodeIterator 时,NodeFilter. FILTER_SKIP 和 NodeFilter.FILTER_REJECT 是一样的。但在使用 TreeWalker 时,NodeFilter. FILTER_SKIP 表示跳过节点,访问子树中的下一个节点,而 NodeFilter.FILTER_REJECT 则表示跳过该节点以及该节点的整个子树。

    例如,如果把前面示例中的过滤器函数改为返回 NodeFilter. FILTER_REJECT(而不是 NodeFilter.FILTER_SKIP),则会导致遍历立即返回,不会访问任何节点。这是因为第一个返回的元素是

    ,其中标签名不是"li",因此过滤函数返回 NodeFilter.FILTER_ REJECT,表示要跳过整个子树。因为
    本身就是遍历的根节点,所以遍历会就此结束。

    当然,TreeWalker 真正的威力是可以在 DOM 结构中四处游走。如果不使用过滤器,单纯使用TreeWalker 的漫游能力同样可以在 DOM 树中访问

  • 元素,比如:

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

    因为我们知道

  • 元素在文档结构中的位置,所以可以直接定位过去。先使用 firstChild()前 往

    元素,再通过 nextSibling()前往

      元素,然后使用 firstChild()到达第一个
    • 元素。注意,此时的 TreeWalker 只返回元素(这是因为传给 createTreeWalker()的第二个参数)。最后就可以使用 nextSibling()访问每个
    • 元素,直到再也没有元素,此时方法返回 null。

      TreeWalker 类型也有一个名为 currentNode 的属性,表示遍历过程中上一次返回的节点(无论使用的是哪个遍历方法)。可以通过修改这个属性来影响接下来遍历的起点,如下面的例子所示:

      let node = walker.nextNode(); 
      console.log(node === walker.currentNode); // true 
      walker.currentNode = document.body; // 修改起点
      

      相比于 NodeIterator,TreeWalker 类型为遍历 DOM 提供了更大的灵活性。

      范围

      DOM 范围

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

      let range = document.createRange();
      

      与节点类似,这个新创建的范围对象是与创建它的文档关联的,不能在其他文档中使用。然后可以使用这个范围在后台选择文档特定的部分。创建范围并指定它的位置之后,可以对范围的内容执行一些操作,从而实现对底层 DOM 树更精细的控制。

      每个范围都是 Range 类型的实例,拥有相应的属性和方法。下面的属性提供了与范围在文档中位置相关的信息。

      •  startContainer,范围起点所在的节点(选区中第一个子节点的父节点)。
      •  startOffset,范围起点在 startContainer 中的偏移量。如果 startContainer 是文本节点、注释节点或 CData 区块节点,则 startOffset 指范围起点之前跳过的字符数;否则,表示范围中第一个节点的索引。
      •  endContainer,范围终点所在的节点(选区中最后一个子节点的父节点)。
      •  endOffset,范围起点在 startContainer 中的偏移量(与 startOffset 中偏移量的含义相同)。
      •  commonAncestorContainer,文档中以startContainer和endContainer为后代的最深的节点。

      这些属性会在范围被放到文档中特定位置时获得相应的值。

      简单选择

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

      假设有如下 HTML:

      <!DOCTYPE html> 
      <html> 
       <body> 
       <p id="p1"><b>Hello</b> world!</p> 
       </body> 
      </html>
      

      以下 JavaScript 代码可以访问并创建相应的范围:

      let range1 = document.createRange(), 
       range2 = document.createRange(), 
       p1 = document.getElementById("p1"); 
      range1.selectNode(p1); 
      range2.selectNodeContents(p1);
      

      例子中的这两个范围包含文档的不同部分。range1 包含

      元素及其所有后代,而 range2 包含元素、文本节点"Hello"和文本节点" world!"

      调用 selectNode()时,startContainer、endContainer 和 commonAncestorContainer 都等于传入节点的父节点。在这个例子中,这几个属性都等于 document.body。startOffset 属性等于传入节点在其父节点 childNodes 集合中的索引(在这个例子中,startOffset 等于 1,因为 DOM的合规实现把空格当成文本节点),而 endOffset 等于 startOffset 加 1(因为只选择了一个节点)。

      在调用 selectNodeContents()时,startContainer、endContainer 和 commonAncestor Container 属性就是传入的节点,在这个例子中是

      元素。startOffset 属性始终为 0,因为范围从传入节点的第一个子节点开始,而 endOffset 等于传入节点的子节点数量(node.child Nodes.length),在这个例子中等于 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 = -1, 
       i, 
       len; 
      for (i = 0, len = p1.parentNode.childNodes.length; i < len; i++) { 
       if (p1.parentNode.childNodes[i] === p1) { 
       p1Index = i; 
       break; 
       } 
      }
      range1.setStart(p1.parentNode, p1Index); 
      range1.setEnd(p1.parentNode, p1Index + 1); 
      range2.setStart(p1, 0); 
      range2.setEnd(p1, p1.childNodes.length);
      

      注意,要选择节点(使用 range1),必须先确定给定节点(p1)在其父节点 childNodes 集合中的索引。而要选择节点的内容(使用 range2),则不需要这样计算,因为可以直接给 setStart()和setEnd()传默认值。虽然可以模拟 selectNode()和 selectNodeContents(),但 setStart()和setEnd()真正的威力还是选择节点中的某个部分。

      假设我们想通过范围从前面示例中选择从"Hello"中的"llo"到" world!"中的"o"的部分。很简单,第一步是取得所有相关节点的引用,如下面的代码所示:

      let p1 = document.getElementById("p1"), 
       helloNode = p1.firstChild.firstChild, 
       worldNode = p1.lastChild
      

      文本"Hello"其实是

      的孙子节点,因为它是的子节点。为此可以使用 p1.firstChild 取 得,而使用 p1.firstChild.firstChild 取得"Hello"这个文本节点。文本节点" world!"是

      的第二个(也是最后一个)子节点,因此可以使用 p1.lastChild 来取得它。然后,再创建范围,指定其边界,如下所示:

      let range = document.createRange(); 
      range.setStart(helloNode, 2); 
      range.setEnd(worldNode, 3);
      

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

      因为 helloNode 和 worldNode 是文本节点,所以它们会成为范围的 startContainer 和endContainer,这样 startOffset 和 endOffset 实际上表示每个节点中文本字符的位置,而不是子节点的位置(传入元素节点时的情形)。而 commonAncestorContainer 是

      元素,即包含这两个节点的第一个祖先节点。

      当然,只选择文档中的某个部分并不是特别有用,除非可以对选中部分执行操作。

      操作范围

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

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

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

      第一个方法最容易理解和使用: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();
      

      执行上面的代码之后,页面中的 HTML 会变成这样:

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

      因为前面介绍的范围选择过程通过修改底层 DOM 结构保证了结构完好,所以即使删除范围之后,剩下的 DOM 结构照样是完好的。

      另一个方法 extractContents()跟 deleteContents()类似,也会从文档中移除范围选区。但不同的是,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);
      

      这个例子提取了范围的文档片段,然后把它添加到文档元素的最后。(别忘了,在把文档片段传给 appendChild()时,只会添加片段的子树,不包含片段自身。)结果就会得到如下 HTML:

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

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

      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.cloneContents(); 
      p1.parentNode.appendChild(fragment);
      

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

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

      此时关键是要知道,为保持结构完好而拆分节点的操作,只有在调用前述方法时才会发生。在 DOM被修改之前,原始 HTML 会一直保持不变。

      范围插入

      上一节介绍了移除和复制范围的内容,本节来看一看怎么向范围中插入内容。使用 insertNode()方法可以在范围选区的开始位置插入一个节点。例如,假设我们想在前面例子中的 HTML中插入如下 HTML:

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

      可以使用下列代码:

      let p1 = document.getElementById("p1"), 
       helloNode = p1.firstChild.firstChild, 
       worldNode = p1.lastChild, 
       range = document.createRange(); 
      range.setStart(helloNode, 2); 
      range.setEnd(worldNode, 3);
      
      let span = document.createElement("span"); 
      span.style.color = "red"; 
      span.appendChild(document.createTextNode("Inserted text")); 
      range.insertNode(span);
      

      运行上面的代码会得到如下 HTML 代码:

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

      注意,正好插入到"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); 
      let span = document.createElement("span"); 
      span.style.backgroundColor = "yellow"; 
      range.surroundContents(span);
      

      执行以上代码会以黄色背景高亮显示范围选择的文本。得到的 HTML 如下所示:

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

      为了插入元素,范围中必须包含完整的 DOM 结构。如果范围中包含部分选择的非文节点,这个操作会失败并报错。另外,如果给定的节点是 Document、DocumentType 或 DocumentFragment类型,也会导致抛出错误。

      范围折叠

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

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

      range.collapse(true); // 折叠到起点
      console.log(range.collapsed); // 输出 true
      

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

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

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

      let p1 = document.getElementById("p1"), 
       p2 = document.getElementById("p2"), 
       range = document.createRange(); 
      range.setStartAfter(p1); 
      range.setStartBefore(p2); 
      console.log(range.collapsed); // true
      

      在这种情况下,创建的范围是折叠的,因为 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
      

      在这段代码中,两个范围的起点是相等的,因为它们都是 selectNodeContents()默认返回的值。

      因此,比较二者起点的方法返回 0。不过,因为 range2 的终点被使用 setEndBefore()修改了,所以

      导致 range1 的终点位于 range2 的终点之后(见图 16-11),结果这个方法返回了 1。

      复制范围

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

      let newRange = range.cloneRange();
      

      新范围包含与原始范围一样的属性,修改其边界点不会影响原始范围。

      清理

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

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

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