WEB前端开发性能优化总结【持续更新中】

阅读() @2019-03-17 16:05:17

前端的性能瓶颈主要集中在页面渲染、Javascript脚本占用内存等方面。这里总结一下平时遇到的前端性能瓶颈以及解决方法。

一、JavaScript脚本文件的加载顺序

现在很多童鞋都知道JS脚本要放在body底部,先让CSS渲染页面,再加载JS代码,这里补充一下另外两种方式:defer和async。

区别:

<script src="script.js"></script>

没有 defer 或 async,浏览器会立即加载并执行指定的脚本,“立即”指的是在渲染该 script 标签之下的文档元素之前,也就是说不等待后续载入的文档元素,读到就加载并执行。

<script async src="script.js"></script>

有 async,加载和渲染后续文档元素的过程将和 script.js 的加载与执行并行进行(异步)。

<script defer src="myscript.js"></script>

有 defer,加载后续文档元素的过程将和 script.js 的加载并行进行(异步),但是 script.js 的执行要在所有元素解析完成之后,DOMContentLoaded 事件触发之前完成。

然后从实用角度来说呢,首先把所有脚本都丢到 </body> 之前是最佳实践,因为对于旧浏览器来说这是唯一的优化选择,此法可保证非脚本的其他一切元素能够以最快的速度得到加载和解析。

我们来看一张图:

前端性能加载预览图

蓝色线代表网络读取,红色线代表执行时间,这俩都是针对脚本的;绿色线代表 HTML 解析。

根据图中数据总结一下:

1、defer 和 async 在网络读取(下载)这块儿是一样的,都是异步的(相较于 HTML 解析);

2、它俩的差别在于脚本下载完之后何时执行,显然 defer 是最接近我们对于应用脚本加载和执行的要求的;

3、关于 defer,此图未尽之处在于它是按照加载顺序执行脚本的,这一点要善加利用;

4、async 则是一个乱序执行的主,反正对它来说脚本的加载和执行是紧紧挨着的,所以不管你声明的顺序如何,只要它加载完了就会立刻执行。

二、了解网页的生成过程:

要理解网页性能为什么不好,就要了解网页是怎么生成的。

网页的生成过程

网页的生成过程,大致可以分为5步:

1、HTML代码转化成DOM;

2、CSS代码转化成CSSOM(CSS Object Model);

3、结合DOM和CSSOM,生成一棵渲染树(包含每个节点的视觉信息)

4、生成布局(layout),即将所有渲染树的所有节点进行平面合成;

5、将布局绘制(paint)在屏幕上。

这五步里面,第一步到第三步都非常快,耗时的是第四步和第五步。

"生成布局"(flow)和"绘制"(paint)这两步,合称为"渲染"(render)

浏览器渲染图

三、重排和重绘

网页生成的时候,至少会渲染一次。用户访问的过程中,还会不断重新渲染。

以下三种情况,会导致网页重新渲染:

1、修改DOM;

2、修改样式表;

3、用户事件(比如鼠标悬停、页面滚动、输入框键入文字、改变窗口大小等等)。

重新渲染,就需要重新生成布局和重新绘制前者叫做"重排"(reflow),后者叫做"重绘"(repaint)

需要注意的是,"重绘"不一定需要"重排",比如改变某个网页元素的颜色,就只会触发"重绘",不会触发"重排",因为布局没有改变。但是,"重排"必然导致"重绘",比如改变一个网页元素的位置,就会同时触发"重排"和"重绘",因为布局改变了

三、对于性能的影响

重排和重绘会不断触发,这是不可避免的。但是,它们非常耗费资源,是导致网页性能低下的根本原因。

提高网页性能,就是要降低"重排"和"重绘"的频率和成本,尽量少触发重新渲染。

前面提到,DOM变动和样式变动,都会触发重新渲染。但是,浏览器已经很智能了,会尽量把所有的变动集中在一起,排成一个队列,然后一次性执行,尽量避免多次重新渲染。

div.style.color = 'blue';
div.style.marginTop = '30px';

上面代码中,div元素有两个样式变动,但是浏览器只会触发一次重排和重绘。

如果写成以下这种情况,就会触发2次重排和重绘:

div.style.color = 'blue';
var margin = parseInt(div.style.marginTop);
div.style.marginTop = (margin + 10) + 'px';

