前端性能优化之路-dom编程优化

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/s8460049/article/details/82844709

在前端性能优化上一直有个瓶颈,就是dom,web应用最常见的性能瓶颈就是dom,用脚本进行dom操作的代价是很昂贵的.

具体体现为几点:

  1. 修改和访问dom元素
  2. 修改dom元素的样式导致的重绘(repaint)和重排(reflow)
  3. 通过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进行一系列操作时,可以通过一些步骤减少重绘和重排的次数,

  1. 使元素脱离文档流
  2. 对其应用多重改变
  3. 把元素带回文档流

该过程一共只发生两次重排,步骤一和步骤二,但是如果我们不采用这个步骤,那么步骤二的任意一次操作,都可能发生重排。

介绍一下一些常用的使元素脱离文档流的方式:

  1. 隐藏元素,应用修改,重新显示
  2. 使用文档片段(document fragment)在当前dom之外构建一个子树,再把它拷贝回文档
  3. 将原始元素拷贝到一个脱离文档的节点中,修改副本,完成后再替换原始元素。
    下面讲解一个例子:
<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>

缓存布局信息

前面提过,浏览器会通过队列化修改和批量执行的方式最小化重排和重绘次数,但是当我们查询某些布局信息的时候,比如获取偏移量,滚动位置等,浏览器为了返回最新值,会刷新队列并应用所有改变,所以我们应该尽量减少布局信息的获取次数,获取后,就将它缓存在局部变量中,然后再操作局部变量。

让元素脱离动画流

展开折叠的方式来显示和隐藏内容,是一种常见的交互方式,通常包括展开区域的几何动画,并将页面其他部分推向下方。

一般来说,重排只影响渲染树中的一小部分,但也可能影响很大的部分,甚至整个渲染树,浏览器所需要的重排次数越少,应用程序的响应速度越快,因此当页面顶部的一个动画推移页面整个余下部分时,会导致一次代价昂贵的大规模重排,让用户感到页面一顿一顿的,我们应该在编码中避免这样的情况。

  1. 使用绝对定位让页面上的动画元素脱离文档流
  2. 让元素动起来,当它扩大时,会临时覆盖部分页面,但这只是页面一个小区域的重绘过程,不会产生重排并重绘大部分内容。
  3. 当动画结束时恢复定位,从而只会下移一次文档的其他元素。

:hover

现代浏览器大部分都支持:hover这个css伪选择器,然而如果我们大量使用这个东西,会降低响应速度。

例如,有一个5列和1000行的表哥,并使用tr:hover改变背景色来高亮显示当前鼠标所在行,当鼠标在表格上移动时,性能会降低,高亮过程会变慢,cpu使用率会提高到80-90%,所以在数据很大时,避免使用这种效果,比如很大的表格和列表,如果不得不使用,应该想办法避免这种情况,比如采用虚拟列表等技术。

事件委托

当页面中存在大量元素,而且每一个都要一次或者多次绑定事件处理器时,这种情况可能会影响性能,每绑定一个事件处理器都是有代价的,它要么是加重了页面负担(更多的标签或者js代码),要么是增加了运行期的执行时间,需要访问和修改的dom元素越多,应用程序也就越慢,特别是事件绑定通常发生在onload时,此时对每一个富交互应用来说都是一个拥堵的时刻,事件绑定占用了处理时间,而且浏览器需要追踪每个事件处理器,也会占用更多的内存,当这些工作结束时,这些事件处理器中的绝大部分都不再需要,因此很多工作是没有必要的。,

一个简单而优雅的处理方式就是dom的事件委托 。事件逐层冒泡并能被父级元素捕获,使用事件代理,只需给外层元素绑定一个处理器,就可以处理其子元素上触发的所有事件。

根据dom标准,每个事件都要经历三个阶段。

  • 捕获
  • 到达目标
  • 冒泡

小结

访问和操作dom是现代web应用的重要组成部分,但每次穿越连接ECMAScript和DOM两个岛屿之间的桥梁,都会被收取过桥费,为了减少dom编程带来的性能损失,可以参考以下几点:

  1. 最小化dom的访问次数,尽可能在javascript端处理
  2. 如果需要多次访问某个dom节点,请使用局部变量存储它的引用
  3. 小心处理html集合,因为它实际连接着底层文档,把集合的长度缓存到一个变量中,并在迭代中使用它,如果需要经常操作集合,建议把它拷贝到一个数组中。
  4. 如果可能的话,使用速度更快的api,比如querySelectorAll,firstElementChild.
  5. 要留意重绘和重排,批量修改样式时,离线操作dom树,使用缓存,并减少访问布局信息的次数。
  6. 动画中使用绝对定位,使用拖放代理
  7. 使用事件委托来减少事件处理器的数量。

文章内容大量参考《高性能javascript》一书

猜你喜欢

转载自blog.csdn.net/s8460049/article/details/82844709
今日推荐