几乎每一个前端程序员都知道应该把script标签放在页面底部。关于这个经典的论述可以追溯到Nicholas的 High Performance javasript 这本书的第一章Loading and Execution中,他之所以建议这么做是因为:
Put all <script> tags at the bottom of the page, just inside of the closing </body> tag. This ensures that the page can be almost completely rendered before script execution begins.
简而言之,如果浏览器加载并执行脚本,会引起页面的渲染被暂停,甚至还会阻塞其他资源(比如图片)的加载。为了更快的给用户呈现网页内容,更好的用户体验,应该把脚本放在页面底部,使之最后加载。
为什么要在标题中使用“再”这个字?因为在工作中逐渐发现,我们经常谈论的一些页面优化技巧,比如上面所说的总是把脚本放在页面的底部,压缩合并样式或者脚本文件等,时至今日已不再是最佳的解决方案,甚至事与愿违,转化为性能的毒药。这篇文章所要聊的,便是展示某些不被人关注的浏览器特性或者技巧,来继续完成资源加载性能优化的任务。
首先让我们看一看这样一类资源分布的页面:
<head> <link rel="stylesheet" type="text/CSS" href=""> <script type="text/Javascript"></script></head><body> <img src=""> <img src=""> <img src=""> <img src=""> <img src=""> <img src=""> <img src=""> <img src=""> <script type="text/javascript"></script> <script type="text/javascript"></script> <script type="text/javascript"></script></body>
这类页面的特点是,一个外链脚本置于页面头部,三个外链脚本置于页面的底部,并且是故意跟随在一系列img之后,在Chrome中页面加载的网络请求瀑布图如下:
值得注意的是,虽然脚本放置在图片之后,但加载仍先于图片。为什么会出现这样的情况?为什么故意置后资源能够提前得到加载?
虽然浏览器引擎的实现不同,但原理都十分的近似。不同浏览器的制造厂商们(vendor)非常清楚浏览器的瓶颈在哪(比如network, javascript evaluate, reflow, repaint)。针对这些问题,浏览器也在不断的进化,所以我们才能看到更快的脚本引擎,调用GPU的渲染等一推陈出新的优化技术和方案。
同样在资源加载上,早在IE8开始,一种叫做lookahead pre-parser(在Chrome中称为preloader)的机制就已经开始在不同浏览器中兴起。IE8相对于之前IE版本的提升除了将每台host最高并行下载的资源数从2提升至6,并且能够允许并行下载脚本文件之外,最后就是这个lookahead pre-parser机制
但我还是没有详述这是一个什么样的机制,不着急,首先看看与IE7的对比:
以上面的页面为例,我们看看IE7下的瀑布图:
底部的脚本并没有提前被加载,并且因为由于单个域名最高并行下载数2的限制,资源总是两个两个很整齐的错开并行下载。
但在IE8下,很明显底部脚本又被提前:
并没有统一的标准规定这套机制应具备何种功能已经如何实现。但你可以大致这么理解:浏览器通常会准备两个页面解析器parser,一个(main parser)用于正常的页面解析,而另一个(preloader)则试图去文档中搜寻更多需要加载的资源,但这里的资源通常仅限于外链的js、stylesheet、image;不包括audio、video等。并且动态插入页面的资源无效。
但细节方面却值得注意:
比如关于preloader的触发时机,并非与解析页面同时开始,而通常是在加载某个head中的外链脚本阻塞了main parser的情况下才启动;
也不是所有浏览器的preloader会把图片列为预加载的资源,可能它认为图片加载过于耗费带宽而不把它列为预加载资源之列;
preloader也并非最优,在某些浏览器中它会阻塞body的解析。因为有的浏览器将页面文档拆分为head和body两部分进行解析,在head没有解析完之前,body不会被解析。一旦在解析head的过程中触发了preloader,这无疑会导致head的解析时间过长。
preloader的诞生本是出于一番好意,但好心也有可能办坏事。
filamentgroup有一种著名的响应式设计的图片解决方案Responsive Design Images:
<html><head> <title></title> <script type="text/javascript" src="./responsive-images.js"></script></head><body> <img src="./running.jpg?medium=_imgs/running.medium.jpg&large=_imgs/running.large.jpg"></body></html>
它的工作原理是,当responsive-images.js加载完成时,它会检测当前显示器的尺寸,并且设置一个cookie来标记当前尺寸。同时你需要在服务器端准备一个.htaccess文件,接下来当你请求图片时,.htaccess中的配置会检测随图片请求异同发送的Cookie是被设置成medium还是large,这样也就保证根据显示器的尺寸来加载对于的图片大小。
很明显这个方案成功的前提是,js执行先于发出图片请求。但在Chrome下打开,你会发现执行顺序是这样:
responsive-images.js和图片几乎是同一时间发出的请求。结果是第一次打开页面给出的是默认小图,如果你再次刷新页面,因为Cookie才设置成功,服务器返回的是大图。
严格意义上来说在某些浏览器中这不一定是preloader引起的问题,但preloader引起的问题类似:插入脚本的顺序和位置或许是开发者有意而为之的,但preloader的这种“聪明”却可能违背开发者的意图,造成偏差。
如果你觉得上一个例子还不够说明问题的话,最后请考虑使用picture(或者@srcset)元素的情况:
<picture> <source src="med.jpg" media="(min-width: 40em)" /> <source src="sm.jpg"/> <img src="fallback.jpg" alt="" /></picture>
在preloader搜寻到该元素并且试图去下载该资源时,它应该怎么办?一个正常的paser应该是在解析该元素时根据当时页面的渲染布局去下载,而当时这类工作不一定已经完成,preloader只是提前找到了该元素。退一步来说,即使不考虑页面渲染的情况,假设preloader在这种情形下会触发一种默认加载策略,那应该是”mobile first”还是”desktop first”?默认应该加载高清还是低清照片?
理想是丰满的,现实是骨感的。出于种种的原因,我们几乎从不直接在页面上插入js脚本,而是使用第三方的加载器,比如seajs或者requirejs。关于使用加载器和模块化开发的优势在这里不再赘述。但我想回到原点,讨论应该如何利用加载器,就从seajs与requirejs的不同聊起。
在开始之前我已经假设你对requirejs与seajs语法已经基本熟悉了,如果还没有,请移步这里:
BTW: 如果你还是习惯在部署上线前把所有js文件合并打包成一个文件,那么seajs和requirejs其实对你来说并无区别。
seajs与requirejs在模块的加载方面是没有差异的,无论是requirejs在定义模块时定义的依赖模块,还是seajs在factory函数中require的依赖模块,在会在加载当前模块时被载入,异步,并且顺序不可控。差异在于factory函数执行的时机。
为了增强对比,我们在定义依赖模块的时候,故意让它们的factory函数要执行相当长的时间,比如1秒:
// dep_A.js定义如下,dep_B、dep_C定义同理define(function(require, exports, module) { (function(second) { var start = +new Date(); while (start + second * 1000 > +new Date()) {} })(window.EXE_TIME); // window.EXE_TIME = 1;此处会连续执行1s exports.foo = function() { console.log("A"); }})
为了增强对比,设置了三组进行对照试验,分别是:
//require.js:require(["dep_A", "dep_B", "dep_C"], function(A, B, C) {});//sea.js:define(function(require, exports, module) { var mod_A = require("dep_A"); var mod_B = require("dep_B"); var mod_C = require("dep_C");});//sea.js(定义依赖但并不require):define(["dep_A", "dep_B", "dep_C"], function(require, exports, module){}
接下来我们看看代码执行的瀑布图:
1.require.js:在加载完依赖模块之后立即执行了该模块的factory函数
2.sea.js: 下面两张图应该放在一起比较。两处代码都同时加载了依赖模块,但因为没有require的关系,第三张图中没有像第二张图那样执行耗时的factory函数。可见seajs执行的原则正如CMD标准中所述Execution must be lazy。
我想进一步表达的是,无论requirejs和seajs,通常来说大部分的逻辑代码都会放在模块的factory函数中,所以factory函数执行的代价是非常大的。但上图也同样告诉我们模块的define,甚至模块文件的Evaluate代价非常小,与factory函数无关。所以我们是不是应该尽可能的避免执行factory函数,或者等到我们需要的指定功能的时候才执行对应的factory函数?比如:
document.body.onclick = function () { require(some_kind_of_module);}
这是非常实际的问题,比如爱奇艺一个视频播放的页面,我们有没有必要在第一屏加载页面的时候就加载登陆注册,或者评论,或者分享功能呢?因为有非常大的可能用户只是来这里看这个视频,直至看完视频它都不会用到登陆注册功能,也不会去分享这个视频等。加载这些功能不仅仅对浏览器是一个负担,还有可能调用后台的接口,这样的性能消耗是非常可观的。
我们可以把这样称之为”懒执行”。虽然seajs并非有意实现如上所说的“懒执行”(它只是在尽可能遵循CommonJS标准靠近)。但“懒执行”确实能够有助于提升一部分性能。
但也有人会对此产生顾虑。
记得玉伯转过的一个帖子:SeaJS与RequireJS最大的区别。我们看看其中反对这么做的人的观点:
我个人感觉requirejs更科学,所有依赖的模块要先执行好。如果A模块依赖B。当执行A中的某个操doSomething()后,再去依赖执行B模块require(‘B’);如果B模块出错了,doSomething的操作如何回滚? 很多语言中的import, include, useing都是先将导入的类或者模块执行好。如果被导入的模块都有问题,有错误,执行当前模块有何意义?
而依赖dependencies是工厂的原材料,在工厂进行生产的时候,是先把原材料一次性都在它自己的工厂里加工好,还是把原材料的工厂搬到当前的factory来什么时候
新闻热点
疑难解答