跳到主要内容

DOM

文档对象模型(DOM,Document Object Model)是 HTML 和 XML 文档的编程接口。它表示由多层节点构成的文档,开发者可以通过它来添加、删除和修改页面的各个部分。DOM 最初源自于网景和微软早期的动态 HTML(DHTML,Dynamic HTML),如今已经成为真正跨平台、语言无关的表示和操作网页的标准方式。

DOM Level 1 于 1998 年成为 W3C 推荐标准,提供了基本文档结构和查询的接口。虽然现在已经有了更高级别的DOM规范(如DOM Level 2、DOM Level 3等),但DOM Level 1 作为基础的部分仍然是非常重要的。它为网页的动态操作和交互提供了基础。

节点层级

任何 HTML 或 XML 文档都可以用 DOM 表示为一个由节点构成的层级结构。节点分很多类型,每种类型对应着文档中不同的信息和(或)标记,也都有自己不同的特性、数据和方法,而且与其他类型有某种关系。这些关系构成了层级,让标记可以表示为一个以特定节点为根的树形结构。以下面的 HTML为例:

<html lang="en">
<head>
<title>Sample Page</title>
</head>
<body>
<p>Hello World!</p>
</body>
</html>

如果表示为层级结构,则如图所示。 14-1

其中,document 节点表示每个文档的根节点。在这里,根节点的唯一子节点是 <html> 元素,我们称之为文档元素(documentElement)。文档元素是文档最外层的元素,所有其他元素都存在于这个元素之内。每个文档只能有一个文档元素。在 HTML 页面中,文档元素始终是 <html> 元素。在 XML 文档中,则没有这样预定义的元素,任何元素都可能成为文档元素。

HTML 中的每段标记都可以表示为这个树形结构中的一个节点。元素节点表示 HTML 元素,属性节点表示属性,文档类型节点表示文档类型,注释节点表示注释。DOM 中总共有 12 种节点类型,这些类型都继承一种基本类型。

Node 类型

DOM Level 1 描述了名为 Node 的接口,这个接口是所有 DOM 节点类型都必须实现的。Node 接口在 JavaScript中被实现为 Node 类型,在除 IE之外的所有浏览器中都可以直接访问这个类型。在 JavaScript中,所有节点类型都继承 Node 类型,因此所有类型都共享相同的基本属性和方法。

每个节点都有 nodeType 属性,表示该节点的类型。节点类型由定义在 Node 类型上的 12 个数值常量表示:

常量
Node.ELEMENT_NODE1
Node.ATTRIBUTE_NODE 2
Node.TEXT_NODE3
Node.CDATA_SECTION_NODE4
Node.ENTITY_REFERENCE_NODE 5
Node.ENTITY_NODE 6
Node.PROCESSING_INSTRUCTION_NODE7
Node.COMMENT_NODE8
Node.DOCUMENT_NODE9
Node.DOCUMENT_TYPE_NODE10
Node.DOCUMENT_FRAGMENT_NODE11
Node.NOTATION_NODE 12
节点类型比较示例
if (someNode.nodeType == Node.ELEMENT_NODE) {
alert('someNode 是一个元素节点');
}

1.nodeName 与 nodeValue

在DOM(文档对象模型)中,nodeNamenodeValue 是节点对象的两个属性。

nodeName 属性表示节点的名称,这取决于节点的类型。对于元素节点,它是元素的标签名(大写字母形式),对于属性节点,它是属性的名称,对于文本节点,它是 #text,对于注释节点,它是 #comment

nodeValue 属性表示节点的值,它根据节点的类型而不同。对于元素节点,文本节点和注释节点,它是节点所包含的文本内容;对于属性节点,它是属性的值。

2.节点关系

在DOM(文档对象模型)中,节点之间可以建立不同类型的关系,这些关系用于描述文档结构。以下是常见的节点关系:

父子关系(Parent-Child Relationship)
  • 每个节点(除了根节点)都可以有一个父节点。父节点是指直接包含该节点的节点。
  • 每个节点可以有零个或多个子节点。子节点是指直接被该节点包含的节点。

通过 parentNodechildNodes 属性,可以访问DOM树中节点的父节点和子节点。

// 访问 body 元素的父节点
console.log(document.body.parentNode); // <html>……</html>

// 访问 html 元素的子节点
console.log(document.documentElement.childNodes); // NodeList(4) [script, head, text, body]

NodeList 是一个类数组对象, 可以通过中括号或 item() 方法访问 NodeList 中的元素:

let nodeList = document.documentElement.childNodes;

console.log(nodeList[0]); // <script>……</script>
console.log(nodeList.item(1)); // <head>……</head>
console.log(nodeList.length); // 4
兄弟关系(Sibling Relationship)

兄弟节点是具有相同父节点的节点。在DOM中,同一父节点下的节点称为兄弟节点。

  • nextSibling: 属性返回节点之后紧跟的兄弟节点。
  • previousSibling: 属性返回节点之前紧跟的兄弟节点。
<!doctype html>
<html>
<head>
<title>兄弟关系示例</title>
</head>
<body>
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>

<script>
const firstItem = document.querySelector('li:nth-child(1)');
const secondItem = document.querySelector('li:nth-child(2)');
const thirdItem = document.querySelector('li:nth-child(3)');

console.log(secondItem.previousSibling); // 输出:<li>Item 1</li>
console.log(secondItem.nextSibling); // 输出:<li>Item 3</li>

console.log(firstItem.previousSibling); // #text
console.log(thirdItem.nextSibling); // #text

console.log(thirdItem.previousElementSibling); // null
console.log(thirdItem.nextElementSibling); // null
</script>
</body>
</html>

这个列表中第一个节点的 previousSibling 属性和最后一个节点的 nextSibling 属性返回的是文本节点。若需要返回 null,使用 previousElementSiblingnextElementSibling 属性。

父节点和它的第一个及最后一个子节点也有专门属性:firstChildlastChild 分别指向 childNodes 中的第一个和最后一个子节点。

<!doctype html>
<html>
<head>
<title>firstChild 和 lastChild 示例</title>
</head>
<body>
<ul id="parentList">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>

<script>
const parentList = document.getElementById('parentList');

console.log(parentList.firstChild); // 输出:<li>Item 1</li>
console.log(parentList.lastChild); // 输出:<li>Item 3</li>

console.log(parentList.firstChild === parentList.childNodes[0]); // true
console.log(
parentList.lastChild ===
parentList.childNodes[parentList.childNodes.length - 1],
); // true
</script>
</body>
</html>

如果只有一个子节点,则 firstChildlastChild 指向同一个节点。如果没有子节点,则 firstChildlastChild 都是 null

如下清晰地展示了节点之间的关系:

14-2

最后还有一个所有节点都共享的关系。Node.ownerDocument 只读属性会返回当前节点的顶层的 document 对象。

document = node.ownerDocument;

3.操作关系

操纵节点是指在DOM(文档对象模型)中对节点进行添加、删除、移动或修改等操作。这些操作可以通过JavaScript来完成,以下是一些常见的节点操作示例:

  • document.createElement(): 创建新节点。
// 创建新的 <p> 元素节点
const paragraph = document.createElement('p');
paragraph.textContent = 'This is a new paragraph.';
  • node.removeChild(): 删除节点。
// 获取要删除的节点
var nodeToRemove = document.getElementById('someElement');

// 从其父节点中移除该节点
nodeToRemove.parentNode.removeChild(nodeToRemove);
  • node.replaceChild(): 替换文档中的节点。
// 创建新的 <span> 元素节点
var newSpan = document.createElement('span');
newSpan.textContent = 'New content';