上面代码对div元素设置背景色以后,第二行要求浏览器给出该元素的位置,所以浏览器不得不立即重排。

一般来说,样式的写操作之后,如果有下面这些属性的读操作,都会引发浏览器立即重新渲染

offsetTop/offsetLeft/offsetWidth/offsetHeight//获取元素宽高等信息
scrollTop/scrollLeft/scrollWidth/scrollHeight//获取浏览器滚动高度等
clientTop/clientLeft/clientWidth/clientHeight//获取浏览器宽高等信息

一般的规则是:

1、样式表越简单,重排和重绘就越快;

2、重排和重绘的DOM元素层级越高,成本就越高;

3、table元素的重排和重绘成本,要高于div元素。

四、提升性能的技巧

有一些技巧,可以降低浏览器重新渲染的频率和成本。

1、DOM 的多个读操作(或多个写操作),应该放在一起。不要两个读操作之间,加入一个写操作。

2、如果某个样式是通过重排得到的,那么最好缓存结果。避免下一次用到的时候,浏览器又要重排。

3、不要一条条地改变样式,而要通过改变class,或者csstext属性,一次性地改变样式,这一点在项目开发中是经常会遇到的。

// 不建议:
var left = 10;
var top = 10;
el.style.left = left + "px";
el.style.top  = top  + "px";

// 建议:
el.className += " theclassname";

// 建议:
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";

所有的“读”操作写在一块儿,所有的“写”操作写在一块儿,如果是通过JS动态的改变某个元素的某几个CSS样式,最好是把这几个style修改写在一块儿。

另一个一次性改变风格的办法是修改CSS的类名称,而不是修改内联风格代码,这种方法适用于那些风格不依赖于运行那个逻辑,不需要计算的情况,改变CSS类名称更清晰,更易于维护:它有助于保持脚本免除显示代码,虽然它可能带来轻微的性能冲击,因为改变类时需要检查级联表。

var el = document.querySelector('#my-div');
el.className = 'active';

或者使用html5最新的增删class类的方法:

el.classList.remove('red');
el.classList.add('blue');

4、尽量使用离线DOM,而不是真实的网面DOM,来改变元素样式。比如,操作Document Fragment对象,完成后再把这个对象加入DOM。再比如,使用 cloneNode() 方法,在克隆的节点上进行操作,然后再用克隆的节点替换原始节点,例如以下代码:

for (var i = 0; i < 1000; i++) {
	var el = document.createElement('p');
	el.innerHTML = i;
	document.body.appendChild(el);
}
//可以替换为:
var frag = document.createDocumentFragment();
for (var i = 0; i < 1000; i++) {
	var el = document.createElement('p');
	el.innerHTML = i;
	frag.appendChild(el);
}
document.body.appendChild(frag);

在文档之外创建并更新一个文档片段,然后将它附加在原始列表上,文档片段是一个轻量级的document对象,它被设计专用于更新、移动节点之类的任务。文档片段一个遍历的语法特性是当你向节点附加一个片段时,实际添加的是文档片段的子节点群,而不是片段自己。以上修改后的代码只引发一次重排,只触发“存在DOM”一次。

推荐尽可能使用这种文档片段,因为它设计最少数量的DOM操作和重排版。

5、先将元素设为display: none(需要1次重排和重绘),然后对这个节点进行100次操作,最后再恢复显示(需要1次重排和重绘)。这样一来,你就用两次重新渲染,取代了可能高达100次的重新渲染。临时从文档上移除某个html元素然后再恢复它。

var oUl = document.querySelector('#my-list');
oUl.style.display = 'none';
// ...一系列操作代码
oUl.style.display = 'block';

6、将原始元素拷贝到一个脱离文档的节点中,修改副本,然后覆盖原始元素。

var wraper = oBody.querySelector('#wraper'),
	cloneWraper = wraper.cloneNode(true);
cloneWraper.style.cssText = 'height: 200px;';
wraper.parentNode.replaceChild(cloneWraper, wraper);

7、position属性为absolute或fixed的元素,重排的开销会比较小,因为不用考虑它对其他元素的影响。

8、只在必要的时候,才将元素的display属性为可见,因为不可见的元素不影响重排和重绘。另外,visibility : hidden的元素只对重绘有影响,不影响重排

9、使用虚拟DOM的脚本库,比如React等。

