探真无阻塞加载javascript脚本技术,我们会发现很多意想不到的秘密
下面的图片是我使用firefox和chrome浏览百度首页时候记录的http请求
下面是firefox:
下面是chrome:
在浏览百度首页前我都将浏览器的缓存全部清理掉,让这个场景最接近第一次访问百度首页的情景。
在firefox的请求瀑布图里有个表现非常之明显:就是javascript文件下载完毕后,有一段时间是没有网络请求被处理的,这段时间过后http请求才会接着执行,这段空闲时间就是所谓的http请求被阻塞。
浏览器里的http请求被阻塞一般都是由javascript所引起,具体原因是javascript下载完毕之后会立即执行,而javascript执行时候会阻塞浏览器的其他行为,例如阻塞其他javascript的执行以及其他的http请求的执行。这样会导致页面加载变慢,如果这个变慢很明显,此时用户操作网页会发现页面没有反应会反应很慢,慢是网站用户体验的梦魇。
我目前开发的一些系统,在开发环境里经常碰到javascript阻塞页面加载的问题,主要原因是我们网站很多静态资源和脚本都被独立抽取在了一台单独的静态资源服务器上,而本地的开发环境模拟的静态资源服务环境常常很不稳定(经常宕机),有时一些新建的脚本没有及时更新到开发环境上,因此某些js脚本文件无法正确获取,这些问题导致页面加载时候这些js脚本就会阻塞页面的加载,此时浏览器会反复尝试请求这些js文件,直到请求超时才会认定该脚本的url无效,如果中途你无法忍受这种等待,强制关闭浏览器的请求,会发现在浏览器控制台里很多脚本都无法找到,这样你就无法在控制台里设置js代码断点调试js,如果等待js加载完毕,时间又会被浪费,无奈之下只要找到那些无效的url将其注释掉,哎,结果好几次都将有注释的错误代码提交到了svn服务器上,这些事情真是很恼人。
不管那种浏览器,也不管是新版本还是老版本的浏览器,它们都秉持浏览器的单线程特性,这个特性似乎是一个很难撼动的准则,当我们在浏览器的地址栏里输入一个url地址,访问一个新页面时候,页面展示的快慢就是由一个单线程所控制,这个线程叫做UI线程,UI线程会根据页面里资源(资源是指css文件,图片等等)书写的先后顺序来加载资源,加载资源也就是使用http请求获取资源,像css外部文件,html文件以及图片等资源http请求处理完毕也就意味着资源加载结束,但是像外部的javascript文件则不同,它的加载过程被分为两步,第一步和加载css文件和图片一样,就是执行一个http请求下载外部的js文件,但是javascript完成http操作后并不意味操作完毕,UI线程接着会执行它,如果你开发的页面里js代码执行时间过长,那么用户就会明显感觉到页面的延迟。为什么浏览器不能把javascript代码的加载过程拆分为下载和执行两个并行的过程,这样就可以充分利用时间完成http请求,这样不是就能提升页面的加载效率了吗?这个问题的答案当然是否定的,javascript是一个编程语言,js代码是有智力的,它除了可以完成逻辑性的工作,还可以通过操作页面元素来改变页面UI效果,如果我们忽略javascript对UI的影响,让它延迟执行,结果会造成页面展示的混乱。那么他会产生什么样的混乱呢?这个混乱的描述如下:
最简单最好理解和最好掌握的思路是线性思路,对于浏览器UI显示要按线性思路理解即放在页面前部的资源会优先加载和执行,浏览器还会认为前一步的内容都可能会是后一步页面展示前提,如果浏览器擅自停止中间某个代码的执行,很有可能页面最终呈现的效果和设计者设计的不同,这样我们就无法开发出正确的页面。而且按线性思路当你碰到页面UI效果出问题时候,你很容易定位问题所在,如果我们将js代码的加载和执行分隔开来,这就好比把线性思路变成了树状结构,那么你掌握页面加载的思路和解决UI加载问题时候就会变得更加困难,到时很多人都会抓狂和思路混乱,所以我在这里说混乱。
综上所述,js脚本下载和执行是一个完整的操作,是绝对不能被割裂的。
浏览器为了提升用户体验,加快UI线程的执行是一个无法回避的问题,看来拆分js的下载和执行是不可行的,如是乎浏览器换了种方式,这个方式也就是在同一个时间能下载多个资源,我们再看上图,在同一个域名下,firefox可以同时下载两种图片,chrome可以同时下载4个静态资源,不过这是针对图片和css文件,对于js文件似乎还是一个接着一个的下载,下载一个执行一个,不过在ie8以上版本,js可以和图片一样并行加载,ie这样做就提升了js文件下载的效率,不过到了js执行时候还是要严格按照顺序执行。
多个http连接并行下载资源就好比多个线程共同完成某个任务,如果并行http连接更多,那么能有更多http资源同时被下载,但是浏览器提供并行执行的http连接实在太少了,例如上面firefox才两个,chrome也只有4个,那如何突破浏览器的连接个数的限制了?方法很简单就是将常用的,稳定的静态资源统一放在一个静态资源服务器上,由统一的域名对外提供,这个域名要和主体请求的域名不一样,原理是因为浏览器只通过域名来限制连接的个数,如果一个页面里有两个不同的域的,那么并行的http请求个数也会变成两倍。这个做法有点讨浏览器的巧,是程序员发现浏览器的特点而总结出的技术,它类似一个hack技术,而hack技术不会是标准技术,所以它肯定有它的瓶颈区,所以这样的技术都是会有个度的,浏览器限制请求个数绝对不是无缘无故的,我们看百度页面并行下载图片的http协议的版本都是1.1,http1.1特点就是长连接,长连接的好处是在页面和服务端频繁交互时候效率很好,但是浏览器的页面操作并不是总是频繁的请求服务器,而为了加载静态资源而创建很多长连接,服务器会不得不维护大量无用的长连接,给服务器的压力是可想而知的。相比之下http1.0协议,它不使用长连接,而是短连接,因此早期版本的浏览器会给http1.0协议开启的连接数要高于http1.1连接数,因此有些网站将静态资源服务器提供的http协议版本降低到1.0,这样可以有效的提升浏览器的并发连接数,这个做法会给网页性能带来意想不到的提升,不过现代的浏览器似乎更愿意平等对待两个不同版本的协议了,新版的浏览器有些将两类协议的并发数变成一样了。而对于处于客户端地位的浏览器维护多个链接对于资源消耗是庞大的,而且域名过多也会增加dns解析的开销,所以并发连接开启越多,并不一定真的会达到提升性能的目的,那么多少个域最合适了?雅虎的前端工程师给了一个答案:2个是最佳的,这个数据怎么得来的我就不太清楚了,不过结果很简单很好用,记住就行了。
下面我就要聊聊如何解决js阻塞页面加载的问题了,js之所以会阻塞UI线程的执行,是因为js能控制UI的展示,而页面加载的规则是要顺序执行,所以在碰到js代码时候UI线程会首先执行它,而这点很多程序员不知道或者知道但被忽视,因此导致编写代码时候将用于展示的代码和用于处理逻辑的代码混淆在一起,这样做的后果是使js代码造成的阻塞更加严重,所以雅虎公司的前端团队提出了一个前端优化的规则:将js脚本放置到html文档的末尾,这样就能有效的避免UI的阻塞。但是这个方法太简单了,不利于我们对网站进行更加深入的优化。为了做的深入,我们要需要更进一步分析,首先我们知道js脚本按出现的位置分为两类一类是行内脚本即写在页面里的脚本,一类是js的外部文件,行内脚本的优化比较简单,就是尽量在页面写更少的代码,就算要写代码也主要是控制页面加载的UI显示的代码,没必要的代码就放在外部的js文件里,js外部文件优化就比较复杂,为了精简行内脚本,我们就不得不将大量的js代码放到外部文件里,早先时候我都会尽量将所有外部js文件合并成一个js文件,但是现在我发现一个复杂的外部脚本很有可能会让页面的阻塞情况变得更加的严重,因此外部脚本要根据其功能拆分为展示脚本和逻辑脚本,但是事实上展示代码和逻辑代码很难分离,其实有个更加简单的标准让我们拆分代码:将所有外部代码分为UI初始化代码和其他代码,,UI初始化代码是在页面加载时候执行的代码,我们现在只要判断哪些代码在页面加载时候执行就行了,这个标准就容易多了。
另外,上文我提到过我碰到页面被js阻塞的情况,有时我为了调试js代码会一直等待浏览器判断无效的js加载失败,那么我是怎么判断浏览器已经判断外部js加载无效了?很简单就是查看浏览器忙指示结束,浏览器的忙指示如下图所示:
忙指示(忙指示现象包括:浏览器选项卡的旋转圆圈,鼠标变成漏斗鼠标,浏览器左下的状态栏显示正在加载某某url以及老版的ie显示页面加载进度的现象)标记结束了,就表明页面的加载操作结束了,为了防止js脚本阻塞页面加载,那么我们要做到的就是让那些不会用于页面初始化展示的js代码的加载和执行操作在浏览器忙指示结束后触发,为了做到这一点我们就得知道忙指示结束后会触发什么命令,这个命令就是浏览器的onload事件,因此我们让那些和页面加载无关的js脚本在onload方法里执行,在onload事件里我就会使用dom技术,构建script节点,设置它的src指向需要加载的脚本路径,然后将这个srcipt节点加入到html文档的head里,为了完全确保这个js在忙指示结束后执行,我使用setTimeout方法调用动态加载脚本的方法,进一步确保代码在忙指示结束后执行,实践下来感觉效果的确不错。
理解到这里我本来很高兴,认为自己又理解了一个前端开发的难点,并且有一个好的解决方案,但是等我回味一下发现有点不对头,我经常使用的jQuery定义了ready方法,ready方法会在dom加载完毕后执行,而我自己的方案却是在onload后执行,代码执行远远落后jQuery的ready方法执行时机,这个感觉让我很不舒服,其次,在页面开发里我们会使用很多第三方库,虽然我现在开发尽量做到只用jQuery这一个第三方库,但是其他人则不尽然,他们会使用很多第三方库,很多库有大量UI操作的通用方法,这些方法非常好用,经常使用这些库会导致我们自己写的控制UI的js代码常常会依赖它们,那么拆分UI控制脚本和其他脚本就无从谈起了。总之,现在web前端开发太依赖第三方库,就算一个牛逼的前端团队,拒绝使用第三方库,什么都自己开发,当web应用变复杂后,通用库和业务代码的耦合度都是很难解决的问题,这也会导致我们没法真正将UI展示代码和逻辑代码真正的分离。
我的方案其实满足不了实际生产的需求,不够完美,所以本文到这里没有推导出通用规则,真令人失望,面对上面的新问题那我们该怎么办了?这个问题不是无解的,现行技术就有它的解决方案,那就是无阻塞脚本的加载。无阻塞加载脚本技术的核心就是:加载js脚本时候,被加载的js脚本不会阻塞UI线程的执行和以阻塞方式加载的脚本。
下面是无阻塞加载脚本的技术方案:
XHR Eval
顾名思义,通过XHR读取脚本,通过Eval令脚本生效。
代码如下:
var xhrObj = new XMLHttpRequest(); xhrObj.onreadystatechange = function(){ if(xhrObj.readyState == 4 && 200 == xhrObj.status){ eval(xhrObj.responseText); } }; xhrObj.open("GET", "A.js", true); xhrObj.send("");
由于XMLHttpRequest本身不能跨域,所以该方法不能跨域。
XHR Injection
使用动态创建script元素,来写入脚本,在某些情况下可能比上一种方法要快些。
代码如下:
var xhrObj = new XMLHttpRequest(); xhrObj.onreadystatechange = function(){ if(xhrObj.readyState == 4){ var scriptElem = document.createElement("script"); document.getElementsByTagName("head")[0].appendChild(scriptElem); scriptElem.text = xhrObj.responseText; } }; xhrObj.open("GET", "A.js", true); xhrObj.send("");
Script in Iframe
由于Iframe是开销最高的DOM元素,这种方法还是尽量避免使用,而且这种方法也无法实现跨域。
Script DOM Element
可跨域方案,利用动态插入script元素来让脚本读取、生效。
代码如下:
var scriptElem = document.createElement("script"); scriptElem.src = "http://anydomain.com/A.js"; document.getElementByTagName("head")[0].appendChild(scriptElem);
Script Defer
原生方案。利用defer属性来防止脚本阻塞。
代码如下:
<script defer src="A.js"></script>
不过许多浏览器不支持该属性。
document.write Script Tag
动态写脚本的另一种方案,不过只在IE中是并行下载的。
代码如下:
document.write("<script type='text/javascript' src='A.js'></script>");
script defer和document.write Srcipt Tag不是跨浏览器的方案这里不推荐。
页面嵌套iframe方案我没有详述,原因是我现在很讨厌iframe,iframe是dom元素里开销最大的元素,有它就意味着慢,而且我最近碰到一个生产问题就是iframe引起,原因就是对iframe跨域造成,iframe跨域以后,父窗体和子窗体代码就不能互访了,而且iframe写法的不正确(写的很类似跨站脚本挟持)还会导致浏览器启动默认的安全机制,最终出现用户无法正常使用页面的情况,所以我也不推荐使用iframe。
xhr eval也是我不会去使用的方式,因为它用eval命令,而eval的使用常常会为黑客留下破坏你网站的漏洞。
因此最好的方案就是xhr 注入和script dom element了,这两个方案不存在浏览器兼容问题,而且后者还能跨域,不过跨域的选择也是要谨慎的,跨域脚本也会带来隐形的安全风险,不管怎么说这两个方案使用场景基本上可以包括所有阻塞脚本加载的场景。
注意:无阻塞加载脚本的核心技术就是动态的创建script的dom节点。
无阻塞脚本加载技术还有个好处就是,那些和页面展示无关的脚本无须非要放在onload事件里执行,它随时随地可以运行简直就是完美。
不过无阻塞脚本有个很大的隐患,这个隐患是很多会使用无阻塞脚本技术的程序员都会忽视的问题,这个问题就是无阻塞脚本很容易产生“变量未定义”的问题,这个问题的本质就是无阻塞脚本会破坏js脚本加载顺序的问题,当某个脚本依赖于另一个脚本时候,而另一个脚本又没有加载执行完毕,最后就会产生“变量未定义”的问题,例如jQuery没有提前加载,因此使用$时候提示$变量未定义。
那么我们该如何解决这个问题了,我们的思路就是让那些依赖于无阻塞加载的脚本的js代码在脚本加载完毕后才会执行,我们需要一个办法将无序的脚本加载变得有序,上面我推荐的两种方法都是使用dom技术创建script节点,然后将该节点加入到文档的head头部,对于script节点,在非ie浏览器下有一个onload事件,该事件会在script加载完毕后才会执行,ie浏览器下有onreadystatechange事件,而ie下script的dom节点有一个readystate属性,它的取值如下:
1.uninitialized(未初始化):对象存在尚未初始化;
2.loading(正在加载):对象正在加载数据;
3.loaded(加载完毕):对象数据加载完成
4.interactive(交互):可以操作对象,但是还没有完全加载;
5.complete(完成):对象已经加载完毕。
具体用法如下所示:
scriptNode.onreadystatechange = function(){ if (scriptNode.readystate == 'complete'){// todo......} }
这个做法就是为dom加载定义了一个回调函数,当dom加载完毕后回调函数才会执行,这样就解决了代码执行顺序的问题了。
另外还有一个方式就是使用setTimeout,具体使用就是定义一个轮询,判断需要使用的变量是否存在,如果不存在,就继续轮询,如果变量存在则停止轮询,代码模式如下所示:
代码如下:
function lunxun(){ if ("undefined" == typeof(XXXX)){ setTimeout(lunxun,300); }else{ ftn(); } } lunxun();
无阻塞脚本的好处就是不会阻塞UI的执行,也不会影响其他同步js代码的执行,不过无阻塞脚本改变了脚本的加载顺序,所以在使用无阻塞脚本时候一定要更加注意脚本之间的依赖关系,保证整个页面的脚本都能正常执行。
在以前的文章里我多次提到了js的模块加载技术,时下流行的模块加载技术有进口货requirejs和国产货seajs,使用这些技术,我们会发现js文件加载都是按模块加载的,也就是说你页面定义了多少个js模块,那么这个页面就有多少个js文件,刚开始使用它们时候我很诧异,按照前端优化原则http请求越少越好,为什么先进的模块技术却会让js文件变得更多了,接着我分析了下它们加载js的请求,终于明白了,它们都使用的无阻塞脚本加载技术,即使用script节点方式加载脚本,这样就很容易屏蔽js带来的阻塞问题了。
上面的实例中我使用script节点将脚本都是嵌入到head节点里,这个似乎和将脚本置于html文档末尾的原则不同,这个是不是需要改进了,答案是不需要改进,将脚本置于文档末尾目的是为了避免js的阻塞,而我们使用无阻塞脚本了,这个问题不是解决了吗?所以代码置于head标签还是html文档底部也就无关紧要了。
最后我要纠正一个错误的观点,页面加载的总时间是衡量页面加载快捷的标准吗?答案是,的确是个标准,但是不是最精确的标准,页面同步阻塞加载的时间才是衡量页面加载效率的准确标准,非阻塞脚本加载可能会增加整个页面加载的时间,但是它可以减少页面阻塞加载的时间,而页面阻塞才是影响用户体验的元凶,页面优化最重要的关注点就是你所看到的的东西要加载的更加快。
无阻塞脚本可以分割外部脚本的下载和执行操作,这是程序员使用的hack技术,它很酷,但是会导致程序的复杂度增加,可读性下降,所以它应该是web前端架构师的技术,日常开发我们要慎用它。