// 获取要替换的节点
var nodeToReplace = document.getElementById('oldSpan');

// 从其父节点中替换该节点
nodeToReplace.parentNode.replaceChild(newSpan, nodeToReplace);
  • node.appendChild(): 将一个节点添加到指定父节点的子节点列表的末尾处。
  • node.insertBefore(): 在参考节点之前插入一个拥有指定父节点的子节点。
// 获取要移动的节点
var nodeToMove = document.getElementById('someNode');

// 获取目标位置的父节点
var targetParent = document.getElementById('targetParent');

// 将节点移动到目标位置的末尾
targetParent.appendChild(nodeToMove);

// 或者将节点移动到目标位置的特定位置之前
var referenceNode = document.getElementById('referenceNode');
targetParent.insertBefore(nodeToMove, referenceNode);

4.其它方法

所有节点类型还共享了两个方法: cloneNode()normalize()

cloneNode() 方法用于克隆节点。它会创建当前节点的一个副本,并返回该副本。

语法
/**
* node: 要克隆的节点
* deep: 是一个布尔值,表示是否深度克隆子节点
*/
const clonedNode = node.cloneNode(deep);
cloneNode() 示例
console.log(document.body.cloneNode()); // <body></body>
console.log(document.body.cloneNode(true)); // <body>…………子节点</body>

normalize() 方法用于规范化节点树。当在DOM中对节点进行操作时,可能会导致文本节点之间存在空白节点或连续的文本节点,这可能会影响到后续的处理和渲染。normalize() 方法可以移除空白文本节点,并将相邻的文本节点合并为一个文本节点。

Document 类型

Document 类型是 JavaScript 中表示文档节点的类型。在浏览器中,文档对象 document 是 HTMLDocument 的实例(HTMLDocument 继承 Document),表示整个 HTML 页面。document 是 window对象的属性,因此是一个全局对象。Document 类型的节点有以下特征:

  • nodeType 等于 9;
  • nodeName 值为"#document";
  • nodeValue 值为 null
  • parentNode 值为 null
  • ownerDocument 值为 null
  • 子节点可以是 DocumentType(最多一个)、Element(最多一个)、ProcessingInstructionComment 类型。

1.文档子节点

虽然 DOM 规范规定 Document 节点的子节点可以是 DocumentType、Element、ProcessingInstruction 或 Comment,但也提供了两个访问子节点的快捷方式。第一个是 documentElement 属性,始终指向 HTML 页面中的 <html> 元素。第二个是 body 属性,始终指向 <body> 元素。

所有主流浏览器都支持 document.documentElementdocument.body

let html = document.documentElement; // 取得对<html>的引用
let body = document.body; // 取得对<body>的引用

Document 类型另一种可能的子节点是 DocumentType。<!doctype> 标签是文档中独立的部分,其信息可以通过 doctype 属性(在浏览器中是 document.doctype)来访问,比如:

let doctype = document.doctype; // 取得对<!doctype>的引用

2.文档信息

document 作为 HTMLDocument 的实例,还有一些标准 Document 对象上所没有的属性。这些属性提供浏览器所加载网页的信息。其中第一个属性是 title,包含 <title> 元素中的文本,通常显示在浏览器窗口或标签页的标题栏。通过这个属性可以读写页面的标题,修改后的标题也会反映在浏览器标题栏上。不过,修改 title 属性并不会改变 <title> 元素。下面是一个例子:

// 读取文档标题
let originalTitle = document.title;

// 修改文档标题
document.title = 'New page title';

接下来要介绍的 3 个属性是 URLdomainreferrer。其中,URL 包含当前页面的完整 URL(地址栏中的 URL),domain 包含页面的域名,而 referrer 包含链接到当前页面的那个页面的 URL。。如果当前页面没有来源,则 referrer 属性包含空字符串。所有这些信息都可以在请求的 HTTP 头部信息中获取,只是在 JavaScript 中通过这几个属性暴露出来而已,如下面的例子所示:

// 以 http://127.0.0.1:5500/index.html 为例

// 取得完整的 URL
console.log(document.URL); // http://127.0.0.1:5500/index.html

// 取得域名
console.log(document.domain); // 127.0.0.1

// 取得来源
console.log(document.referrer); // http://127.0.0.1:5500/index.html

在这些属性中,只有 domain 属性是可以设置的。出于安全考虑,给 domain 属性设置的值是有限制的。

// 页面来自 p2p.wrox.com
// highlight-success
document.domain = 'wrox.com'; // 成功
// highlight-error
document.domain = 'nczonline.net'; // 出错!

/*
浏览器对 domain 属性还有一个限制,
即这个属性一旦放松就不能再收紧。
*/
// highlight-success
document.domain = 'wrox.com'; // 放松,成功
// highlight-error
document.domain = 'p2p.wrox.com'; // 收紧,错误!

3.定位元素

使用 DOM 最常见的情形可能就是获取某个或某组元素的引用,然后对它们执行某些操作。

document 对象上暴露了一些方法,可以实现这些操作。getElementById()getElementsByTagName()getElementsByName 就是 Document 类型提供的两个方法。

getElementById() 方法接收一个参数,即要获取元素的 ID,如果找到了则返回这个元素,如果没找到则返回 null。参数 ID 必须跟元素在页面中的 id 属性值完全匹配,包括大小写。

getElementById() 示例
let div = document.getElementById('myDiv'); // 取得对这个<div id="myDiv">元素的引用

getElementsByTagName() 方法接收一个参数,即要获取元素的标签名,返回包含零个或多个元素的 NodeList。在 HTML 文档中,这个方法返回一个 HTMLCollection 对象。

let images = document.getElementsByTagName('img');

// 要取得文档中的所有元素,可以给 getElementsByTagName()传入*。
let allElement = document.getElementsByTagName('*');

getElementsByName() 方法接收一个参数,会根据给定的 name 返回一返回包含零个或多个元素的 NodeList。在 HTML 文档中,这个方法返回一个 HTMLCollection 对象。

HTMLCollection 对象和 NodeList 对象都是实时列表,都可以通过中括号或 item() 方法获取元素。

HTMLCollection 对象还有一个额外的方法 namedItem(),可通过标签的 name 属性取得某一项的引用。

getElementsByTagName() 一样,getElementsByName() 方法也返回 HTMLCollection。不过在这种情况下,namedItem() 方法只会取得第一项(因为所有项的 name 属性都一样)。

4.特殊集合

document 对象上还暴露了几个特殊集合,这些集合也都是 HTMLCollection 的实例。这些集合是访问文档中公共部分的快捷方式,列举如下。

  • document.forms: 返回当前文档中的 <form> 元素的一个集合。
  • document.images: 返回当前文档中所有 image 元素的集合。
  • document.links: 返回一个文档中所有具有 href 属性值的 <area> 元素与 <a> 元素的集合

这些特殊集合始终存在于 HTMLDocument 对象上,而且与所有 HTMLCollection 对象一样,其内容也会实时更新以符合当前文档的内容。

5.文档写入

document 对象有一个古老的能力,即向网页输出流中写入内容。这个能力对应 4 个方法:write()writeln()open()close()。其中,write()writeln() 方法都接收一个字符串参数,可以将这个字符串写入网页中。write()简单地写入文本,而 writeln()还会在字符串末尾追加一个换行符(\n)。这两个方法可以用来在页面加载期间向页面中动态添加内容,如下所示:

document.write('<h1>Hello, world!</h1>');
document.writeln('<p>This is a paragraph written using document.writeln()</p>');

如果是在页面加载完之后再调用 document.write(),则输出的内容会重写整个页面。

// 文档被 "hello world" 重写
window.onload = function () {
document.write('<h1>Hello, world!</h1>');
};

