前端性能优化之路-dom编程优化
在前端性能优化上一直有个瓶颈,就是dom,web应用最常见的性能瓶颈就是dom,用脚本进行dom操作的代价是很昂贵的.
具体体现为几点:
- 修改和访问dom元素
- 修改dom元素的样式导致的重绘(repaint)和重排(reflow)
- 通过dom事件处理与用户的交互
DOM(document object model)文档对象模型,用户操作xml和html文档的程序接口,在浏览器中,主要用来和html文档打交道,同样在web应用中获取xml文档也有用到,也可以使用DOM API访问文档中的数据。
浏览器中通常会把DOM和JavaScript独立实现,比如在IE中,JavaScript的实现名为JScript,位于jscript.dll,DOM的实现则存在于另一个库中,名为mshtml.dll(Trident),这个分离的好处在于允许了其他技术和语言可以共享DOM与Trident提供的api。
各个浏览器
浏览器 | DOM渲染 | JavaScript引擎 |
---|---|---|
safari | webkit(webCore) | Nitro(原名SquirrelFish) |
Chrome | webkit->blink | V8(大名鼎鼎) |
Firefox | Gecko | SpiderMonkey(1.0-3.0)/ TraceMonkey(3.5-3.6)/ JaegerMonkey(4.0-) |
Opera | Presto->blink | Linear A(4.0-6.1)/ Linear B(7.0-9.2)/ Futhark(9.5-10.2)/ Carakan(10.5-) |
IE -> Edge | Trident->EdgeHTML | JScript(IE3.0-IE8.0) / Chakra(IE9+之后,查克拉,微软也看火影么…) |
各个引擎的介绍和原理,都兴趣的朋友可以自行研究一下。
为什么慢?
上面的介绍,我们看到了,dom渲染和javacript引擎是相对独立的,那么这两个模块相互访问的时候,都是通过接口访问的。网上有个著名的例子,把DOM和JavaScript(ECMAScript)各自想象为一个岛屿,他们之间用收费桥梁连接,ECMAScript每次访问DOM,都要经过这座桥,并交纳过桥费,访问的次数越多,费用就越高,因此,推荐的做法是尽可能减少过桥的次数,一直待在ECMAScript岛上。
DOM访问与修改
访问dom元素是有代价的(过桥费),修改元素更是昂贵,因为它会导致浏览器重新计算页面的几何变化。
最坏的情况就是在循环中访问或者修改元素,尤其是对html元素集合循环操作。(表示自己刚工作的时候,经常这样)。
function badLoop(){
var start = new Date().getTime();
for(var i=0;i<10000;i++){
document.getElementById('id1').innerHTML += 'a'
}
console.log(new Date().getTime()-start);
}
function normalLoop(){
var start = new Date().getTime();
var content = ''
for(var i=0;i<10000;i++){
content += 'a'
}
document.getElementById('id1').innerHTML = content
console.log(new Date().getTime()-start);
}
大家可以运行一下上述代码,观察一下打印的结果,结果是显而易见的,访问dom的次数越多,代码的运行速度越慢,因此,通用的经验法则是减少访问dom的次数,把运算尽量留在ECMAScript这一端。
节点克隆
使用element.cloneNode()替代document.createElement(),在大多数浏览器中,节点克隆更有效率的。
HTML集合
html集合是包含了dom节点引用的类数组对象,下列api均返回的是html集合。
- document.getElementsByName()
- document.getElementsByClassName()
- document.getElementsByTagName()
下面的属性同样返回html集合:
document.images 页面中所有的img元素
document.links页面中有的a元素
document.forms 所有的表单元素
document.forms[0].elements页面中第一个表单元素的所有字段
上面这些api都是返回html的集合对象,一个类似数组的列表,又不是真正的数组(没有push,slice之类的方法),但是有length,可以通过索引访问列表的元素。
在dom标准中所定义的,html集以一种‘假定实时态’实时存在,这意味着当底层文档对象更新时,它也会自动更新。
事实上,html集合一直与文档保持着连接,每次你需要最新的信息时,都是重复执行查询的过程,哪怕只是获取集合里的元素个数,也是如此,这正是低效之源。
就是每次我们去访问这个集合的属性的时候,它都会去底层文档内存中重新查询实际的内存,
昂贵的集合
比较出名的一个死循环
var alldivs = document.getElementsByTagName('div')
for(var i=0;i<alldivs.length;i++){
documeng.body.appendChild(document.createElement('div'))
}
这段代码的原意是把页面中的div元素数量翻倍,它遍历现有的div,每次创建一个新的然后插入到body中,但事实上这是一个死循环,因为没新增一个div,alldivs.length就会加1,因为它反应是底层文档的实时状态。文档集合反应的是底层文档的实时状态
既然它要保持一个实时的状态,那么它必然就是每次访问它的属性,都要去查询底层文档实际状态。
针对上面的代码,优化的方案,结合我们前面说的,局部变量缓存法,然后既然访问这个伪数组很慢,那么我们将他缓存在局部变量内成为一个新数组,是不是就快了呢。
var alldivs = document.getElementsByTagName('div')
alldivs = Array.prototype.slice.call(alldivs,0)
for(var i=0;i<alldivs.length;i++){
documeng.body.appendChild(document.createElement('div'))
}
当然,如果这里我们只是想根据长度遍历,并不需要访问集合里每个元素的属性的话,
var alldivs = document.getElementsByTagName('div')
var length = alldivs.length
for(var i=0;i<length;i++){
documeng.body.appendChild(document.createElement('div'))
}
只需要缓存一个长度就可以了,毕竟将集合转成数组,也是有消耗的。所以要根据实际情况来看是否需要数组拷贝。
访问集合元素时使用局部变量
这个就是我们在上一节提到过的,每次循环中,如果会多次访问循环的当前元素中的属性,则先缓存该元素。
平常使用需要使用html集合的时候,我们将集合引用,如果有循环,将集合元素引用,如果不想实时获取集合的状态进行循环,可以将集合转为数组,以此来提升性能。
遍历DOM
dom api提供了多种方法来读取文档结构中的特定部分,当你需要从多种方案选择时,最好为特定的操作选用最高效的api。
获取dom元素
获取元素所有直接子节点。element.childNodes
直接获取元素集合childNode.nextSibling
以遍历的方式,通过元素相邻的下一个元素获取。
在老ie中,后者的性能大大高于前者,其他浏览器中,时间差不多,需要现代浏览器,可以不考虑。
元素节点
childNodes,nextSibling,firstChild
,这些api并不区分元素节点和其他元素节点,就是说一些注释节点,文本节点,只是节点间的空格,我们在获取的时候,通常要自己过滤掉。
现代浏览器提供了一些api,只访问元素节点,其过滤的效率要比我们自己写javascript代码要高效的多。
属性名 | 被替代的属性 |
---|---|
children | childNodes |
childElementCount | childNodes.length |
firstElementChild | firstChild |
lastElementChild | lastChild |
nextElementSibling | nextSibling |
previousElementSibling | previousSibling |
上述api的速度,都比被替代的方案快,尤其体现在ie中。
选择器api
对dom中特定元素操作时,开发者通常需要得到比getElementById,getElementByTagName更好的控制。
docuement.querySelectorAll('css选择器')
这个api不会返回html集合,但是他是一个类数组对象
还有一个便利的api,element.querySelector
,获取第一个匹配的节点
重绘与重排
浏览器下载完页面中的所有组件,html,javascript,css,图片,字体等后会解析并生成两个内部数据结构
dom树
表示页面结构
渲染树
表示dom节点如何展示
dom树中的每一个需要显示的节点在渲染树中至少存在一个对应的节点(隐藏的dom元素在dom树中没有对应的节点)。渲染树中的节点被称为帧,或者盒子,符合css模型的定义。
把页面理解成一个具有内边距(padding),外边距(margin),边框(border),位置(position)的盒子,一旦dom和渲染树构建完成,浏览器就开始显示(绘制paint)页面元素。
当dom的变化影响了元素的几何属性,宽和高,比如改变边框之类的,导致行数增加–浏览器需要重新计算元素的几何属性,同样其他元素的几何和位置也会因此收到影响,浏览器会让渲染树中受到影响的部分失效,并重新构造渲染页面,这个过程称为重排,完成重排后,浏览器会重新绘制受到影响的部分到屏幕中显示,这个过程叫做重绘。
并不是所有的dom变化都会影响几何属性,比如背景色,这种情况只会发生一次重绘(所以重绘不一定发生重排,但是重排一定发生重绘),因为元素的布局并为发生改变。无论是重绘和重排,都是非常昂贵的操作,他们会导致web应用程序的ui反应迟钝,所以,应当减少这类过程的发生。
何时发生重排
- 可见元素发生事务操作(增删改)
- 元素的位置变化
- 元素的尺寸变化
- 内容改变
- 页面初始化
- 浏览器窗口尺寸变化
渲染树变化的排队与刷新
由于每次重排都会产生计算消耗,大多数浏览器通过队列化修改并批量执行来优化重排过程,然而,我们会通过一些操作导致强制队列刷新,计划任何立刻执行
- offsetTop offsetLeft,offsetWidth,offsetHeight(包含border和padding)
- scrollTop scrollLeft scrollWidth scrollHeight
- clientTop clientLeft clientWidth clientHeight(包含padding)
以上属性和方法,需要返回最新的布局信息,因此浏览器不得不执行渲染列表中待处理的变化,并出发重排以返回最新的数据
所以在修改样式的过程中,避免使用上述属性,
一个简单的例子说明,下面是一个伪代码,
var bodystyle = document.body.style;
bodystyle.color = 'red'
console.log(document.body.offsetHeight)
bodystyle.color = 'white'
console.log(document.body.offsetHeight)
bodystyle.color = 'green'
console.log(document.body.offsetHeight)
每次改变颜色,都读取了offsetHeight,每次读取这个值,都会导致浏览器要刷新渲染队列并重排。
我们把代码稍微改一下,速度就明显不一样了
var bodystyle = document.body.style;
bodystyle.color = 'red'
bodystyle.color = 'white'
bodystyle.color = 'green'
console.log(document.body.offsetHeight)
console.log(document.body.offsetHeight)
console.log(document.body.offsetHeight)
尽量减少重排和重绘次数
一个简单的例子
//bad
function bad(){
var el = document.getElementById('id')
el.style.padding = '1px'
el.style.borderWidth = '1px'
el.style.marginTop = '1px'
}
function good() {
var el = document.getElementById('id')
el.style.cssText = 'padding:1px;border-width:1px;margin-top:1px;'
}
批量修改dom
当我们需要对dom进行一系列操作时,可以通过一些步骤减少重绘和重排的次数,
- 使元素脱离文档流
- 对其应用多重改变
- 把元素带回文档流
该过程一共只发生两次重排,步骤一和步骤二,但是如果我们不采用这个步骤,那么步骤二的任意一次操作,都可能发生重排。
介绍一下一些常用的使元素脱离文档流的方式:
- 隐藏元素,应用修改,重新显示
- 使用文档片段(document fragment)在当前dom之外构建一个子树,再把它拷贝回文档
- 将原始元素拷贝到一个脱离文档的节点中,修改副本,完成后再替换原始元素。
下面讲解一个例子:
<ul id="mylist">
<li><a href="www.baidu.com">我是一个连接1</a></li>
<li><a href="www.google.com">我是一个连接2</a></li>
</ul>
<!-- 将一组数据加在这个列表中 -->
<script>
var data = [
{
"text":"我是连接3",
"url":"www.taobao.com"
},
{
"text":"我是连接4",
"url":"www.zhifubao.com"
}
]
//我们前面说过,尽量把所有的操作都在js完成,然后一次性插入到dom,来减少重排次数,但是这里是一个列表项,组装完成一组数据后,就需要插入到dom中,所以我们要考虑批量操作的几个优化点
//一个插入列表的通用方法
function appendDataToElement(parentElement,data){
let a,lli;
for(let i=0;i<data.length;i++){
a = document.createElement('a');
a.href = data[i].url;
a.appendChild(document.createTextNode(data[i].text));
li = document.createElement('li');
li.appendChild(a);
parentElement.appendChild(li);
}
}
//浪费性能的操作
var ul = document.getElementById('mylist');
appendDataToElement(ul,data)
//
//方法一,隐藏元素,应用修改,显示元素
//该方法,发生了两次重排
var ul = document.getElementById('mylist');
ul.style.display = 'none';
appendDataToElement(ul,data);
ul.style.display = 'block';
//
//方法二,使用fragment,该方法只发生一次重排,访问一次dom,比较好的方案
var fragment = document.createDocumentFragment();
appendDataToElement(fragment,data);
document.getElementById('mylist').appendChild(fragment);
//
//第三种方案
var old = document.getElementById('mylist');
var clone = old.cloneNode(true);
appendDataToElement(clone,data);
old.parentNode.replaceChild(clone,old);
//
//推荐尽量使用第二种方案,dom访问次数,重排次数比较少。
</script>
缓存布局信息
前面提过,浏览器会通过队列化修改和批量执行的方式最小化重排和重绘次数,但是当我们查询某些布局信息的时候,比如获取偏移量,滚动位置等,浏览器为了返回最新值,会刷新队列并应用所有改变,所以我们应该尽量减少布局信息的获取次数,获取后,就将它缓存在局部变量中,然后再操作局部变量。
让元素脱离动画流
展开折叠的方式来显示和隐藏内容,是一种常见的交互方式,通常包括展开区域的几何动画,并将页面其他部分推向下方。
一般来说,重排只影响渲染树中的一小部分,但也可能影响很大的部分,甚至整个渲染树,浏览器所需要的重排次数越少,应用程序的响应速度越快,因此当页面顶部的一个动画推移页面整个余下部分时,会导致一次代价昂贵的大规模重排,让用户感到页面一顿一顿的,我们应该在编码中避免这样的情况。
- 使用绝对定位让页面上的动画元素脱离文档流
- 让元素动起来,当它扩大时,会临时覆盖部分页面,但这只是页面一个小区域的重绘过程,不会产生重排并重绘大部分内容。
- 当动画结束时恢复定位,从而只会下移一次文档的其他元素。
:hover
现代浏览器大部分都支持:hover这个css伪选择器,然而如果我们大量使用这个东西,会降低响应速度。
例如,有一个5列和1000行的表哥,并使用tr:hover改变背景色来高亮显示当前鼠标所在行,当鼠标在表格上移动时,性能会降低,高亮过程会变慢,cpu使用率会提高到80-90%,所以在数据很大时,避免使用这种效果,比如很大的表格和列表,如果不得不使用,应该想办法避免这种情况,比如采用虚拟列表等技术。
事件委托
当页面中存在大量元素,而且每一个都要一次或者多次绑定事件处理器时,这种情况可能会影响性能,每绑定一个事件处理器都是有代价的,它要么是加重了页面负担(更多的标签或者js代码),要么是增加了运行期的执行时间,需要访问和修改的dom元素越多,应用程序也就越慢,特别是事件绑定通常发生在onload时,此时对每一个富交互应用来说都是一个拥堵的时刻,事件绑定占用了处理时间,而且浏览器需要追踪每个事件处理器,也会占用更多的内存,当这些工作结束时,这些事件处理器中的绝大部分都不再需要,因此很多工作是没有必要的。,
一个简单而优雅的处理方式就是dom的事件委托 。事件逐层冒泡并能被父级元素捕获,使用事件代理,只需给外层元素绑定一个处理器,就可以处理其子元素上触发的所有事件。
根据dom标准,每个事件都要经历三个阶段。
- 捕获
- 到达目标
- 冒泡
小结
访问和操作dom是现代web应用的重要组成部分,但每次穿越连接ECMAScript和DOM两个岛屿之间的桥梁,都会被收取过桥费,为了减少dom编程带来的性能损失,可以参考以下几点:
- 最小化dom的访问次数,尽可能在javascript端处理
- 如果需要多次访问某个dom节点,请使用局部变量存储它的引用
- 小心处理html集合,因为它实际连接着底层文档,把集合的长度缓存到一个变量中,并在迭代中使用它,如果需要经常操作集合,建议把它拷贝到一个数组中。
- 如果可能的话,使用速度更快的api,比如
querySelectorAll,firstElementChild
. - 要留意重绘和重排,批量修改样式时,离线操作dom树,使用缓存,并减少访问布局信息的次数。
- 动画中使用绝对定位,使用拖放代理
- 使用事件委托来减少事件处理器的数量。
文章内容大量参考《高性能javascript》一书
上一篇: 关于ArrayList的add()方法
下一篇: 一次迭代式开发的研究:开始真正的工作