欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2022-05-14 11:56:37
...

在前端性能优化上一直有个瓶颈,就是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》一书