open() 方法:通常在 <script> 标签内部使用,用于开启一个新的文档流。当使用 <script> 标签动态创建文档时,可以在使用 document.write() 或 document.writeln() 方法之前调用 open() 方法,以确保后续的输出内容会被写入到新的文档流中。

close() 方法:与 open() 方法相对应,在使用 document.write()document.writeln() 方法写入内容之后,可以调用 close() 方法来关闭当前的文档流。这样做会告诉浏览器当前的文档流已经完成,可以将其呈现到页面上。

// 开启文档流
document.open();

// 关闭文档流
document.close();

open()close() 方法分别用于打开和关闭网页输出流。在调用 write()writeln() 时,这两个方法都不是必需的。

Element 类型

除了Document 类型,Element 类型就是Web开发中最常用的类型了。Element 表示 XML 或 HTML 元素,对外暴露出访问元素标签名、子节点和属性的能力。Element 类型的节点具有以下特征:

  • nodeType 等于 1;
  • nodeName 值为元素的标签名;
  • nodeValue 值为 null;
  • parentNode 值为 Document 或 Element 对象;
  • 子节点可以是 Element、Text、Comment、ProcessingInstruction、CDATASection、EntityReference 类型。

可以通过 nodeNametagName 属性来获取元素的标签名。这两个属性返回同样的值。

/**
* nodeName 和 tagName 返回值说明:
* 在 HTML 中,元素标签名始终以全大写表示
* 在 XML(包括 XHTML)中,标签名始终与源代码中的大小写一致
*/
// highlight-error
if (element.tagName == 'div') {
// 不要这样做,可能出错!
// do something here
}

// highlight-success
if (element.tagName.toLowerCase() == 'div') {
// 推荐,适用于所有文档
// 做点什么
}

1.HTML 元素

所有 HTML 元素都通过 HTMLElement 类型表示,包括其直接实例和间接实例。另外,HTMLElement 直接继承 Element 并增加了一些属性。每个属性都对应下列属性之一,它们是所有 HTML 元素上都有的标准属性:

  • id,元素在文档中的唯一标识符;
  • title,包含元素的额外信息,通常以提示条形式展示;
  • lang,元素内容的语言代码(很少用);
  • dir,语言的书写方向("ltr"表示从左到右,"rtl"表示从右到左,同样很少用);
  • className,相当于 class 属性,用于指定元素的 CSS 类(因为 class 是 ECMAScript 关键字,所以不能直接用这个名字)。

所有这些都可以用来获取对应的属性值,也可以用来修改相应的值。

操作如上属性进行读写
// html: <div id="myDiv" class="bd" title="Body text" lang="en" dir="ltr"></div>

let div = document.getElementById('myDiv');
// 读
console.log(div.id); // myDiv
console.log(div.className); // db

// 写
div.title = 'Some other text';
div.dir = 'rtl';

如前所述,所有 HTML 元素都是 HTMLElement 或其子类型的实例。下表列出了所有 HTML 元素及其对应的类型。

元素类型元素类型
aHTMLAnchorElementabbrHTMLElement
addressHTMLElementareaHTMLAreaElement
articleHTMLElementasideHTMLElement
audioHTMLAudioElementbHTMLElement
baseHTMLBaseElementbdiHTMLElement
bdoHTMLElementblockquoteHTMLQuoteElement
bodyHTMLBodyElementbrHTMLBRElement
buttonHTMLButtonElementcanvasHTMLCanvasElement
captionHTMLTableCaptionElementciteHTMLElement
codeHTMLElementcolHTMLTableColElement
colgroupHTMLTableColElementdataHTMLDataElement
datalistHTMLDataListElementddHTMLElement
delHTMLModElementdetailsHTMLDetailsElement
dfnHTMLElementdialogHTMLDialogElement
divHTMLDivElementdlHTMLDListElement
dtHTMLElementemHTMLElement
embedHTMLEmbedElementfieldsetHTMLFieldSetElement
figcaptionHTMLElementfigureHTMLElement
footerHTMLElementformHTMLFormElement
h1-h6HTMLHeadingElementheadHTMLHeadElement
headerHTMLElementhrHTMLHRElement
htmlHTMLHtmlElementiHTMLElement
iframeHTMLIFrameElementimgHTMLImageElement
inputHTMLInputElementinsHTMLModElement
kbdHTMLElementlabelHTMLLabelElement
legendHTMLLegendElementliHTMLLIElement
linkHTMLLinkElementmainHTMLElement
mapHTMLMapElementmarkHTMLElement
menuHTMLElementmetaHTMLMetaElement
meterHTMLMeterElementnavHTMLElement
noscriptHTMLElementobjectHTMLObjectElement
olHTMLOListElementoptgroupHTMLOptGroupElement
optionHTMLOptionElementoutputHTMLOutputElement
pHTMLParagraphElementparamHTMLParamElement
pictureHTMLPictureElementpreHTMLPreElement
progressHTMLProgressElementqHTMLQuoteElement
rpHTMLElementrtHTMLElement
rubyHTMLElementsHTMLElement
sampHTMLElementscriptHTMLScriptElement
sectionHTMLElementselectHTMLSelectElement
slotHTMLSlotElementsmallHTMLElement
sourceHTMLSourceElementspanHTMLSpanElement
strongHTMLElementstyleHTMLStyleElement
subHTMLElementsummaryHTMLElement
supHTMLElementtableHTMLTableElement
tbodyHTMLTableSectionElementtdHTMLTableDataCellElement
templateHTMLTemplateElementtextareaHTMLTextAreaElement
tfootHTMLTableSectionElementthHTMLTableHeaderCellElement
theadHTMLTableSectionElementtimeHTMLTimeElement
titleHTMLTitleElementtrHTMLTableRowElement
trackHTMLTrackElementuHTMLElement
ulHTMLUListElementvarHTMLElement
videoHTMLVideoElementwbrHTMLElement

2.取得属性和设置属性

每个元素都有零个或多个属性,通常用于为元素或其内容附加更多信息。与属性相关的 DOM 方法主要有 3 个:getAttribute()setAttribute()removeAttribute()。这些方法主要用于操纵属性,包括在 HTMLElement 类型上定义的属性和自定义属性。

HTML片段
<div id="myDiv" class="my-class" data-age="18"></div>
获取属性 getAttribute() 示例
let div = document.getElementById('myDiv');

// 取得 HTML 正式属性
let id = div.getAttribute('id'); // getAttribute('ID') 等阶,属性名不区分大小写
let className = div.getAttribute('class'); // 要传"class"而非"className"(className 是作为对象属性时才那么拼写的)。

console.log(id, className);

// 取得自定义属性
// 根据 HTML5 规范的要求,自定义属性名应该前缀 data-以方便验证。
console.log(div.getAttribute('data-age'));

getAttribute() 方法并不是对获取所有属性都得心应手。

通过 DOM 对象访问的属性中有两个返回的值跟使用 getAttribute() 取得的值不一样。首先是 style 属性,这个属性用于为元素设定 CSS 样式。在使用 getAttribute() 访问 style 属性时,返回的是 CSS 字符串。而在通过 DOM 对象的属性访问时,style 属性返回的是一个(CSSStyleDeclaration)对象。DOM 对象的 style 属性用于以编程方式读写元素样式,因此不会直接映射为元素中 style 属性的字符串值。

第二个属性其实是一类,即事件处理程序(或者事件属性),比如 onclick。在元素上使用事件属性时,属性的值是一段 JavaScript 代码。如果使用 getAttribute() 访问事件属性,则返回的是字符串形式的源代码。而通过 DOM 对象的属性访问事件属性时返回的则是一个 JavaScript函数(未指定该属性则返回 null)。这是因为 onclick 及其他事件属性是可以接受函数作为值的。

