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"
这里先创建了一个
内嵌窗格的变化
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-image | style.backgroundImage |
| color | style.color |
| display | style.display |
| font-family | style.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 的值为value,priority 是"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"
注意,这样修改规则会影响到页面上所有应用了该规则的元素。如果页面上有两个
创建规则
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()返回
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),则会导致遍历立即返回,不会访问任何节点。这是因为第一个返回的元素是
当然,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();
}
因为我们知道
元素,再通过 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; // 解除引用这两步是最合理的结束使用范围的方式。剥离之后的范围就不能再使用了。