前言
在之前的文章 如何优化网站性能,提高页面加载速度 中,我们简单介绍了网站性能优化的重要性以及几种网站性能优化的方法(没有看过的可以狂戳 链接 移步过去看一下),那么今天我们深入讨论如何进一步优化网站性能。
一、拆分初始化负载
拆分初始化负载——听名字觉得高大上,其实不然,土一点将讲就是将页面加载时需要的一堆JavaScript文件,分成两部分:渲染页面所必需的(页面出来,没他不行)和剩下的。页面初始化时,只加载必须的,其余的等会加载。
其实在现实生产环境中,对于大部分网站:页面加载完毕(window.onload触发)时,已经执行的JavaScript函数只占到全部加载量的少部分,譬如10%到20%或者更少。
注意:这里所说的页面加载完毕是指window.onload触发。window.onload什么时候出发?当页面中的内容(包括图片、样式、脚本)全部加载到浏览器时,才会触发window.onload,请与jQuery中$(document).ready作区分。
上面我们可以看到大部分JavaScript函数下载之后并未执行,这就造成了浪费。因此,如果我们能够使用某种方式来延迟这部分未使用的代码的加载,那想必可以极大的缩减页面初始化时候的下载量。
拆分文件
我们可以将原来的代码文件拆分成两部分:渲染页面所必需的(页面出来,没他不行)和剩下的;页面加载时只加载必须的,剩余的JavaScript代码在页面加载完成之后采用无阻塞下载技术立即下载。
需要注意的问题:
1. 我们可以通过某些工具(譬如:Firebug)来获得页面加载时执行的函数,从而将这些代码拆分成一个单独的文件。那么问题来了,有些代码在页面加载的时候不会执行,但是确实必须的,譬如条件判断代码或者错误处理的代码。另外JavaScript的作用域问题是相对比较奇葩的,这些都给拆分造成了很大的困难
2. 关于未定义标识符的错误,譬如已加载的JavaScript代码在执行时,引用了一个被我们拆分延迟加载的JavaScript代码中的变量,就会造成错误。举个栗子:
页面加载完成时用户点击了某个按钮(此时原JavaScript文件被拆分,只下载了页面加载所必需的的代码),而监听此按钮的代码还没有被下载(因为这不是页面加载所必需的,所以在拆分时被降级了),所以点击就没有响应或者直接报错(找不到事件处理函数)。
解决方案:
1. 在低优先级的代码被加载完成时,按钮处于不可用状态(可附带提示信息);
2. 使用桩函数,桩函数与原函数名字相同,但是函数体为空,这样就可以防止报错了。当剩余的代码加载完成时,桩函数就被原来的同名函数覆盖掉。我们可以做的再狠一点:记录用户的行为(点击、下拉),当剩余的代码加载完成时,再根据记录调用相应的函数。
二、无阻塞加载脚本
大多数浏览器可以并行下载页面所需要的组件,然而对于脚本文件却并非如此。脚本文件在下载时,在其下载完成、解析执行完毕之前,并不会下载任何其他的内容。这么做是有道理的,因为浏览器并不知道脚本是否会操作页面的内容;其次,后面加载的脚本可能会依赖前面的脚本 ,如果并行下载,后面的脚本可能会先下载完并执行,产生错误。所以,之前我们讲到了脚本应该尽可能放在底部接近</body>的位置,就是为了尽量减少整个页面的影响。
接下来我们讨论几种技术可以使页面不会被脚本的下载阻塞:
1、Script Defer
<script type="text/javascript" src="file1.js" defer></script>
支持浏览器: IE4+ 、Firefox 3.5+以及其它新版本的浏览器
defer表示该脚本不打算修改DOM,可以稍后执行。
2、动态脚本元素
var script = document.createElement ("script"); script.type = "text/javascript"; script.src = "a.js"; document.body.appendChild(script);
用动态创建script标签的方法不会阻塞其它的页面处理过程,在IE下还可以并行下载脚本。
3、XHR(XMLHttpRequest)Eval
该方法通过XMLHttpRequest以非阻塞的方式从服务端加载脚本,加载完成之后通过eval解析执行。
1 var xhr = getXHRObj(); 2 3 xhr.onreadystatechange = function() { 4 if(xhr.readyState == 4 && xhr.status == 200) { 5 eval(xhr.responseText); 6 } 7 }; 8 9 xhr.open(‘GET‘,‘text.js‘,true); 10 xhr.send(‘‘); 11 12 function getXHRObj() { 13 // ...... 14 return xhrObj; 15 }
该方式不会阻塞页面中其它组件的下载。
缺点:(1)脚本的域必须和主页面在相同的域中;(2)eval的安全性问题
4、XHR Injection
XMLHttpRequest Injection(XHR脚本注入)和XHR Eval类似,都是通过 XMLHttpRequest 来获取JavaScript的。 在获得文件之后 ,将会创建一个script标签将得到的代码注入页面。
1 var xhr = new XMLHttpRequest(); 2 xhr.open("GET", "test.js", true); 3 xhr.send(‘‘); 4 xhr.onreadystatechange = function(){ 5 if (xhr.readyState == 4){ 6 if (xhr.status >= 200 && xhr.status < 300 || xhr.status == 304){ 7 var script = document.createElement("script"); 8 script.type = "text/javascript"; 9 script.text = xhr.responseText; 10 document.body.appendChild(script); 11 } 12 } 13 };
XMLHttpRequest获取的内容必须和主页处于相同的域。
5、Script元素的src属性
1 var script = document.createElement(‘script‘); 2 script.src = ‘http://a.com/a.js‘ 3 document.body.appendChild(script);
这种方式不会阻塞其它组件,而且允许跨域获取脚本。
6、IFrame嵌入Script
页面中的iframe和其它元素是并行下载的,因此可以利用这点将需要加载的脚本嵌入iframe中。
<iframe src="1.html" frameborder="0" width=0 height="0"></iframe>
注意:这里是1.html而不是1.js,iframe以为这是html文件,而我们则把要加载的脚本嵌入其中。
这种方式要求iframe的请求url和主页面同域。
三、整合异步脚本
上面我们介绍了如何异步加载脚本,提高页面的加载速度。但是异步加载脚本也是存在问题的,譬如行内脚本依赖外部脚本里面定义的标识,这样当内联的脚本执行的时候外部脚本还没有加载完成,那么就会发生错误。
那么接下来我们就讨论一下如何实现在异步加载脚本的时候又能保证脚本的能够按照正确的顺序执行。
单个外部脚本与内联脚本
譬如:内联脚本使用了外部脚本定义的标识符,外部脚本采用异步加载提高加载速度
$(".button").click(function() { alert("hello"); });
<script src="jquery.js"></script>
1、Script Onload
通过Script的onload方法监听脚本是否加载完成,将依赖外部文件的内联代码写在init函数中,在onload事件函数中调用init函数。
script.onload的支持情况:
IE6、IE7、IE8不支持onload,可以用onreadystatechange来代替。
IE9、IE10先触发onload事件,再触发onreadystatechange事件
IE11(Edge)只触发onload事件
其他浏览器支持均支持onload,在opera中onload和onreadystatechange均有效。
1 function init() { 2 // inline code...... 3 } 4 var script = document.createElement("script"); 5 script.type = "text/javascript"; 6 script.src = "a.js"; 7 script.onloadDone = false; 8 9 script.onreadystatechange = function(){ 10 if((script.readyState == ‘loaded‘ || script.readyState == ‘complete‘) && !script.onloadDone){ 11 // alert("onreadystatechange"); 12 init(); 13 } 14 } 15 16 script.onload = function(){ 17 // alert("onload"); 18 init(); 19 script.onloadDone = true; 20 } 21 22 document.getElementsByTagName(‘head‘)[0].appendChild(script);
这里onloadDone用来防止在IE9、IE10已结opera中初始化函数执行两次。
Script Onload是整合内联脚本和外部异步加载脚本的首选。
推荐指数:5颗星
2、硬编码回调
将依赖外部文件的内联代码写在init函数中,修改异步加载的文件,在文件中添加对init函数的调用。
缺点:要修改外部文件,而我们一般不会修改第三方的插件;缺乏灵活性,改变回调接口时,需要修改外部的脚本。
推荐指数:2颗星
3、定时器
将依赖外部文件的内联代码写在init函数中,采用定时器的方法检查依赖的名字空间是否存在。若已经存在,则调用init函数;若不存在,则等待一段时间在检查。
function init() { // inline code...... } var script = document.createElement("script"); script.type = "text/javascript"; script.src = "jquery.js"; document.getElementsByTagName(‘head‘)[0].appendChild(script); function timer() { if("undefined" === typeof(jQuery)) { setTimeout(timer,500); } else { init(); } } timer();
缺点:
如果setTimeout设置的时间间隔过小,则可能会增加页面的开销;如果时间间隔过大,就会发生外部脚本加载完毕而行内脚本需要间隔一段才能时间执行的状况,从而造成浪费。
如果外部脚本(jquery.js)加载失败,则这个轮询将会一直持续下去。
增加维护成本,因为我们需要通过外部脚本的特定标识符来判断脚本是否加载完毕,如果外部脚本的标识符变了,则行内的代码也需要改变。
推荐指数:2颗星
4、window.onload
我们可以使用window.onload事件来触发行内代码的执行,但是这要求外部的脚本必须在window.onload事件触发之前下载完毕。
在 无阻塞加载脚本提到的技术中,IFrame嵌入Script 、动态脚本元素 、Script Defer 可以满足这点要求。
1 function init() { 2 // inline code...... 3 } 4 if(window.addEventListener) { 5 window.addEventListener("load",init,false); 6 } 7 else if(window.attachEvent) { 8 window.attachEvent("onload",init); 9 }
缺点:这会阻塞window.onload事件,所以并不是一个很好的办法;如果页面中还有很多其他资源(譬如图片、Flash等),那么行内脚本将会延迟执行(就算它依赖的外部脚本一早就加载完了),因为window.onload不会触发。
推荐指数:3颗星
5、降级使用script
来来来,先看看它什么样子:
<script src="jquery.js" type="text/javascript"> $(".button").click(function() { alert("hello"); }); </script>
然并卵,目前还没有浏览器可以实现这种方式,一般情况下,外部脚本(jquery.js)加载成功后,两个标签之间的代码就不会执行了。
但是我们可以改进一下:修改外部脚本的代码,让它在DOM树种搜索自己,用innerHTML获取自己内部的代码,然后用eval执行,就可以解决问题了。
然后我们在修改一下让它异步加载,就变成了这样:
1 function init() { 2 // inline code...... 3 } 4 var script = document.createElement("script"); 5 script.type = "text/javascript"; 6 script.src = "jquery.js"; 7 script.innerHTML = "init()‘" 8 document.getElementsByTagName(‘head‘)[0].appendChild(script);
而在外部脚本中我们需要添加如下代码:
1 var scripts = document.getElementsByTagName("script"); 2 3 for(var i = 0; i < scripts.length;i++) { 4 if(-1 != scripts[i].src.indexOf(‘jquery.js‘)) { 5 eval(script.innerHTML); 6 break; 7 } 8 }
这样就大功告成 。然而,缺点也很明显,我们还是需要修改外部文件的代码。
推荐指数:2颗星
内联脚本、多个外部脚本相互依赖
举个栗子:
内联脚本依赖a.js,a.js依赖b.js;
这种情况比较麻烦(好吧,是因为我太菜),简单介绍一下思路:
确保a.js在b.js之后执行,内联脚本在a.js之后执行。
我们可以使用XMLHttpRequest同时异步获取两个脚本,如果a.js先下载完成,则判断b.js是否下载完成,如果下载完成则执行,否则等待,a.js执行之后就可以调用内联脚本执行了。b.js下载完成之后即可执行。
代码大概这样(求指正):
1 function init() { 2 // inline code...... 3 } 4 5 6 var xhrA = new XMLHttpRequest(); 7 var xhrB = new XMLHttpRequest(); 8 var scriptA , scriptB; 9 10 var scriptA = document.createElement("script"); 11 scriptA.type = "text/javascript"; 12 13 var scriptB = document.createElement("script"); 14 scriptB.type = "text/javascript"; 15 16 scriptA = scriptB = false; 17 18 xhrA.open("GET", "a.js", true); 19 xhrA.send(‘‘); 20 xhrA.onreadystatechange = function(){ 21 if (xhr.readyState == 4){ 22 if (xhr.status >= 200 && xhr.status < 300 || xhr.status == 304){ 23 scriptA.text = xhr.responseText; 24 scriptA = true; 25 if(scriptB) { 26 document.body.appendChild(scriptA); 27 init(); 28 } 29 } 30 } 31 }; 32 33 xhrB.open("GET", "b.js", true); 34 xhrB.send(‘‘); 35 xhrB.onreadystatechange = function(){ 36 if (xhr.readyState == 4){ 37 if (xhr.status >= 200 && xhr.status < 300 || xhr.status == 304){ 38 scriptB.text = xhr.responseText; 39 scriptB = true 40 document.body.appendChild(scriptB); 41 if(scriptA) { 42 document.body.appendChild(scriptA); 43 init(); 44 } 45 } 46 } 47 };
四、编写高效的JavaScript
之前讲过了,大家可以猛戳 这里 看一下。
五、CSS选择器优化
1、在谈论选择器优化之前,我们先简单介绍一下选择器的类型:
ID选择器 : #id;
类选择器: .class
标签选择器: a
兄弟选择器:#id + a
子选择器: #id > a
后代选择器: #id a
通赔选择器: *
属性选择器: input[type=‘input‘]
伪类和伪元素:a:hover , div:after
组合选择器:#id,.class
2、浏览器的匹配规则
#abc > a怎么匹配? 有人可能会以为:先找到id为abc的元素,再查找子元素为a的元素!!too young,too simple!
其实,浏览器时从右向左匹配选择符的!!!那么上面的写法效率就低了:先查找页面中的所有a标签,在看它的父元素是不是id为abc
知道了浏览器的匹配规则我们就能尽可能的避免开销很大的选择器了:
避免通配规则
除了 * 之外,还包括子选择器、后台选择器等。
而它们之间的组合更加逆天,譬如:li *
浏览器会查找页面的所有元素,然后一层一层地寻找他的祖先,看是不是li,这对可能极大地损耗性能。
不限定ID选择器
ID就是唯一的,不要写成类似div#nav这样,没必要。
不限定class选择器
我们可以进一步细化类名,譬如li.nav 写成 nav-item
尽量避免后代选择器
通常后代选择器是开销最高的,如果可以,请使用子选择器代替。
替换子选择器
如果可以,用类选择器代替子选择器,譬如
nav > li 改成 .nav-item
依靠继承
了解那些属性可以依靠继承得来,从而避免重复设定规则。
3、关键选择符
选择器中最右边的选择符成为关键选择符,它对浏览器执行的工作量起主要影响。
举个栗子:
div div li span.class-special
乍一看,各种后代选择器组合,性能肯定不能忍。其实仔细一想,浏览器从右向左匹配,如果页面中span.class-special的元素只有一个的话,那影响并不大啊。
反过来看,如果是这样
span.class-special li div div ,尽管span.class-special很少,但是浏览器从右边匹配,查找页面中所有div在层层向上查找,那性能自然就低了。
4、重绘与回流
优化css选择器不仅仅提高页面加载时候的效率,在页面回流、重绘的时候也可以得到不错的效果,那么接下来我们说一下重绘与回流。
4.1、从浏览器的渲染过程谈起
解析HTML构建dom树→构建render树→布局render树→绘制render树
1)构建dom树
根据获得的html代码生成一个DOM树,每个节点代表一个HTML标签,根节点是document对象。dom树种包含了所有的HTML标签,包括未显示的标签(display:none)和js添加的标签。
2)构建cssom树
将得到所有样式(浏览器和用户定义的css)除去不能识别的(错误的以及css hack),构建成一个cssom树
3)cssom和dom结合生成渲染树,渲染树中不包括隐藏的节点包括(display:none、head标签),而且每个节点都有自己的style属性,渲染树种每一个节点成为一个盒子(box)。注意:透明度为100%的元素以及visibility:hidden的元素也包含在渲染树之中,因为他们会影响布局。
4)浏览器根据渲染树来绘制页面
4.2、重绘(repaint)与回流(reflow)
1)重绘 当渲染树中的一部分或者全部因为页面中某些元素的布局、显示与隐藏、尺寸等改变需要重新构建,这就是回流。每个页面至少会发生一次回流,在页面第一次加载的时候发生。在回流的时候,浏览器会使渲染树中受到影响的部分失效,并重新构造这部分渲染树,完成回流后,浏览器会重新绘制受影响的部分到屏幕中,该过程成为重绘。
2. 当渲染树中的一些元素需要更新属性,而这些属性不会影响布局,只影响元素的外观、风格,比如color、background-color,则称为重绘。
注意:回流必将引起重绘,而重绘不一定会引起回流。
4.3、回流何时发生:
当页面布局和几何属性改变时就需要回流。下述情况会发生浏览器回流:
1、添加或者删除可见的DOM元素;
2、元素位置改变;
3、元素尺寸改变——边距、填充、边框、宽度和高度
4、内容改变——比如文本改变或者图片大小改变而引起的计算值宽度和高度改变;
5、页面渲染初始化;
6、浏览器窗口尺寸改变——resize事件发生时;
4.4、如何影响性能
页面上任何一个结点触发reflow,都会导致它的子结点及祖先结点重新渲染。
每次重绘和回流发生时,浏览器会根据对应的css重新绘制需要渲染的部分,如果你的选择器不优化,就会导致效率降低,所以优化选择器的重要性可见一斑。
六、尽量少用iframe
在写网页的时候,我们可能会用到iframe,iframe的好处是它完全独立于父文档。iframe中包含的JavaScript文件访问其父文档是受限的。例如,来自不同域的iframe不能访问其父文档的Cookie。
开销最高的DOM元素
通常创建iframe元素的开销要比创建其它元素的开销高几十倍甚至几百倍。
iframe阻塞onload事件
通常我们会希望window.onload事件能够尽可能触发,原因如下:
- 我们可能在onload事件处理函数中编写了用于初始化UI的代码;
- onload事件触发时,浏览器停止“忙指示器”,并向用户反馈页面已经准备就绪。
- 部分低版本浏览器(IE6、IE7、IE8、Safari3、Safari4、Chrome1、Chrome2等)只有onload事件触发之后才会触发unload事件。有时,我们会把一些重要的操作和window的unload事件绑定在一起。例如,减少内存泄露的代码。如果onload花费时间太长,用户可能会离开页面,那么在这些浏览器中unload可能就永远不会执行了。
通常情况下,iframe中的内容对页面来说不是很重要的(譬如第三方的广告),我们不应该因为这些内容而延迟window.onload事件的触发。
综上,即使iframe是空的,其开销也会很高,而且他会阻塞onload事件。所以,我们应该尽可能避免iframe的使用。
七、图片优化
在大多数网站中,图片的大小往往能占到一半以上,所以优化图片能带来更好的效果;而且,对图片的优化,还可以实现再不删减网站功能的条件下实现网站性能的提升。
1、图像格式
GIF
透明:允许二进制类型的透明度,要么完全透明,要么不透明。
动画:支持动画。动画由若干帧组成。
无损:GIF是无损的
逐行扫描:生成GIF时,会使用压缩来减小文件大小。压缩时,逐行扫描像素,当图像在水平方向有很多重复颜色时,可以获得更好的压缩效果。
支持隔行扫描
GIF有256色限制,所以不适合显示照片。可以用来显示图形,但是PNG8是用来显示图形的最佳方式。所以,一般在需要动画时才用到GIF。
JPEG
有损
不支持动画和透明
支持隔行扫描
PNG
透明:PNG支持完全的alpha透明
动画:目前无跨浏览器解决方案
无损
逐行扫描:和GIF类似,对水平方向有重复颜色的图像压缩比高。
支持隔行扫描
隔行扫描是什么:
网速很慢时,部分图像支持对那些连续采样的图像进行隔行扫描。隔行扫描可以让用户在完整下载图像之前,可以先看到图像的一个粗略的版本,从而消除页面被延迟加载的感觉。
2、PNG在IE6中的奇怪现象
所有在调色板PNG中的半透明像素在IE6下会显示为完整的透明。
真彩色PNG中的alpha透明像素,会显示为背景色
3、无损图像优化
PNG图像优化
PNG格式图像信息保存在”块“中,对于Web现实来说,大部分块并非必要,我们可以将其删除。
推荐工具:Pngcrush
JPEG图像优化
剥离元数据(注释、其他内部信息等)
这些元数据可以安全删除不会影响图片质量。
推荐工具jpegtran
GIF转换成PNG
前面提到GIF的功能吃了动画之外,完全可以用PNG8来代替,所以我们使用PNG代替GIF
推荐工具ImageMagick
优化GIF动画
因为动画里面有很多帧,并且部分内容在很多帧上都是一样的,所以我们可以将图像里面连续帧中的重复像素移除。
推荐工具:Gifsicle
4、CSS sprite优化
如果网站页面较少,可以将图像放在一个超级CSS sprite中
看看Google就使用了一个:
最佳实践:
- 按照颜色合并:颜色相近的突变组合在一起
- 避免不必要的空白
- 元素水平排列:比竖直排列稍微小点
- 将颜色限制在25种之内(尽量)
- 先优化单独的图像,再优化Sprite
- 通过控制大小和对齐减少反锯齿的数量。
- 避免使用对角线渐变,这种渐变无法被平铺。
- IE6中alpha透明图像单独使用sprite
- 每2-3个像素改变渐变颜色,而不是每个
- 避免对图像缩放
- 如果我们需要一张小的图像,就没必要在下载一张大的图像之后在HTML中将其缩小。
- 譬如我们需要一个100*100的图像,我们可以现在服务器端改变图像的大小,这样可以节省下载的流量。
5、避免对图像缩放
如果我们在页面中用不到大的图像,就没必要下载一个很大的然后用css限制他的大小。
譬如我们需要一个100*100的图像,我们可以现在服务器端改变图像的大小,这样可以节省下载的流量。
八、划分主域
在之前我们谈到为了减少DNS的查找,我们应该减少域的数量。但有的时候增加域的数量反而会提高性能,关键是找到提升性能的关键路径。如果一个域提供了太多的资源而成为关键路径,那么将资源分配到多个域上(我们成为域划分),可以使页面加载更快。
当单个域下载资源成为瓶颈时,可将资源分配到多个域上。通过并行的下载数来提高页面速度。
譬如YouTube序列化域名:i1.ytimg.com、i2.ytimg.com、i3.ytimg.com、i4.ytimg.com
IP地址和主机名
浏览器执行“每个服务端最大连接数”的限制是根据URL上的主机名,而不是解析出来的IP地址。因此,我们可以不必额外部署服务器,而是为新域建立一条CNAME记录。CNAME仅仅是域名的别名,即使域名都指向同一个服务器,浏览器依旧会为每个主机名开放最大连接数。
譬如,我们为www.abc.com建立一个别名abc.com,这两个主机名有相同的IP地址,浏览器会将每个主机名当做一个单独的服务端。
另外,研究表明,域的数量从一个增加到两个性能会得到提高,但超过两个时就可能出现负面影响了。最终数量取决于资源的大小和数量,但分为两个域是很好的经验。