考虑到以上差异,开发者在进行DOM编程时通常会放弃使用 getAttribute() 而只使用对象属性。getAttribute() 主要用于取得自定义属性的值。


setAttribute() 方法接收两个参数:要设置的属性名和属性的值。如果属性已经存在,则 setAttribute() 会以指定的值替换原来的值;如果属性不存在,则 setAttribute() 会以指定的值创建该属性。

设置属性 setAttribute() 示例
let div = document.getElementById('myDiv');

div.setAttribute('class', 'new-my-class');
console.log(div.className); // new-my-class

removeAttribute() 方法接收一个参数:要删除的属性名,从指定的元素中删除一个属性。

删除属性 removeAttribute() 示例
let div = document.getElementById('myDiv');

console.log(div.getAttribute('class')); // my-class
div.removeAttribute('class');
console.log(div.getAttribute('class')); // null

3.attributes 属性

Element 类型是唯一使用 attributes 属性的 DOM 节点类型。attributes 属性包含一个NamedNodeMap 实例,是一个类似 NodeList 的“实时”集合。元素的每个属性都表示为一个 Attr 节点,并保存在这个 NamedNodeMap 对象中。NamedNodeMap 对象包含下列方法:

  • getNamedItem(name),返回 nodeName 属性等于 name 的节点;
  • removeNamedItem(name),删除 nodeName 属性等于 name 的节点;
  • setNamedItem(node),向列表中添加 node 节点,以其 nodeName 为索引;
  • item(pos),返回索引位置 pos 处的节点。

attributes 属性中的每个节点的 nodeName 是对应属性的名字,nodeValue 是属性的值。

getNamedItem() 示例
// <div id="myDiv"></div>
let idAttr = element.attributes.getNamedItem('id');

console.log(idAttr.nodeName); // id
console.log(idAttr.nodeValue); // myDiv

// 中括号访问属性的简写形式
let idAttr2 = element.attributes['id'];
console.log(idAttr2.nodeValue); // myDiv

removeNamedItem() 方法与元素上的 removeAttribute() 方法类似,也是删除指定名字的属性。下面的例子展示了这两个方法唯一的不同之处,就是 removeNamedItem() 返回表示被删除属性的Attr节点:

let oldAttr = element.attributes.removeNamedItem('id');

setNamedItem() 方法很少使用,它接收一个属性节点,然后给元素添加一个新属性,如下所示:

// 创建一个属性节点
var attribute = document.createAttribute('class');
attribute.value = 'my-class';

// 使用 setNamedItem() 方法添加属性
div.attributes.setNamedItem(attribute);

一般来说,因为使用起来更简便,通常开发者更喜欢使用 getAttribute()removeAttribute()setAttribute() 方法,而不是刚刚介绍的 NamedNodeMap 对象的方法。

4.创建元素

可以使用 document.createElement() 方法创建新元素。这个方法接收一个参数,即要创建元素的标签名。在 HTML 文档中,标签名是不区分大小写的,而 XML 文档(包括 XHTML)是区分大小写的。要创建 <div> 元素,可以使用下面的代码:

let div = document.createElement('div');

5.元素后代

元素可以拥有任意多个子元素和后代元素,因为元素本身也可以是其他元素的子元素。childNodes 属性包含元素所有的子节点,这些子节点可能是其他元素、文本节点、注释或处理指令。不同浏览器在识别这些节点时的表现有明显不同。

HTML 片段
<ul id="myList">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
let myList = document.getElementById('myList');

// 如下打印的节点,包含了元素节点、文本节点
console.log(myList.childNodes); // NodeList(7) [text, li, text, li, text, li, text]

// 若要子节点元素处理,可以在遍历时,设置关口
for (let i of myList.childNodes) {
// 元素节点放行
if (i.nodeType === 1) {
console.log(i); // li li li
}
}

// 如果要取得某个元素的子节点和其他后代节点,可以使用元素的 getElementsByTagName() 方法。
let childElement = myList.getElementsByTagName('li');
console.log(childElement); // HTMLCollection(3) [li, li, li]

Text 类型

Text 节点由 Text 类型表示,包含按字面解释的纯文本,也可能包含转义后的 HTML 字符,但不含 HTML 代码。Text 类型的节点具有以下特征:

  • nodeType 等于 3;
  • nodeName 值为 "#text";
  • nodeValue 值为节点中包含的文本;
  • parentNode 值为 Element 对象;
  • 不支持子节点。

Text 节点中包含的文本可以通过 nodeValue 属性访问,也可以通过 data 属性访问,这两个属性包含相同的值。修改 nodeValue 或 data 的值,也会在另一个属性反映出来。文本节点暴露了以下操作文本的方法:

  • appendData(text),向节点末尾添加文本 text;
  • deleteData(offset, count),从位置 offset 开始删除 count 个字符;
  • insertData(offset, text),在位置 offset 插入 text;
  • replaceData(offset, count, text),用 text 替换从位置 offset 到 offset + count 的文本;
  • splitText(offset),在位置 offset 将当前文本节点拆分为两个文本节点;
  • substringData(offset, count),提取从位置 offset 到 offset + count 的文本。

除了这些方法,还可以通过 length 属性获取文本节点中包含的字符数量。这个值等于 nodeValue.lengthdata.length

1.创建文本节点

document.createTextNode() 可以用来创建新文本节点,它接收一个参数,即要插入节点的文本。跟设置已有文本节点的值一样,这些要插入的文本也会应用 HTML 或 XML 编码,如下面的例子所示:

let textNode = document.createTextNode('Hello world!');
element.appendChild(textNode);

document.body.appendChild(element);

2.规范化文本节点

DOM 文档中的同胞文本节点可能导致困惑,因为一个文本节点足以表示一个文本字符串。同样,DOM 文档中也经常会出现两个相邻文本节点。为此,有一个方法可以合并相邻的文本节点。这个方法叫 normalize(),是在 Node 类型中定义的(因此所有类型的节点上都有这个方法)。在包含两个或多个相邻文本节点的父节点上调用 normalize() 时,所有同胞文本节点会被合并为一个文本节点,这个文本节点的 nodeValue 就等于之前所有同胞节点 nodeValue 拼接在一起得到的字符串。来看下面的例子:

normalize() 示例
let element = document.createElement('div');
element.className = 'message';

let textNode = document.createTextNode('Hello world!');
element.appendChild(textNode);

let anotherTextNode = document.createTextNode('Yippee!');
element.appendChild(anotherTextNode);

document.body.appendChild(element);

alert(element.childNodes.length); // 2

// 两个节点合并成一个节点
element.normalize();

alert(element.childNodes.length); // 1
alert(element.firstChild.nodeValue); // "Hello world!Yippee!"

浏览器在解析文档时,永远不会创建同胞文本节点。同胞文本节点只会出现在 DOM 脚本生成的文档树中。

3.拆分文本节点

Text 类型定义了一个与 normalize() 相反的方法 splitText()。这个方法可以在指定的偏移位置拆分 nodeValue,将一个文本节点拆分成两个文本节点。拆分之后,原来的文本节点包含开头到偏移位置前的文本,新文本节点包含剩下的文本。这个方法返回新的文本节点,具有与原来的文本节点相同的 parentNode

splitText() 示例
let element = document.createElement('div');
element.className = 'message';

let textNode = document.createTextNode('Hello world!');
element.appendChild(textNode);

document.body.appendChild(element);

// 拆分节点:1 分 2
let newNode = element.firstChild.splitText(5);

