写在前面
总所周知,在前端的日常里面,采用JS操作DOM节点的情况占据了大多数。虽然现在有MVVM主流框架代替我们做了这些事情,但充分理解在DOM操作各种损耗问题,将有助于让我们更加理解和优化问题。
DOM简介
DOM,全称文档对象模型。是一个独立于语言的,用于操作XML和HTML文档的程序接口。在浏览器中,主要用来与HTML文档打交道,同样也用在Web程序上获取XML文档上,并使用DOM API用来访问文档中的数据。
但是浏览器中通常会把DOM和JavaScript独立实现。所以这两个独立的功能只要通过接口彼此连接,就会产生消耗。如果分别把DOM和JavaScript想象成孤岛,那么js每次访问DOM都需要消耗资源。访问DOM的次数越多,费用也就越高。因此,推荐的做法就是尽可能减少访问的次数。
DOM访问和操作的优化方式
避免HTML元素集合的循环操作
举个例子,编写一个函数用来修改元素文本内容。
function innerHTMLLoop(){
for(var count = 0;count < 15000;count++){
// 错误方式
document.getElementById('here').innerHTML += 'a'
}
}
该方法的问题是在每次循环时都引用innerHTML属性值,正确的做法讲需要插入的文本用一个局部变量缓存起来,再一次性插入innerHTML属性。如下:
function innerHTMLLoop2(){
var content = ''
for(var count = 0;i < 15000;count++){
content += 'a'
}
document.getElementById('here').innerHTML = content
}
节点克隆比单纯使用创建createElement稍快
使用DOM方法更新内容的方法除了createElement之外,还可以辅助使用element.cloneNode方法替代掉createElement。
比如,我想要动态拼凑成一个表格,常规的方法肯定是在每次循环里面依次使用createElement创建节点之后,再把自己的子元素append起来。使用cloneNode之后其实大方向上不变,就是先初始化创建好各个节点,然后在循环体里替换掉createElement。
function tableCloneDOM(){
var i , table, thead, tbody, tr, th ,a , ul ,li,
oth = document.createElement('th'),
otr = document.createElement('tr'),
oa = document.createElement('a'),
oli = document.createElement('li') ,
oul = document.createElement('ul');
tbody = document.createElement('tbody');
for(var i = 1;i <= 1000;i++){
tr = otr.cloneNode(false)
td = otd.cloneNode(false)
// 循环其他部分
}
// 生成表格
document.getElementById('here').append(table)
}
查询普通数组的长度比查询HTML集合的长度的时间快
在DOM编程中返回HTML集合的有哪些属性和方法呢,常见的有以下几种
- document.getElementsByName()
- document.getElementsByClassName()
- document.getElementsByTagName()
- document.images
- document.links
- document.forms
- document.forms[0].elements
这些属性和方法返回的是一个类似数组的列表,但并不是真正的数组。不过给提供了一个length属性,方便操作时可用。如果需要依靠该集合遍历时可以先将集合长度缓存下来。
// 1、效率低的方式,每次都计算一遍元素的长度
function loopCollection(){
var el = document.getElementsByTagName('div')
for(var i = 0;i < el.length;i++){
// 代码处理
}
}
// 2、效率稍高的方式
function loopCollectionCache(){
var el = document.getElementsByTagName('div'),
len = el.length
for(var i = 0;i < len;i++){
// 代码处理
}
}
// 以上在遍历较小集合时可以使用第2种
// 3、但是如果集合比较大的时候,建议将集合先拷贝到一个数组,再获取数组长度之后再进行代码处理
function toArray(coll){
for(var i = 0,a = [],len = coll.length;i < len;i++){
a[i] = coll[i]
}
return a
}
var coll = document.getElementsByTagName('div')
var arr = toArray(coll)
function loopCopiedArray(){
for(var count = 0,len = arr.length;count<len;i++){
// 代码处理
}
}
如果在遍历元素较多时,第3种才是合理的选择。一般情况下选择第2种即可
使用元素节点比DOM属性方便
诸如像childNodes,firstChild和nextSibling 并不区分元素节点和其他类型节点,比如注释和文本节点。在某种情况下如果我们只需要访问元素节点(可以理解为HTML标签),那么我们还需要手动去过滤掉非元素节点。这些类型检查和过滤都是不必要的dom操作。
所以,如果只想访问元素节点,那么直接使用元素节点会比操作DOM属性来的省心。
使用children替代childNodes会更快,因为集合项更少。HTML源码中的空白实际上是文本节点,而且它并不包含在children集合中。在所有浏览器中,children都比childNodes要快,尽管不会快太多。
选择器API少用dom链式调用的方式
使用选择器选中元素通常会有两种方式:
- domAPI调用,用的最多的应该是document.getElementById和document.getElementsByClassName的方式。
- css选择器调用,也就是document.querySelectorAll("selector")
从性能角度来讲,建议采用第2种,第一种方式的缺点很明显,从上文看,无论是getElementById还是getElementsByClassName返回的都是HTML集合,这是个比较“昂贵”的成本。尤其我们一般还不是一次就可以选中,还需要用上链式调用。
// e.g
var el = document.getElementsByTagName('div')
.getElementsByClassName('byby')
.getElementById('tag')
那么以上的调用方式,每操作一次成本翻倍。
那么采用css选择器的方式的优点就显而易见了。
// e.g
var el = document.querySelectorAll('div .byby #tag') // 采用css选择一次性过滤,一句代码就能解决
除了避免写出来代码的好处以外,由于querySelectorAll使用css选择器作为参数返回一个NodeList,包含这匹配节点的类数组对象。这个方法不会返回HTML集合,因此返回的节点不会对应实时的文档结构,因此大大避免由于HTML集合引起的性能问题。
最小化重排和重绘的优化方式
重排(reflow):当我们对DOM的修改引发了DOM几何尺寸的变化(比如修改元素的宽、高或隐藏元素)时,浏览器需要重新计算元素的集合属性(其他元素的集合属性和位置也会因此受到影响),然后再将计算的结果绘制出来。这个过程称为重排。
重绘(repaint):当我们对DOM的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的集合属性、直接为该元素绘制新的样式(跳过了重排环节)。这个过程称为重绘。
综上可知,重绘不一定导致重排,重排一定会导致重绘。两者都会一定程度地影响性能,因此从开发角度来看应当尽可能地减少重排和重绘次数。
重排何时发生
- 添加或删除可见的DOM元素
- 元素位置改变
- 元素尺寸改变(包括:外边距、内边距、边框厚度、宽度、高度等属性改变)
- 内容改变,例如:文本改变或图片被另一个不同尺寸的图片替代
- 页面渲染器初始化
- 浏览器窗口尺寸改变
除了1、2(对于增减,移动节点)之外,其他的都是相当“昂贵”的操作。因为其他都改变了DOM的几何属性。
获取“即时”型几何属性应当少用
- offsetTop、offsetLeft、offsetWidth、offsetHeight
- scrollTop、scrollLeft、scrollWidth、scrollHeight
- clientTop、clientLeft、clientWidth、clientHeight
- getComputedStyle()(currentStyle in IE)
由于每次重排都会产生计算消耗,大多数浏览器通过队列花修改并批量执行来优化重排过程。而以上方法强制的都是获取“最新”属性,因此浏览器不得不执行渲染队列中的“待处理变化”强制触发重排返回正确的值。
如何尽可能规避重排和重绘
缓存布局信息
考虑一个例子,将一个元素myElement元素沿对角线移动,每次移动一个像素,从100px * 100px 开始,再到500px * 500px 的位置结束。在timeout循环体可以用以下方法:
// 低效的
myElement.style.left = 1 + myElement.offsetLeft + 'px'
myElement.style.top = 1 + myElement.offsetTop + 'px'
if(myElement.offsetLeft >=500){
stopAnimation()
}
在每次调用时都需要引用offsetLeft,每次都会触发重排,正确的做法是用一个全局变量缓存好offsetLeft的位置。
var current = myElement.offsetLeft
// 循环体内
current++
myElement.style.left = current + 'px'
myElement.style.top = current + 'px'
if(current >=500){
stopAnimation()
}
避免逐条改变样式,使用类名去合并样式
比如有以下这段代码:
const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
优化成一个有 class 加持的样子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
.basic_style {
width: 100px;
height: 200px;
border: 10px solid red;
color: red;
}
</style>
</head>
<body>
<div id="container"></div>
<script>
const container = document.getElementById('container')
container.classList.add('basic_style')
</script>
</body>
</html>
离线操作DOM
当你需要对DOM元素进行一系列操作时,可以通过将DOM脱离文档流的方式来减少重排和重绘的次数
有三种基本方法可以使DOM脱离文档:
- 隐藏元素,应用修改,重新显示
- 使用文档片段(document fragment)在当前dom之外构建一个子树,再把它拷贝回文档。(推荐)
- 将原始元素拷贝到一个脱离文档的节点中,修改副本,完成后再替换原始元素。
一个例子,用一个函数替换列表文本信息如下:
<ul id="myList">
<li><a href="http://phpied.com">Saure</li>
<li><a href="http://javascript.com">Xavier</li>
</ul>
<!-- 数据-->
<script>
var data = [
{
"name":"steph",
"url":"http://nczonline.net"
},
{
"name":"ross",
"url":"http://tech.net"
},
]
// 更新数据函数
function appendDataToElement(appendToElement,data){
var a , li ;
for(var i = 0,max = data.length;i<max;i++){
a = document.createElement('a')
a.href = data[i].url
a.appendChild(document.createTextNode(data[i].name))
li = document.createElement('li')
li.appendChild(a)
appendToElement.appendChild(li)
}
}
</script>
// 插入操作
var ul = document.getElementById('myList')
appendDataToElement(ul,data)
问题是每一次新条目被附加到当前DOM树都会导致重排。
下面是优化方案:
// 1.隐藏元素。方式是改变display属性,临时从文档中移除<ul>元素,然后再恢复它:
var ul = document.getElementById('myList')
ul.style.display = 'none'
appendDataToElement(ul,data)
ul.style.display = 'block'
// 2.创建文档片段,只访问一次实时的DOM,(推荐)
var frag = document.createDocumentFragment();
appendDataToElement(frag,data);
document.getElementById('myList').appendChild(frag)
// 3、为需要修改的节点创建一个备份,对副本进行操作,一旦完成,就用新节点替换旧节点
var old = document.getElementById('myList')
var clone = old.cloneNode(true) // true 代表也把子节点都复制进去
appendDataToElement(clone,data)
old.parentNode.replaceChild(clone,old)
其他方式
1.对经常需要展开/折叠这种类型的动画元素,将其使用绝对定位方式,使其脱离文档流。
2.在IE浏览器上避免在大量元素上使用:hover这个伪类。
3.千万不要使用table布局。因为可能很小的一个小改动会造成整个table的重新布局。
采用事件委托减少事件绑定次数
当页面存在大量元素时,多次绑定事件处理器也是会影响性能的。每绑定一个事件处理器都是有代价的,或者需要更加多的js代码,或者增加了运行期的执行时间。因为事件绑定占用了处理时间,而且浏览器需要跟踪每个事件处理器,这也会占用更多的内存。
这时事件委托就是一种优雅的处理DOM事件的技术。它是基于事件会被逐层冒泡并被父级元素捕获的原理。因此,只需要给外层绑定一个事件处理器,就可以处理其子元素上触发的所有事件。
这个技术最常用的是在列表元素需要被绑定事件的时候。
// 比如一个父元素#menu需要监听子元素
document.getElementById("menu").onClick = function(evt){
// 浏览器target
var target = e.target || e.srcElement
// 一些跨浏览器之间的兼容
// 判断事件源是否为预期
if(target.nodeName !== 'A'){
return ;
}
// 浏览器阻止默认行为并取消冒泡
if(typeof e.preventDefault === 'function'){
e.preventDefault() // 阻止默认行为
e.stopPropgation() // 阻止冒泡
}else{
e.returnValue = false
e.cancelBubble = true
}
}
引用
掘金小册juejin.im- 《高性能JavaScript 第三章》