文章目录
浏览器中的DOM
DOM,即文档对象模型,是一个独立于语言的、用于操作XML和HTML文档的程序接口。
尽管DOM是一个与语言无关的接口,但它在浏览器中的接口是用JavaScript实现的。
浏览器中通常会把DOM和JavaScript独立实现,例如在Chrome浏览器中,网页的渲染是由WebKit来实现而JavaScript的执行则是由Google自行开发的V8引擎来完成。
可以看出DOM的操作和JavaScript的执行通过接口连接。可以将DOM和JavaScript想象成两个岛屿,这两个岛之间通过一座需要收费的桥梁连接。那么每次通过JavaScript来访问DOM都需要途径这座桥,那么次数越多则费用越高。所以,推荐的做法就是尽可能的少去DOM岛,尽可能留在JavaScript岛上。
DOM的访问与修改
访问DOM的代价是高昂的,可能比你我想象的还要高昂,为了对这种代价有个量化的了解,来看下面这两个例子:
function innerHTMLLoop(){
for(let i=0; i<15000; i++){
document.getElementById('div').innerHTML += 'a';
}
}
function innerHTMLLoop2(){
let tmp = '';
for(let i=0; i<15000; i++){
tmp += 'a';
}
document.getElementById('div').innerHTML += tmp;
}
这两个函数,实现的都是同样的功能,即往页面中id
为div
的元素的innerHTML
属性后添加15000个字符"a"。
看似都很简单的两个函数,它们的实际性能可谓是云泥之别。
使用console.time
来对两个函数的运行时间计时。可以发现两者的性能差距竟有数百倍之多。
原因在于,innerHTMLLoop2
中通过tmp
这个局部变量将要追加的内容先保存下来,然后再一次性将值追加到innerHTML
属性中。也就是说,只访问了一次DOM。
而innerHTMLLoop
则是每次循环都访问了DOM,访问了15000次,于是就产生了巨大的性能开销。
结果显而易见,访问DOM的次数越多,代码的运行速度越慢。
innerHTML与DOM方法
修改页面除开使用createElement
、appendChild
之类的DOM方法,还可以直接修改innerHTML
属性。
然而这两种方法在性能上有差异吗?
在都做了优化的情况下(即尽量少的访问DOM和尽量少的修改innerHTML),两者的差别很小,修改innerHTML
的方法略微占优。但是在基于WebKit内核浏览器中,却恰恰相反,使用DOM方法会略胜一筹。
因此,选择哪种方法取决于用户经常使用的浏览器。
HTML集合
所谓HTML集合就是一个包含了DOM节点引用的类数组对象。
以下方法的返回值就是一个集合:
document.getElementsByClassName
document.getElementsByTagName
document.getElementByName
还有下面的属性也同样返回一个集合:
document.images
document.links
document.forms
以上的方法或属性返回的都是一个HTML集合对象(HTMLCollection)。这是个类似数组的列表,区别在于它没有push
和pop
方法,但它提供了length
属性,并且能以数字为索引访问集合中元素。
DOM标准中规定,HTML集合以一种“假定实时态”(asumed to be lived)实时存在。换句话说,当底层文档对象更新时,集合也会自动更新。
每次需要访问集合中的信息,都会重复查询的过程,哪怕是获取集合的长度。
下列代码可以很好的体现集合的实时性:
let collections = document.getElementsByTagName('div');
for(let i=0; i<collection.length; i++){
document.body.appendChild(document.createElemen('div'));
}
这段代码会陷入死循环,原因在于HTML集合是动态的,也就是说循环体中每次添加一个div
元素,在下一次循环开始时集合的长度都会加1。
访问集合元素时使用局部变量
需要多次访问同一个DOM属性或方法需要多次访问时,最好使用一个局部变量缓存此成员。
当遍历一个集合时,第一优化原则是把集合存储在局部变量中,并把length
存储在循环外部。
// 最慢
function collectionGlobal(){
let coll = document.getElementsByTagName('div');
let lenth = coll.length;
let name = ' ;
for(let i=0;i<length;i++){
name = document.getElementsByTagName('div')[i].nodeName;
name = document.getElementsByTagName('div')[i].nodeType;
name = document.getElementsByTagName('div')[i].tagName;
}
return name;
}
//最快
function collectionNodeLocal(){
let coll = document.getElementsByTagName('div');
let lenth = coll.length;
let name = ' ;
let el = null;
for(let i=0; i<length; i++){
el = coll[i];
name = el.nodeName;
name = el.nodeType;
name = el.tagName;
}
}
重绘与回流
浏览器下载完所有的资源之后–HTML、JavaScript、CSS等,会解析并生成两个内部数据结构:DOM树、渲染树。
DOM树用以表示页面结构,渲染树表示DOM节点该如何显示。
一旦DOM和渲染树构建完成,浏览器就开始绘制页面元素。
当DOM的变化影响了元素的集合属性(如宽和高),比如说改变边框宽度或给增加文字导致函数增加,那么浏览器都需要重新计算元素的集合属性,并重新构建渲染树。这个过程就称为回流。
完成回流之后,浏览器会重新绘制受影响的部分,这个过程称为“重绘”。
并不是所有的DOM变化都会引起回流,改变元素的背景色仅会引起重绘而已。
重绘与回流都是代价高昂的操作,所以应当尽可能的减少这类过程的发生。
回流何时发生
- 添加或删除可见的DOM元素
- 元素位置改变
- 元素尺寸改变(
padding
、margin
、border
等等) - 内容改变
- 页面渲染器初始化
- 浏览器窗口改变
根据改变的范围和程度,渲染树中或大或小的对应的部分也需要重新计算。有些改变会触发整个页面的回流,例如滚动条出现时。
渲染树的排队和刷新
因为回流十分消耗性能,大多数浏览器通过队列化和批量执行来优化回流过程。即当一个操作会触发回流时,浏览器并不立即执行而是放进一个队列,将一系列的回流整合到一次执行。
然而,有一些操作会要求浏览器强制刷新队列并要求队列中的任务立即执行。
例如:
offsetTop
、offsetLeft
、offsetHeight
、offsetWidth
scrollTop
、scrollLeft
、scrollHeight
、scrollWidth
clientTop
、clientLeft
、clientHeight
、clientWidth
getComputedStyle()
以上属性和方法需要返回最新的布局信息,因此浏览器不得不执行渲染队列中的“待处理变化”,并触发回流以返回正确的值。
最小化重绘和回流
知道了了重绘和回流代价高昂,接着就是如何减少它们的发生了。为了减少发生次数,应该合并多次对DOM和样式的修改,然后一次处理掉。
例如:
let el = document.getElementById('div');
el.style.borderLeft = '1px';
el.style.padding = '5px';
el.style.marginLeft = '10px';
上面代码中所修改的3个CSS属性每一个都会影响元素的集合结构,最糟糕的情况下可能会因此浏览器回流3次。
一个能够达到同样效果且效率更高的方式是合并所有的改变然后一次处理,可以使用cssText
实现:
let el = document.getElementById('div');
el.style.cssText += 'border-left: 1px; padding: 5px; margin-left: 10px';
批量修改DOM
当需要对DOM元素进行一系列操作是,可以通过以下步骤减少重绘和回流的次数:
- 使元素脱离文档流
- 对其应用多重改变
- 把元素带回文档中
有3中方法可以使DOM脱离文档:
- 隐藏元素
- 使用文档片段(Document fragment),在当前DOM之外构建一个子树,再把它考本会文档
- 将原始元素拷贝到一个脱离文档的节点中,修改副本,完成后再替换原始元素