console.log(element.firstChild); // Hello
console.log(element.lastChild); // world!
console.log(newNode === element.lastChild); // true

Comment 类型

DOM 中的注释通过 Comment 类型表示。Comment 类型的节点具有以下特征:

  • nodeType 等于 8;
  • nodeName 值为"#comment";
  • nodeValue 值为注释的内容;
  • parentNode 值为 Document 或 Element 对象;
  • 不支持子节点。

Comment 类型与 Text 类型继承同一个基类(CharacterData),因此拥有除 splitText() 之外Text 节点所有的字符串操作方法。与 Text 类型相似,注释的实际内容可以通过 nodeValue 或 data属性获得。

注释节点可以作为父节点的子节点来访问。比如下面的 HTML 代码:

<div id="myDiv"><!-- A comment --></div>

<script>
let div = document.getElementById('myDiv');
let comment = div.firstChild;
console.log(comment.data); // "A comment"
</script>

可以使用 document.createComment() 方法创建注释节点,参数为注释文本,如下所示:

let comment = document.createComment('A comment');

CDATASection 类型

CDATASection 类型表示 XML 中特有的 CDATA 区块。CDATASection 类型继承 Text 类型,因此拥有包括 splitText() 在内的所有字符串操作方法。CDATASection 类型的节点具有以下特征:

  • nodeType 等于 4;
  • nodeName 值为"#cdata-section";
  • nodeValue 值为 CDATA 区块的内容;
  • parentNode 值为 Document 或 Element 对象;
  • 不支持子节点。

CDATA 区块只在 XML 文档中有效,因此某些浏览器比较陈旧的版本会错误地将 CDATA 区块解析为 Comment 或 Element。

在 XML 文档中,可以使用 document.createCDataSection() 并传入节点内容来创建CDATA 区块。

DocumentType 类型

DocumentType 类型的节点包含文档的文档类型(doctype)信息,具有以下特征:

  • nodeType 等于 10;
  • nodeName 值为文档类型的名称;
  • nodeValue 值为 null;
  • parentNode 值为 Document 对象;
  • 不支持子节点。

在 DOM Level 1 中,DocumentType 对象通常是在解析文档代码时创建的,而不是通过动态创建。该对象保存在 document.doctype 属性中。

DOM Level 1 规定了 DocumentType 对象的三个属性:nameentitiesnotations。其中,name 属性表示文档类型的名称,即紧跟在 <!DOCTYPE 后面的文本。而 entitiesnotations 分别表示文档类型描述的实体和表示法的 NamedNodeMap。

然而,在浏览器中,通常处理的文档类型是 HTML 或 XHTML,因此在这种情况下,entities 和 notations 列表通常为空。

对于给定的 HTML 4.01 严格文档类型,其 name 属性的值为 "html"。因此,可以使用 document.doctype.name 来获取文档类型的名称。

DocumentFragment 类型

在所有节点类型中,DocumentFragment 类型是唯一一个在标记中没有对应表示的类型。DOM 将文档片段定义为“轻量级”文档,能够包含和操作节点,却没有完整文档那样额外的消耗。DocumentFragment 节点具有以下特征:

  • nodeType 等于 11;
  • nodeName 值为"#document-fragment";
  • nodeValue 值为 null;
  • parentNode 值为 null;
  • 子节点可以是 Element、ProcessingInstruction、Comment、Text、CDATASection 或 EntityReference。

不能直接把文档片段添加到文档。相反,文档片段的作用是充当其他要被添加到文档的节点的仓库。可以使用 document.createDocumentFragment() 方法。可以通过 appendChild()insertBefore() 方法将文档片段的内容添加到文档。如下方法所示:

// 创建文档仓库
let fragment = document.createDocumentFragment();

document.body.appendChild(fragment);

在把文档片段作为参数传给这些方法时,这个文档片段的所有子节点会被添加到文档中相应的位置。文档片段本身永远不会被添加到文档树。

createDocumentFragment() 示例
<ul id="myList"></ul>

<script>
let fragment = document.createDocumentFragment();
let ul = document.getElementById('myList');

for (let i = 0; i < 3; ++i) {
let li = document.createElement('li');
li.appendChild(document.createTextNode(`Item ${i + 1}`));
fragment.appendChild(li);
}

ul.appendChild(fragment);
</script>
展示文档树结构
<!--fragment文档本身并没有被添加到文档树中-->
<ul id="myList">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>

Attr 类型

元素数据在 DOM 中通过 Attr 类型表示。Attr 类型构造函数和原型在所有浏览器中都可以直接访问。技术上讲,属性是存在于元素 attributes 属性中的节点。Attr 节点具有以下特征:

  • nodeType 等于 2;
  • nodeName 值为属性名;
  • nodeValue 值为属性值;
  • parentNode 值为 null;
  • 在 HTML 中不支持子节点;
  • 在 XML 中子节点可以是 Text 或 EntityReference。

属性节点尽管是节点,却不被认为是 DOM 文档树的一部分。Attr 节点很少直接被引用,通常开发者更喜欢使用 getAttribute()removeAttribute()setAttribute() 方法操作属性。

Attr 对象上有 3 个属性:name、value 和 specified。其中,name 包含属性名(与 nodeName 一样),value 包含属性值(与 nodeValue 一样),而 specified 是一个布尔值,表示属性使用的是默认值还是被指定的值。

可以使用 document.createAttribute() 方法创建新的 Attr 节点,参数为属性名。比如,要给 元素添加 align 属性,,可以使用下列代码:

let attr = document.createAttribute('align');
attr.value = 'left';
element.setAttributeNode(attr);

console.log(element.attributes['align'].value); // "left"
console.log(element.getAttributeNode('align').value); // "left"
console.log(element.getAttribute('align')); // "left"

DOM 编程

很多时候,操作 DOM 是很直观的。通过 HTML 代码能实现的,也一样能通过 JavaScript 实现。但有时候,DOM 也没有看起来那么简单。浏览器能力的参差不齐和各种问题,也会导致 DOM 的某些方面会复杂一些。

动态脚本

在网页中动态加载 JavaScript 代码,包括两种方式:引入外部文件和直接插入源代码。

<script src="client.js"></script>

可以使用 createElement 方法创建 <script> 元素,并设置 src 属性指向要加载的脚本文件。然后,将创建的 <script> 元素添加到页面中。

使用 DOM 编程创建脚本
function loadScript(url) {
let script = document.createElement('script');
script.src = url;
document.body.appendChild(script);
}

loadScript('client.js');

动态样式

CSS 样式在 HTML 页面中可以通过两个元素加载。<link> 元素用于包含 CSS 外部文件,而 <style> 元素用于添加嵌入样式。与动态脚本类似,动态样式也是页面初始加载时并不存在,而是在之后才添加到页面中的。

在head元素内引入外部样式文件
<head>
<link rel="stylesheet" type="text/css" href="styles.css" />
</head>
使用 DOM 编程创建样式
let link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = 'styles.css';
let head = document.getElementsByTagName('head')[0];
head.appendChild(link);

另一种定义样式的方式是使用 <style> 元素包含嵌入的 CSS 规则,定义在 <head> 元素内,例如:

<head>
<style>
body {
background-color: red;
}
</style>
</head>

操作表格

表格是 HTML 中最复杂的结构之一。通过 DOM 编程创建 <table> 元素,通常要涉及大量标签,包括表行、表元、表题,等等。因此,通过 DOM 编程创建和修改表格时可能要写很多代码。假设要通过DOM 来创建以下 HTML 表格:

HTML 片段
<table border="1" width="100%">
<tbody>
<tr>
<td>Cell 1,1</td>
<td>Cell 2,1</td>
</tr>
<tr>
<td>Cell 1,2</td>
<td>Cell 2,2</td>
</tr>
</tbody>
</table>
使用 DOM 编程创建表格
// 创建表格
let table = document.createElement('table');
table.border = 1;
table.width = '100%';

// 创建表体
let tbody = document.createElement('tbody');
table.appendChild(tbody);

// 创建第一行
let row1 = document.createElement('tr');
tbody.appendChild(row1);
let cell1_1 = document.createElement('td');
cell1_1.appendChild(document.createTextNode('Cell 1,1'));
row1.appendChild(cell1_1);
let cell2_1 = document.createElement('td');
cell2_1.appendChild(document.createTextNode('Cell 2,1'));
row1.appendChild(cell2_1);

// 创建第二行
let row2 = document.createElement('tr');
tbody.appendChild(row2);
let cell1_2 = document.createElement('td');
cell1_2.appendChild(document.createTextNode('Cell 1,2'));
row2.appendChild(cell1_2);
let cell2_2 = document.createElement('td');
cell2_2.appendChild(document.createTextNode('Cell 2,2'));
row2.appendChild(cell2_2);

// 把表格添加到文档主体
document.body.appendChild(table);

以上代码相当烦琐,也不好理解。为了方便创建表格,HTML DOM 给 <table><tbody><tr> 元素添加了一些属性和方法。

<table> 元素添加了以下属性和方法:

  • caption,指向 caption 元素的指针(如果存在);
  • tBodies,包含 tbody 元素的 HTMLCollection;
  • tFoot,指向 tfoot 元素(如果存在);
  • tHead,指向 thead 元素(如果存在);
  • rows,包含表示所有行的 HTMLCollection;
  • createTHead(),创建 thead 元素,放到表格中,返回引用;
  • createTFoot(),创建 tfoot 元素,放到表格中,返回引用;
  • createCaption(),创建 caption 元素,放到表格中,返回引用;
  • deleteTHead(),删除 thead 元素;
  • deleteTFoot(),删除 tfoot 元素;
  • deleteCaption(),删除 caption 元素;
  • deleteRow(pos),删除给定位置的行;
  • insertRow(pos),在行集合中给定位置插入一行。

<tbody> 元素添加了以下属性和方法:

-[ ] rows,包含 tbody 元素中所有行的 HTMLCollection;-[ ] deleteRow(pos),删除给定位置的行;-[ ] insertRow(pos),在行集合中给定位置插入一行,返回该行的引用。

<tr> 元素添加了以下属性和方法:

cells,包含 tr 元素所有表元的 HTMLCollection;  deleteCell(pos),删除给定位置的表元;  insertCell(pos),在表元集合给定位置插入一个表元,返回该表元的引用。

这些属性和方法极大地减少了创建表格所需的代码量。例如,使用这些方法重写前面的代码之后是这样的(加粗代码表示更新的部分):

重写使用 DOM 编程创建表格
// 创建表格
let table = document.createElement('table');
table.border = 1;
table.width = '100%';

// 创建表体
let tbody = document.createElement('tbody');
table.appendChild(tbody);

// 创建第一行
tbody.insertRow(0);
tbody.rows[0].insertCell(0);
tbody.rows[0].cells[0].appendChild(document.createTextNode('Cell 1,1'));
tbody.rows[0].insertCell(1);
tbody.rows[0].cells[1].appendChild(document.createTextNode('Cell 2,1'));

// 创建第二行
tbody.insertRow(1);
tbody.rows[1].insertCell(0);
tbody.rows[1].cells[0].appendChild(document.createTextNode('Cell 1,2'));
tbody.rows[1].insertCell(1);
tbody.rows[1].cells[1].appendChild(document.createTextNode('Cell 2,2'));

// 把表格添加到文档主体
document.body.appendChild(table);

使用 NodeList

理解 NodeList 对象和相关的 NamedNodeMap、HTMLCollection,是理解 DOM 编程的关键。这 3 个集合类型都是“实时的”,意味着文档结构的变化会实时地在它们身上反映出来,因此它们的值始终代表最新的状态。实际上,NodeList 就是基于 DOM 文档的实时查询。

导致无穷循环
let divs = document.getElementsByTagName('div');

for (let i = 0; i < divs.length; ++i) {
let div = document.createElement('div');
document.body.appendChild(div);
}

使用 ES6 迭代器并不会解决这个问题,因为迭代的是一个永远增长的实时集合。以下代码仍然会导致无穷循环:

for (let div of document.getElementsByTagName('div')) {
let newDiv = document.createElement('div');
document.body.appendChild(newDiv);
}

任何时候要迭代 NodeList,最好再初始化一个变量保存当时查询时的长度,然后用循环变量与这个变量进行比较。

let length = document.getElementsByTagName('div').length;

for (let i = 0; i < length; ++i) {
let div = document.createElement('div');
document.body.appendChild(div);
}

MutationObserver 接口

提示

新引进 MutationObserver 接口是为了取代废弃的 MutationEvent。

不久前添加到 DOM 规范中的 MutationObserver 接口,可以在 DOM 被修改时异步执行回调。使 用 MutationObserver 可以观察整个文档、DOM 树的一部分,或某个元素。此外还可以观察元素属性、 子节点、文本,或者前三者任意组合的变化。

基本用法

MutationObserver 的实例要通过调用 MutationObserver 构造函数并传入一个回调函数来创建:

let observer = new MutationObserver(() => console.log('DOM was mutated!'));

1.observe()

新创建的 MutationObserver 实例不会关联 DOM 的任何部分。要把这个 observer 与 DOM 关联起来,需要使用 observe() 方法。这个方法接收两个必需的参数:要观察其变化的 DOM 节点,以及一个 MutationObserverInit 对象。

MutationObserverInit 对象用于控制观察哪些方面的变化,是一个键/值对形式配置选项的字典。

function handle() {
console.log('<body> 元素属性被改变!');
}

let observer = new MutationObserver(handle);

observer.observe(document.body, { attributes: true });

document.body.className = 'body-class';

console.log('当前线程执行完毕');

// ①:当前线程执行完毕
// ②:<body> 元素属性被改变!

执行以上代码后,<body> 元素上任何属性发生变化都会被这个 MutationObserver 实例发现,然后就会异步执行注册的回调函数<body> 元素后代的修改或其他非属性修改都不会触发回调进入任务队列。

2.回调与 MutationRecord

每个回调都会收到一个 MutationRecord 实例的数组。MutationRecord 实例包含的信息包括发生了什么变化,以及 DOM 的哪一部分受到了影响。因为回调执行之前可能同时发生多个满足观察条件的事件,所以每次执行回调都会传入一个包含按顺序入队的 MutationRecord 实例的数组。

let observer = new MutationObserver(mutationRecords =>
console.log(mutationRecords),
);
observer.observe(document.body, { attributes: true });
document.body.setAttribute('foo', 'bar');
// [
// 0: {
// addedNodes: NodeList [],
// attributeName: "foo",
// attributeNamespace: null,
// nextSibling: null,
// oldValue: null,
// previousSibling: null
// removedNodes: NodeList [],
// target: body
// type: "attributes"
// }
// ]

连续修改会生成多个 MutationRecord 实例,下次回调执行时就会收到包含所有这些实例的数组, 顺序为变化事件发生的顺序:

let observer = new MutationObserver(mutationRecords =>
console.log(mutationRecords),
);

observer.observe(document.body, { attributes: true });

document.body.className = 'foo';
document.body.className = 'bar';
document.body.className = 'baz';
// [MutationRecord, MutationRecord, MutationRecord]