10、操作DOM事件的时候,尽量使用事件委托:

当页面中存在大量元素,而且每个元素有一个或多个事件句柄与之连接(如onclick)时,可能会影响性能,链接每个句柄都是有代价的,无论其形式是家中了页面负担(更多的页面标记和javascript代码)还是表现在运行期的运行时间上。你需要访问和修改更多的DOM节点,程序就会更慢,特别是因为事件挂接过程中都发生在onload(或DOMContentReady)事件中,对任何一个富交互网页来说都是一个繁忙的时间段。挂接事件占用率处理时间,另外,浏览器需要保存每个句柄的记录,占用更多内存,当这些工作结束时,这些事件句柄中的相当一部分根本不需要(因为并不是100%的按钮或者链接都会被每一个用户点到),所以很多工作都是不必要的。

推荐查看:《JavaScript与jQuery事件委托的写法》。

11、使用 window.requestAnimationFrame()、window.requestIdleCallback() 这两个方法调节重新渲染,它可以将某些代码放到下一次重新渲染时执行,如下代码:

(1)window.requestAnimationFrame():

function doubleHeight(element) {
	var currentHeight = element.clientHeight;
	element.style.height = (currentHeight * 2) + 'px';
}
elements.forEach(doubleHeight);

上面的代码使用循环操作,将每个元素的高度都增加一倍。可是,每次循环都是,读操作后面跟着一个写操作。这会在短时间内触发大量的重新渲染,显然对于网页性能很不利。

我们可以使用window.requestAnimationFrame(),让读操作和写操作分离,把所有的写操作放到下一次重新渲染。

function doubleHeight(element) {
	var currentHeight = element.clientHeight;
	window.requestAnimationFrame(function () {
		element.style.height = (currentHeight * 2) + 'px';
	});
}
elements.forEach(doubleHeight);

页面滚动事件(scroll)的监听函数,就很适合用 window.requestAnimationFrame() ,推迟到下一次重新渲染。

$(window).on('scroll', function() {
   window.requestAnimationFrame(scrollHandler);
});

当然,最适用的场合还是网页动画。下面是一个旋转动画的例子,元素每一帧旋转1度。

var rAF = window.requestAnimationFrame;

var degrees = 0;
function update() {
  div.style.transform = "rotate(" + degrees + "deg)";
  console.log('updated to degrees ' + degrees);
  degrees = degrees + 1;
  rAF(update);
}
rAF(update);

(2)window.requestIdleCallback():

还有一个函数window.requestIdleCallback(),也可以用来调节重新渲染。

它指定只有当一帧的末尾有空闲时间,才会执行回调函数。

requestIdleCallback(fn);

上面代码中,只有当前帧的运行时间小于16.66ms时,函数fn才会执行。否则,就推迟到下一帧,如果下一帧也没有空闲时间,就推迟到下下一帧,以此类推。

它还可以接受第二个参数,表示指定的毫秒数。如果在指定 的这段时间之内,每一帧都没有空闲时间,那么函数fn将会强制执行。

requestIdleCallback(fn, 5000);

上面的代码表示,函数fn最迟会在5000毫秒之后执行。

函数 fn 可以接受一个 deadline 对象作为参数。

requestIdleCallback(function someHeavyComputation(deadline) {
	while(deadline.timeRemaining() > 0) {
		doWorkIfNeeded();
	}

	if(thereIsMoreWorkToDo) {
		requestIdleCallback(someHeavyComputation);
	}
});

上面代码中,回调函数 someHeavyComputation 的参数是一个 deadline 对象。

deadline对象有一个方法和一个属性:timeRemaining() 和 didTimeout。

timeRemaining() 方法:

timeRemaining() 方法返回当前帧还剩余的毫秒。这个方法只能读,不能写,而且会动态更新。因此可以不断检查这个属性,如果还有剩余时间的话,就不断执行某些任务。一旦这个属性等于0,就把任务分配到下一轮requestIdleCallback。

前面的示例代码之中,只要当前帧还有空闲时间,就不断调用doWorkIfNeeded方法。一旦没有空闲时间,但是任务还没有全执行,就分配到下一轮requestIdleCallback。

didTimeout属性:

deadline对象的 didTimeout 属性会返回一个布尔值,表示指定的时间是否过期。这意味着,如果回调函数由于指定时间过期而触发,那么你会得到两个结果。

