浏览器 - DOM

理解DOM

HTML和XML本身只是一种特定格式的文档,要想对其进行操作,必须按照他们这种格式特点转换成编程语言能够处理的对象。行业中需要有一套HTML和XML文档转换后的接口规范,来约定接口的实现方,尤其是各浏览器厂商,以便上层开发者使用。DOM(Document Object Model的缩写)就是被业界使用的HTML和XML文档的编程接口。在MDN上对DOM的解释如下:

文档对象模型 (DOM) 是HTML和XML文档的编程接口。它提供了对文档的结构化的表述,并定义了一种方式可以使从程序中对该结构进行访问,从而改变文档的结构,样式和内容。DOM 将文档解析为一个由节点和对象(包含属性和方法的对象)组成的结构集合。简言之,它会将web页面和脚本或程序语言连接起来。

查看更多介绍

浏览器环境中是用JavaScript实现的DOM接口,主要用来解析HTML文档,但DOM并不是ECMAScript规范中的一部分,JavaScript和DOM是相互独立的两部分。浏览器通常也把DOM和JavaScript独立实现。比如,Safari中的DOM和渲染是使用WebKit中的WebCore实现,JavaScript部分是由独立的JavaScriptCore引擎来实现。Google Chrome同样使用WebKit中的WebCore库来渲染页面,但JavaScript引擎是他们自己研发的,名为V8。

其他编程语言也可以实现DOM接口,比如Swift的第三方库SwiftSouppythonBeautifulSoup库,Node.js也有第三库可用,如jsdom

这可以让我们做很多有意思的事,可以多琢磨琢磨,不要把自己的思维局限在浏览器中的JavaScript上~

DOM中的所有元素项都被定义为节点(nodes),这些节点又嵌套成一个树结构,通常称为DOM tree,根节点是document。下面总结一下如何访问、修改DOM中的元素。

操作DOM

查询DOM节点

1. document.getElementById()

返回一个特定ID的元素,由于元素的ID在大部分情况下都是独一无二的,这自然而然地成为了高效查找特定元素的方法。

1
var elem = document.getElementById('para')

2. Element.getElementsByClassName()

返回一个动态的包含指定标签名的元素的HTML集合HTMLCollection。指定的元素的子树会被搜索,不包括元素自己。返回的列表是动态的,这意味着它会随着DOM树的变化自动更新自身,所以使用相同元素和相同参数时,没有必要多次调用Element.getElementsByClassName()。当Elementdocument对象时,会搜索整个文档,包括根元素。参数可以为多个class名,多个时用空格分割。

注意,这个方法返回的并不是一个数组,Array.prototype中的方法不能直接使用,可以将HTMLCollection作为方法的上下文(this)传入。

1
2
3
4
var testElements = document.getElementsByClassName('test')
var testDivs = Array.prototype.filter.call(testElements, function(testElement) {
return testElement.nodeName === 'div'
})

3. Element.getElementsByTagName()

getElementsByClassName一样,也是返回一个动态的包含指定标签名的元素的HTML集合HTMLCollection。参数中的标签名最好小写,兼容XHMTL。如果传入*,代表所有元素。

1
2
var table = document.getElementById('forecast-table')
var cells = table.getElementsByTagName('td')

4. Element.querySelector()

参数是一组合法的css选择器。返回指定的元素的子树中,第一个与选择器组匹配的元素。如果没有找到,返回值为null。如果Elementdocument对象,则搜索整个文档,找到第一个满足条件的元素。

1
2
3
4
var el = document.getElementById('#para')
var testEl = document.body.querySelector('.test')
// 查找div或article元素
var el = document.querySelector('div, article')

5. Element.querySelectorAll()

参数也是一组合法的css选择器。返回指定的元素的子树中,所有与选择器组匹配的元素列表NodeList。返回的NodeList不是动态的,也就是如果DOM树发生变化,它不会跟着改变。

1
2
var el = document.querySelector('#test')
var matches = el.querySelectorAll('div.highlighted > p');

遍历DOM节点

DOM本身是一个树的结构,可以使用根节点、父节点、子节点,兄弟节点等这些属性来遍历DOM元素。

1. 根节点

document对象是DOM中每个节点的根节点。它实际上是window对象的一个属性。htmlheadbody元素直接作为document的属性。

Property Node Node Type
document #document DOCUMENT_NODE
document.documentElement html ELEMENT_NODE
document.head head ELEMENT_NODE
document.body body ELEMENT_NODE

2. 父节点

父节点是当前节点的上一层节点。

Property Gets
parentNode Parent Node
parentElement Parent Element Node

这两个属性大多数时候使用哪一都可以,只有一点微小的区别:

1
2
3
4
const html = document.documentElement

console.log(html.parentNode) // #document
console.log(html.parentElement) // null

当遍历DOM时,parentNode使用的更普通一些。

3. 子节点

子节点是当前节点的下一层节点。