下表列出了 MutationRecord 实例的属性。

属性名描述
type表示 DOM 变动的类型。可能的值包括 "attributes""characterData""childList"
target表示发生变动的 DOM 节点。
addedNodes一个 NodeList,包含添加到 DOM 结构中的新节点。
removedNodes一个 NodeList,包含从 DOM 结构中移除的节点。
previousSibling表示被添加或移除的节点在其父节点的子节点列表中的前一个节点。
nextSibling表示被添加或移除的节点在其父节点的子节点列表中的后一个节点。
attributeName表示发生变动的属性的名称(仅在 type 为 "attributes" 时可用)。
attributeNamespace表示发生变动的属性的命名空间(仅在 type 为 "attributes" 时可用)。
oldValue表示变动前的属性值或字符数据(仅在 type 为 "attributes""characterData" 时可用)。

传给回调函数的第二个参数是观察变化的 MutationObserver 的实例

let observer = new MutationObserver((mutationRecords, mutationObserver) =>
console.log(mutationRecords, mutationObserver),
);

observer.observe(document.body, { attributes: true });

document.body.className = 'foo';

// [MutationRecord], MutationObserver

3.disconnect() 方法

默认情况下,只要被观察的元素不被垃圾回收,MutationObserver 的回调就会响应 DOM 变化事件,从而被执行。要提前终止执行回调,可以调用 disconnect() 方法。

同步调用 disconnect() 之后,不仅会停止此后变化事件的回调,也会抛弃已经加入任务队列要异步执行的回调

let observer = new MutationObserver(() =>
console.log('<body> attributes changed'),
);

observer.observe(document.body, { attributes: true });

document.body.className = 'foo';

observer.disconnect();

document.body.className = 'bar';
//(没有日志输出)

要想让已经加入任务队列的回调执行,可以使用 setTimeout() 让已经入列的回调执行完毕再调用 disconnect()。即异步终止执行回调,前面的异步队列先执行完再终止。

let observer = new MutationObserver(() =>
console.log('<body> attributes changed'),
);
observer.observe(document.body, { attributes: true });

document.body.className = 'foo';

setTimeout(() => {
observer.disconnect();

document.body.className = 'bar';
}, 0);

// <body> attributes changed

4.复用MutationObserver

多次调用 observe() 方法,可以复用一个 MutationObserver 对象观察多个不同的目标节点。此时,MutationRecord 的 target 属性可以标识发生变化事件的目标节点。

let observer = new MutationObserver(mutationRecords =>
console.log(mutationRecords.map(x => x.target)),
);

// 向页面主体添加两个子节点
let childA = document.createElement('div'),
childB = document.createElement('span');
document.body.appendChild(childA);
document.body.appendChild(childB);

// 观察两个子节点
observer.observe(childA, { attributes: true });
observer.observe(childB, { attributes: true });

// 修改两个子节点的属性
childA.setAttribute('foo', 'bar');
childB.setAttribute('foo', 'bar');

// [<div>, <span>]

disconnect() 方法是一个“一刀切”的方案,调用它会停止观察所有目标:

5.重用MutationObserver

调用 disconnect() 并不会结束 MutationObserver 的生命。还可以重新使用这个观察者,再将它关联到新的目标节点。下面的示例在两个连续的异步块中先断开然后又恢复了观察者与 <body> 元素的关联:

let observer = new MutationObserver(() =>
console.log('<body> attributes changed'),
);
observer.observe(document.body, { attributes: true });

// 这行代码会触发变化事件
document.body.setAttribute('foo', 'bar');

setTimeout(() => {
observer.disconnect();
// 这行代码不会触发变化事件
document.body.setAttribute('bar', 'baz');
}, 0);

setTimeout(() => {
observer.observe(document.body, { attributes: true });
// 这行代码会触发变化事件
document.body.setAttribute('baz', 'qux');
}, 0);

// <body> attributes changed
// <body> attributes changed

MutationObserverInit 与观察范围

MutationObserverInit 对象用于控制对目标节点的观察范围。粗略地讲,观察者可以观察的事件包括属性变化、文本变化和子节点变化。

下表列出了 MutationObserverInit 对象的属性。

属性名描述
childList一个布尔值,指示是否观察目标节点的子节点的变动。
attributes一个布尔值,指示是否观察目标节点的属性的变动。
characterData一个布尔值,指示是否观察目标节点的字符数据(文本节点或注释节点)的变动。
subtree一个布尔值,指示是否观察目标节点的所有后代节点的变动。
attributeOldValue一个布尔值,指示是否在 MutationRecord 对象的属性中包含变动前的属性值。
characterDataOldValue一个布尔值,指示是否在 MutationRecord 对象的属性中包含变动前的字符数据。
attributeFilter一个字符串数组,用于指定要观察的特定属性的名称。只有当指定属性发生变动时,才会触发 MutationObserver。
注意

在调用 observe() 时,MutationObserverInit 对象中的 attributecharacterDatachildList 属性必须至少有一项为 true(无论是直接设置这几个属性,还是通过设置attributeOldValue 等属性间接导致它们的值转换为 true)。否则会抛出错误,因为没有任何变化事件可能触发回调。

1.观察属性

MutationObserver 可以观察节点属性的添加、移除和修改。要为属性变化注册回调,需要在 MutationObserverInit 对象中将 attributes 属性设置为 true。开遍已引入该方案,不再赘述。

attributes 设置为 true 的默认行为是观察所有属性,但不会在 MutationRecord 对象中记录原来的属性值。如果想观察某个或某几个属性,可以使用 attributeFilter 属性来设置白名单,即一个属性名字符串数组:

let observer = new MutationObserver(mutationRecords =>
console.log(mutationRecords),
);

observer.observe(document.body, { attributeFilter: ['foo'] });

// 添加白名单属性
document.body.setAttribute('foo', 'bar');

// 添加被排除的属性
document.body.setAttribute('baz', 'qux');

// 只有 foo 属性的变化被记录了
// [MutationRecord]

如果想在变化记录中保存属性原来的值,可以将 attributeOldValue 属性设置为 true

let observer = new MutationObserver(mutationRecords =>
console.log(mutationRecords.map(x => x.oldValue)),
);

observer.observe(document.body, { attributeOldValue: true });

document.body.setAttribute('foo', 'bar');
document.body.setAttribute('foo', 'baz');
document.body.setAttribute('foo', 'qux');

// 每次变化都保留了上一次的值
// [null, 'bar', 'baz']

2.观察字符数据

MutationObserver 可以观察文本节点(如 Text、Comment 或 ProcessingInstruction 节点)中字符的添加、删除和修改。要为字符数据注册回调,需要在 MutationObserverInit 对象中将 characterData 属性设置为 true,如下所示:

let observer = new MutationObserver(mutationRecords =>
console.log(mutationRecords),
);

// 创建要观察的文本节点
document.body.firstChild.textContent = 'foo';

observer.observe(document.body.firstChild, { characterData: true });

// 赋值为相同的字符串
document.body.firstChild.textContent = 'foo';

// 赋值为新字符串
document.body.firstChild.textContent = 'bar';

// 通过节点设置函数赋值
document.body.firstChild.textContent = 'baz';

// 以上变化都被记录下来了
// [MutationRecord, MutationRecord, MutationRecord]

characterData 属性设置为 true 的默认行为不会在 MutationRecord 对象中记录原来的字符数据。如果想在变化记录中保存原来的字符数据,可以将 characterDataOldValue 属性设置为 true

let observer = new MutationObserver(mutationRecords =>
console.log(mutationRecords.map(x => x.oldValue)),
);

document.body.innerText = 'foo';