timeRemaining方法返回0
didTimeout 属性等于 true

因此,如果回调函数执行了,无非是两种原因:当前帧有空闲时间,或者指定时间到了。

function myNonEssentialWork (deadline) {
	while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && tasks.length > 0)
		doWorkIfNeeded();

	if (tasks.length > 0)
		requestIdleCallback(myNonEssentialWork);
}

requestIdleCallback(myNonEssentialWork, 5000);

上面代码确保了,doWorkIfNeeded 函数一定会在将来某个比较空闲的时间(或者在指定时间过期后)得到反复执行。

requestIdleCallback 是一个很新的函数,刚刚引入标准,目前只有Chrome支持,不过其他浏览器可以用垫片库。

五、DOM访问和操作是现代网页应用中很重要的一部分,但为了减少DOM编程中的性能损失,要尽力注意以下几点:

1、最小化DOM访问,在javascript端做尽可能多的事情;

2、在反复访问的地方使用局部变量存放DOM引用;

3、小心处理HTML集合,因为他们表现出“存在性”,总是对底层文档重新查询,将集合的length属性缓存到一个变量中,在迭代中使用这个变量。如果经常操作这个集合,可以将集合拷贝到数组中。

4、如果可能的话,使用速度更快的API,如querySelector、querySelectorAll和firstElementChild等;

5、注意重绘和重排,批量修改风格,离线操作DOM数,缓存并减少对布局信息的访问;

6、使用事件委托技术最小化事件句柄数量。

六、常用JS代码规范提升页面性能

1.关于JS的循环,循环是一种常用的流程控制。JS提供了三种循环:for(;;)、while()、for(in)。在这三种循环中 for(in)的效率最差,因为它需要查询Hash键,因此应尽量少用for(in)循环,for(;;)、while()循环的性能基本持平。当然,推 荐使用for循环,如果循环变量递增或递减,不要单独对循环变量赋值,而应该使用嵌套的++或--运算符。

2.如果需要遍历数组,应该先缓存数组长度,将数组长度放入局部变量中,避免多次查询数组长度。

3.局部变量的访问速度要比全局变量的访问速度更快,因为全局变量其实是window对象的成员,而局部变量是放在函数的栈里的。

4.尽量少使用eval,每次使用eval需要消耗大量时间,这时候使用JS所支持的闭包可以实现函数模板。

5.尽量避免对象的嵌套查询,对于obj1.obj2.obj3.obj4这个语句,需要进行至少3次查询操作,先检查obj1中是否包含 obj2,再检查obj2中是否包含obj3,然后检查obj3中是否包含obj4...这不是一个好策略。应该尽量利用局部变量,将obj4以局部变量 保存,从而避免嵌套查询。

6.使运算符时,尽量使用+=,-=、*=、\=等运算符号,而不是直接进行赋值运算。

7.当需要将数字转换成字符时,采用如下方式:"" + 1。从性能上来看,将数字转换成字符时,有如下公式:("" +) > String() > .toString() > new String()。String()属于内部函数,所以速度很快。而.toString()要查询原型中的函数,所以速度逊色一些,new String()需要重新创建一个字符串对象,速度最慢。

8.当需要将浮点数转换成整型时,应该使用Math.floor()或者Math.round()。而不是使用parseInt(),该方法用于将字符串转换成数字。而且Math是内部对象,所以Math.floor()其实并没有多少查询方法和调用时间,速度是最快的。

9.尽量作用JSON格式来创建对象,而不是var obj=new Object()方法。因为前者是直接复制,而后者需要调用构造器,因而前者的性能更好。

10.当需要使用数组时,也尽量使用JSON格式的语法,即直接使用如下语法定义数组:[parrm,param,param...],而不是采用 new Array(parrm,param,param...)这种语法。因为使用JSON格式的语法是引擎直接解释的。而后者则需要调用Array的构造器。

11.对字符串进行循环操作,例如替换、查找,就使用正则表达式。因为JS的循环速度比较慢,而正则表达式的操作是用C写成的API,性能比较好。

最后有一个基本原则,对于大的JS对象,因为创建时时间和空间的开销都比较大,因此应该尽量考虑采用缓存或者变量使用完成之后,手动设置为null空值。

【持续更新中】!

微信二维码