Property Gets
childNodes Child Nodes
firstChild First Child Node
lastChild Last Child Node
children Element Child Nodes
firstElementChild First Child Element Node
lastElementChild Last Child Element Node

DOM中的节点有一个nodeType属性,表示节点的类型,常用的有如下几个:

Node Type Value Example
ELEMENT_NODE 1 <body>元素
TEXT_NODE 3 文本部分
COMMENT_NODE 8 <!-- an HTML comment -->

childNodesfirstChildlastChild查找的是所有类型的节点,而childrenfirstElementChildlastElementChild只查找nodeType = 1的节点。例如:

1
2
3
4
5
<h1>Shark World</h1>

const h1 = document.getElementsByTagName('h1')[0]
h1.firstChild // "Shark World"
h1.firstELementChild // null

4. 兄弟节点

与当前节点处于同一层级的节点。

Property Gets
previousSibling Previous Sibling Node
nextSibling Next Sibling Node
previousElementSibling Previous Sibling Element Node
nextElementSibling Next Sibling Element Node

previousSiblingnextSibling会查找任意类型的节点,previousElementSiblingnextElementSibling只查找nodeType = 1的节点。

改变DOM节点

1. 创建节点

Property/Method Description
createElement() 创建一个元素节点(nodeType = 1
createTextNode() 创建一个文本节点(nodeType = 3
node.textContent 获取或设置一个元素节点的文本内容
node.innerHTML 获取或设置一个元素的HTML内容

2. 插入节点

Method Description
parentNode.appendChild(newNode) 将传入的节点作为最后一个子节点添加到当前节点
parentNode.insertBefore(newNode, referenceNode) 在特定的元素之前插入一个节点到父元素中
parentNode.replaceChild(newChild, oldChild) 用一个新节点替换一个旧节点

3. 删除节点

Method Description
parentNode.removeChild(child); 从父节点中移除传入的节点
node.remove() 移除节点本身

这两个方法足以让我们移除DOM中的任何节点。也可以用node.innerHTML = ''达到移除节点的效果,但最好不要这样用,这样写很不直观。

修改元素的Attributes、Classes、Styles

1. 修改元素的Attributes

Method Description Example
hasAttribute() 返回truefalse element.hasAttribute('href')
getAttribute() 返回传入的属性值或null element.getAttribute('href')
setAttribute() 新增或更新传入的属性 element.setAttribute('href', 'index.html')
removeAttribute() 从元素上移除传入的属性 element.removeAttribute('href')

2. 修改元素的Classes

Property/Method Description Example
className 获取或设置class值 element.className
classList.add() 添加一个或多个class,添加多个时用逗号隔开 element.classList.add('active')
classList.toggle() 开启或关闭一个class element.classList.toggle('active')
classList.contains() 判断一个class是否存在 element.classList.contains('active')
classList.replace() 用一个新的class替换一个之前已存在的class classList.replace()
classList.remove() 移除一个class element.classList.remove('active')

3. 修改元素的Styles

直接使用元素的style属性设置即可,样式名称用驼峰格式写法并且开头字母小写,例如:

1
2
3
4
div.style.borderRadius = '50%'
div.style.display = 'flex'
div.style.justifyContent = 'center'
div.style.alignItems = 'center'

可以使用setAttribute()给一个元素设置style属性:

1
div.setAttribute('style', 'text-align: center')

参考资料

【1】 Understanding the DOM — Document Object Model

减少操作DOM带来的性能损失

把DOM和JavaScript各自想象为一个岛屿,它们之间用收费桥梁连接。ECMAScript每次访问DOM,都要途径这座桥,并交纳“过桥费”。访问DOM的次数越多,费用也就越高。

摘自《高性能JavaScript》第3章

最小化DOM访问次数

1. 克隆已有节点,而不是创建新节点

使用element.cloneNode()代替document.createElement(),在大多数浏览器中都更有效率,但也不是特别明显。

2. 访问集合元素时使用局部变量

上一节总结查询DOM节点的方法时说到,Element.getElementsByClassName()Element.getElementsByTagName()返回的HTMLCollection,是动态的,时刻保持着和DOM节点的同步。增加对该集合的访问次数,就会增加对DOM节点的查询次数,从而影响性能。所以当遍历一个集合时,能用局部变量进行缓存的属性和方法,就用局部变量缓存,能带来较大的速度提升。

1
2
3
4
5
6
7
8
function loopCollection() {
const coll = document.getElementsByTagName('div')
const len = coll.length

for(let count = 0; count < len; count++) {
// ...
}
}

3. 访问元素节点时使用只会返回元素节点的API

上一节总结遍历DOM节点的属性和方法时说到,像firstChild这一类的API返回的是所有类型的节点,firstElementChild这一类的API只会返回元素节点(nodeType = 1)。如果我们的目的是只想访问元素节点(HTML标签),最好使用firstElementChild这一类的API,具体有哪些可以再回到上一节看一下。这样得到的集合项更少,所以速度也会更快。

4. 访问特定元素时使用选择器API

选择器API主要是:querySelector()querySelectorAll()。在需要组合查询时,选择器API只要传入正确的css选择器,就能一次返回特定元素。而使用getElementById()getElementByTagName()等等API,需要查询多次,并且代码冗长。而且querySelectorAll()返回的是静态的节点列表,省略了将动态集合再转换成数组的步骤。所以选择器API能带来较好的速度提升。

减少重绘和重排的次数

当DOM的变化影响了元素的几何属性(宽和高)——比如改变边框或给段落增加文字,导致行数增加——浏览器需要重新计算元素的几何属性,同样其他元素的几何属性和位置也会因此受到影响。浏览器会使渲染树中受到影响的部分失效,并重新构造渲染树。这个过程称为“重排”。完成重排后,浏览器会重新绘制受影响的部分到屏幕中,该过程称为“重绘”。

摘自《高性能JavaScript》第3章

也就是页面需要重新布局时就会触发“重排”,主要有下列情况会发生重排。

  • 添加或删除可见的DOM元素.
  • 改变元素位置。
  • 元素尺寸改变(包括:外边距、内边距、边框宽度、宽度、高度等属性改变)。
  • 内容改变,例如:文本改变或图片被另一个不同尺寸的图片替代。
  • 页面渲染初始化。
  • 浏览器窗口尺寸改变。

每一次重排都会产生计算消耗,大多数浏览器通过队列化修改并批量执行来优化重排过程。但是如果访问下面这些属性,会强制浏览器立即执行重排,返回布局信息。

  • offsetTop, offsetLeft, offsetWidth, offsetHeight
  • scrollTop, scrollLeft, scrollWidth, scrollHeight
  • clientTop, clientLeft, clientWidth, clientHeight
  • getComputedStyle()(currentStyle in IE)

下面总结一下减少重排和重绘次数的方法。

1. 改变样式时,使用el.style.cssText一次性设置所有样式,这样只会修改DOM一次。

1
2
const el = document.getElementById('myDiv')
el.style.cssText = 'border-left: 1px; border-right: 2px; padding: 5px;'

2. 批量修改DOM

批量修改DOM说的是,先让元素脱离文档流,然后再对元素进行改变,所有的改变都设置完了后,再把元素放回到文档中。脱离文档和再回到文档中会分别触发一次重排,中间无论有多少修改都只会触发一次重排。

使元素脱离文档流有三种基本方法:

  • 隐藏元素,应用修改,重新显示。
1
2
3
4
const ul = document.getElementById('myList')
ul.style.display = 'none'
appendDataToElement(ul, data)
ul.style.display = 'block'
  • 使用文档片段(document fragment)在当前DOM之外构建一个子树,再把它拷贝回文档。

这种效率是最高的,可能平时都被忽略了。

1
2
3
const fragment = document.createDocumentFragment()
appendDataToElement(fragment, data)
document.getElementById('myList').appendChild(fragment)
  • 将原始元素拷贝到一个脱离文档的节点中,修改副本,完成后再替换原始元素。
1
2
3
4
const old = document.getElementById('myList')
const clone = old.cloneNode(true)
appendDataToElement(clone, data)
old.parentNode.replaceChild(clone, old)

3. 缓存布局信息

比如要移动一个元素,下面的代码可以进行优化:

1
myElement.style.left = 1 + myElement.style.left + 'px'

myElement.style.left用赋值给一个变量,在移动时使用该变量做计算:

1
2
3
4
var current = myElement.style.left

current++
myElement.style.left = current + 'px'

4. 让元素脱离动画流

  1. 使用绝对位置定位页面上的动画元素,将其脱离文档流。

  2. 让元素动起来。当它扩大时,会临时覆盖部分页面。但这只是页面一个小区域的重绘过程,不会产生重排并重绘页面的大部分内容。

  3. 当动画结束时恢复定位,从而只会下移一次文档中的其他元素。

5. 避免在在大量元素上使用:hover

使用事件委托

如果想要在大量子元素中单击任何一个元素都执行一段代码,可以将事件监听器设置在父节点上,发生在子节点上的事件会冒泡到父节点上,这样就不需要给每个子节点都单独设置监听器。

1
2
3
4
5
6
<ul id="menu">
<li><a href="menu1.html"></a></li>
<li><a href="menu2.html"></a></li>
<li><a href="menu3.html"></a></li>
<li><a href="menu4.html"></a></li>
</ul>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 处理点击a标签的事件
document.getElementById('menu').onclick = function(e) {
e = e || window.event
var target = e.target || e.srcElement

// 从target中拿到有用信息,进行相关处理

// 取消默认行为,并阻止再向#menu的父节点传递
if (typeof e.preventDefault === 'function') {
e.preventDefault()
e.stopPropagation()
} else {
e.returnValue = false
e.cancelBubble = true
}
}

参考资料

【1】 《高性能JavaScript》第3章

本文作者:意林
本文链接:http://shinancao.cn/2018/12/05/Browser-DOM/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!