observer.observe(document.body.firstChild, { characterDataOldValue: true });

document.body.innerText = 'foo';
document.body.innerText = 'bar';
document.body.firstChild.textContent = 'baz';

// 每次变化都保留了上一次的值
// ["foo", "foo", "bar"]
注意

innerText 很容易与 Node.textContent 混淆,但这两个属性间实际上有很重要的区别。大体来说,innerText 知道文本的渲染外观,而 textContent 不知道。

3.观察子节点

MutationObserver 可以观察目标节点子节点的添加和移除。要观察子节点,需要在 MutationObserverInit 对象中将 childList 属性设置为 true

let observer = new MutationObserver(mutationRecords =>
console.log(mutationRecords.map(x => x.addedNodes)),
);

observer.observe(document.body, { childList: true });

document.body.appendChild(document.createElement('div'));

// [
// {
// addedNodes: NodeList[div],
// attributeName: null,
// attributeNamespace: null,
// oldValue: null,
// nextSibling: null,
// previousSibling: null,
// removedNodes: NodeList[],
// target: body,
// type: "childList",
// }
// ]

对子节点重新排序(尽管调用一个方法即可实现)会报告两次变化事件,因为从技术上会涉及先移除和再添加:

// 清空主体
document.body.innerHTML = '';
let observer = new MutationObserver(mutationRecords =>
console.log(mutationRecords),
);

// 创建两个初始子节点
document.body.appendChild(document.createElement('div'));
document.body.appendChild(document.createElement('span'));

observer.observe(document.body, { childList: true });

// 交换子节点顺序
document.body.insertBefore(document.body.lastChild, document.body.firstChild);

// 发生了两次变化:第一次是节点被移除,第二次是节点被添加
// [
// {
// addedNodes: NodeList[],
// attributeName: null,
// attributeNamespace: null,
// oldValue: null,
// nextSibling: null,
// previousSibling: div,
// removedNodes: NodeList[span],
// target: body,
// type: childList,
// },
// {
// addedNodes: NodeList[span],
// attributeName: null,
// attributeNamespace: null,
// oldValue: null,
// nextSibling: div,
// previousSibling: null,
// removedNodes: NodeList[],
// target: body,
// type: "childList",
// }
// ]

4.观察子树

默认情况下,MutationObserver 将观察的范围限定为一个元素及其子节点的变化。可以把观察的范围扩展到这个元素的子树(所有后代节点),这需要在 MutationObserverInit 对象中将 subtree 属性设置为 true

下面的代码展示了观察元素及其后代节点属性的变化:

// 清空主体
document.body.innerHTML = '';
let observer = new MutationObserver(mutationRecords =>
console.log(mutationRecords),
);

// 创建一个后代
document.body.appendChild(document.createElement('div'));

// 观察<body>元素及其子树
observer.observe(document.body, { attributes: true, subtree: true });

// 修改<body>元素的子树
document.body.firstChild.setAttribute('foo', 'bar');

// 记录了子树变化的事件
// [
// {
// addedNodes: NodeList[],
// attributeName: "foo",
// attributeNamespace: null,
// oldValue: null,
// nextSibling: null,
// previousSibling: null,
// removedNodes: NodeList[],
// target: div,
// type: "attributes",
// }
// ]

有意思的是,被观察子树中的节点被移出子树之后仍然能够触发变化事件。这意味着在子树中的节点离开该子树后,即使严格来讲该节点已经脱离了原来的子树,但它仍然会触发变化事件。

// 清空主体
document.body.innerHTML = '';

let observer = new MutationObserver(mutationRecords =>
console.log(mutationRecords),
);

let subtreeRoot = document.createElement('div'),
subtreeLeaf = document.createElement('span');

// 创建包含两层的子树
document.body.appendChild(subtreeRoot);
subtreeRoot.appendChild(subtreeLeaf);

// 观察子树
observer.observe(subtreeRoot, { attributes: true, subtree: true });

// 把节点转移到其他子树
document.body.insertBefore(subtreeLeaf, subtreeRoot);
subtreeLeaf.setAttribute('foo', 'bar');

// 移出的节点仍然触发变化事
// [MutationRecord]

异步回调与记录队列

MutationObserver 接口是出于性能考虑而设计的,其核心是异步回调与记录队列模型。为了在大量变化事件发生时不影响性能,每次变化的信息(由观察者实例决定)会保存在 MutationRecord实例中,然后添加到记录队列。这个队列对每个 MutationObserver 实例都是唯一的,是所有 DOM变化事件的有序列表。

1.记录队列

每次 MutationRecord 被添加到 MutationObserver 的记录队列时,仅当之前没有已排期的微任务回调时(队列中微任务长度为 0),才会将观察者注册的回调(在初始化 MutationObserver 时传入)作为微任务调度到任务队列上。这样可以保证记录队列的内容不会被回调处理两次。

不过在回调的微任务异步执行期间,有可能又会发生更多变化事件。因此被调用的回调会接收到一个 MutationRecord 实例的数组,顺序为它们进入记录队列的顺序。回调要负责处理这个数组的每一个实例,因为函数退出之后这些实现就不存在了。回调执行后,这些 MutationRecord 就用不着了,因此记录队列会被清空,其内容会被丢弃。

2.takeRecords() 方法

调用 MutationObserver 实例的 takeRecords() 方法可以清空记录队列,取出并返回其中的所有 MutationRecord 实例。

let observer = new MutationObserver(mutationRecords =>
console.log(mutationRecords),
);

observer.observe(document.body, { attributes: true });

document.body.className = 'foo';
document.body.className = 'bar';
document.body.className = 'baz';

console.log(observer.takeRecords());

console.log(observer.takeRecords());
// [MutationRecord, MutationRecord, MutationRecord]
// []

这在希望断开与观察目标的联系,但又希望处理由于调用 disconnect() 而被抛弃的记录队列中的 MutationRecord 实例时比较有用。

性能、内存与垃圾回收

DOM Level 2 规范中描述的 MutationEvent 定义了一组会在各种 DOM 变化时触发的事件。由于浏览器事件的实现机制,这个接口出现了严重的性能问题。因此,DOM Level 3 规定废弃了这些事件。MutationObserver 接口就是为替代这些事件而设计的更实用、性能更好的方案

将变化回调委托给微任务来执行可以保证事件同步触发,同时避免随之而来的混乱。为 MutationObserver 而实现的记录队列,可以保证即使变化事件被爆发式地触发,也不会显著地拖慢浏览器。

无论如何,使用 MutationObserver 仍然不是没有代价的。因此理解什么时候避免出现这种情况就很重要了。

1.MutationObserver 的引用

MutationObserver 实例与目标节点之间的引用关系是非对称的。MutationObserver 拥有对要观察的目标节点的弱引用。因为是弱引用,所以不会妨碍垃圾回收程序回收目标节点。

然而,目标节点却拥有对 MutationObserver 的强引用。如果目标节点从 DOM 中被移除,随后被垃圾回收,则关联的 MutationObserver 也会被垃圾回收。

2.MutationRecord 的引用

记录队列中的每个 MutationRecord 实例至少包含对已有 DOM 节点的一个引用。如果变化是 childList 类型,则会包含多个节点的引用。记录队列和回调处理的默认行为是耗尽这个队列,处理每个 MutationRecord,然后让它们超出作用域并被垃圾回收。

有时候可能需要保存某个观察者的完整变化记录。保存这些 MutationRecord 实例,也就会保存它们引用的节点,因而会妨碍这些节点被回收。如果需要尽快地释放内存,建议从每个 MutationRecord 中抽取出最有用的信息,然后保存到一个新对象中,最后抛弃 MutationRecord。