前言
關於JavaScript腳本加載的問題,相信大家碰到很多。主要在幾個點——
1> 同步腳本和異步腳本帶來的文件加載、文件依賴及執行順序問題
2> 同步腳本和異步腳本帶來的性能優化問題
深入理解腳本加載相關的方方面面問題,不僅利於解決實際問題,更加利於對性能優化的把握並執行。
先看隨便一個script標簽代碼——
復制代碼 代碼如下:
<script src="js/myApp.js"></script>
如果放在<head>上面,會阻塞所有頁面渲染工作,使得用戶在腳本加載完畢並執行完畢之前一直處於“白屏死機”狀態。而<body>末尾的打腳本只會讓用戶看到毫無生命力的靜態頁面,原本應該進行客戶端渲染的地方卻散布著不起作用的控件和空空如也的方框。拿一個測試用例——
復制代碼 代碼如下:
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title>異步加載script</title>
<script src="js/test.js"></script>
</head>
<body>
<div>我是內容</div>
<img src="img/test.jpg">
</body>
</html>
其中,test.js中的內容——
復制代碼 代碼如下:
alert('我是head裡面的腳本代碼,執行這裡的js之後,才開始進行body的內容渲染!');
我們會看到,alert是一個暫停點,此時,頁面是空白的。但是要注意,此時整個頁面已經加載完畢,如果body中包含某些src屬性的標簽(如上面的img標簽),此時浏覽器已經開始加載相關內容了。總之要注意——js引擎和渲染引擎的工作時機是互斥的(一些書上叫它為UI線程)。
因此,我們需要——那些負責讓頁面更好看、更好用的腳本應該立即加載,而那些可以待會兒再加載的腳本稍後再加載。
一、腳本延遲執行
現在越來越流行把腳本放在頁面<body>標簽的尾部。這樣,一方面用戶可以更快地看到頁面,另一方面腳本可以直接操作已經加載完成的dom元素。對於大多數腳本而言,這次“搬家”是個巨大的進步。該頁面模型如下——
復制代碼 代碼如下:
<!DOCTYPE html>
<html>
<head lang="en">
<!--metadata and scriptsheets go here-->
<script src="headScript.js"></script>
</head>
<body>
<!--content goes here-->
<script src="bodyScript.js"></script>
</body>
</html>
這確實大大加快了頁面的渲染時間,但是注意一點,這可能讓用戶有機會在加載bodyScript之前與頁面交互。源於浏覽器在加載完整個文檔之前無法加載這些腳本,這對那些通過慢速連接傳送的大型文檔來說會是一大瓶頸。
理想情況下,腳本的加載應該與文檔的加載同時進行,並且不影響DOM的渲染。這樣,一旦文檔就緒就可以運行腳本,因為已經按照<script>標簽的次序加載了相應腳本。
我們使用defer便能夠完成這樣的需求,即——
復制代碼 代碼如下:
<script src="deferredScript.js"></script>
添加defer屬性相當於告訴浏覽器:請馬上開始加載這個腳本吧,但是,請等到文檔就緒且此前所有具有defer屬性的腳本都結束運行之後再運行它。
這樣,在head標簽裡放入延遲腳本,技能帶來腳本置於body標簽時的所有好處,又能讓大文檔的加載速度大幅提升。此時的頁面模式便是——
復制代碼 代碼如下:
<!DOCTYPE html>
<html>
<head lang="en">
<!--metadata and scriptsheets go here-->
<script src="headScript.js"></script>
<script src="deferredScript.js" defer></script>
</head>
<body>
<!--content goes here-->
</body>
</html>
但是並非所有的浏覽器都支持defer(對於一些modern浏覽器,如果聲明defer,其內部腳本將不會執行document.write及DOM渲染操作。IE4+均支持defer屬性)。這意味著,如果想確保自己的延遲腳本能在文檔加載後運行,就必須將所有延遲腳本的代碼都封裝在諸如jQuery之$(document).ready之類的結構中。這是值得的,因為差不多97%的訪客都能享受到並行加載的好處,同時另外3%的訪客仍然能使用功能完整的JavaScript。
二、腳本的完全並行化
讓腳本的加載及執行再快一步,我不想等到defer腳本一個接著一個運行(defer讓我們想到一種靜靜等待文檔加載的有序排隊場景),更不想等到文檔就緒之後才運行這些腳本,我想要盡快加載並且盡快運行這些腳本。這裡也就想到了HTML5的async屬性,但是要注意,它是一種混亂的無政府狀態。
例如,我們加載兩個完全不相干的第三方腳本,頁面沒有它們也運行得很好,而且也不在乎它們誰先運行誰後運行。因此,對這些第三方腳本使用async屬性,相當於一分錢沒花就提升了它們的運行速度。
async屬性是HTML5新增的。作用和defer類似,即允許在下載腳本的同時進行DOM的渲染。但是它將在下載後盡快執行(即JS引擎空閒了立馬執行),不能保證腳本會按順序執行。它們將在onload 事件之前完成。
Firefox 3.6、Opera 10.5、IE 9 和 最新的Chrome 和 Safari 都支持 async 屬性。可以同時使用 async 和 defer,這樣IE 4之後的所有 IE 都支持異步加載,但是要注意,async會覆蓋掉defer。
那麼此時的頁面模型如下——
復制代碼 代碼如下:
<!DOCTYPE html>
<html>
<head lang="en">
<!--metadata and scriptsheets go here-->
<script src="headScript.js"></script>
<script src="deferredScript.js" defer></script>
</head>
<body>
<!--content goes here-->
<script src="asyncScript1.js" async defer></script>
<script src="asyncScript2.js" async defer></script>
</body>
</html>
要注意這裡的執行順序——各個腳本文件加載,接著執行headScript.js,緊接著在DOM渲染的同時會在後台加載defferedScript.js。接著在DOM渲染結束時將運行defferedScript.js和那兩個異步腳本,要注意對於支持async屬性的浏覽器而言,這兩個腳本將做無序運行。
三、可編程的腳本加載
盡管上面兩個腳本屬性的功能非常吸引人,但是由於兼容性的問題,應用並不是很廣泛。故此,我們更多使用腳本加載其他腳本。例如,我們只想給那些滿足一定條件的用戶加載某個腳本,也就是經常提到的“懶加載”。
在浏覽器API層面,有兩種合理的方法來抓取並運行服務器腳本——
1> 生成ajax請求並用eval函數處理響應
2> 向DOM插入<script>標簽
後一種方式更好,因為浏覽器會替我們操心生成HTTP請求這樣的事。再者,eval也有一些實際問題:洩露作用域,調試搞得一團糟,而且還可能降低性能。因此,想要加載名為feture.js的腳本,我們應該使用類似下面的代碼:
復制代碼 代碼如下:
var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
script.src = 'feature.js';
head.appendChild(script);
當然,我們要處理回調監聽,HTML5規范定義了一個可以綁定回調的onload屬性。
復制代碼 代碼如下:
script.onload = function() {
console.log('script loaded ...');
}
不過,IE8及更老的版本並不支持onload,它們支持的是onreadystatechange。而且,對於錯誤處理仍然千奇百怪。在這裡,可以多參考一些流行的校本加載庫,如labjs、yepnope、requirejs等。
如下,自己封裝了一個簡易loadjs文件——
復制代碼 代碼如下:
var loadJS = function(url,callback){
var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
script.src = url;
script.type = "text/javascript";
head.appendChild( script);
// script 標簽,IE下有onreadystatechange事件, w3c標准有onload事件
// IE9+也支持 W3C標准的onload
var ua = navigator.userAgent,
ua_version;
// IE6/7/8
if (/MSIE ([^;]+)/.test(ua)) {
ua_version = parseFloat(RegExp["$1"], 10);
if (ua_version <= 8) {
script.onreadystatechange = function(){
if (this.readyState == "loaded" ){
callback();
}
}
} else {
script.onload = function(){
callback();
};
}
} else {
script.onload = function(){
callback();
};
}
};
對於document.write的方式異步加載腳本,在這裡就不說了,現在很少有人這麼干了,因為浏覽器差異性實在是搞得頭大。
要注意,使用 Image 對象異步預加載 js 文件,裡面的js代碼將不會被執行。
最後,談一下requirejs中的異步加載腳本。
requirejs不會保證按順序運行目標腳本,只是保證它們的運行次序能滿足各自的依賴性要求。從而我們確保了盡快的並行加載所有腳本,並有條不紊的按照依賴性拓撲結構去執行這些腳本。
四、總結
OK,談到這兒,異步加載腳本的陳述也就完了。我再次啰嗦一下這裡的優化順序——
1> 傳統的方式,我們使用script標簽直接嵌入到html文檔中,這裡分兩種情況——
a> 嵌入到head標簽中——要注意,這樣做並不會影響文檔內容中其他靜態資源文件的並行加載,它影響的是,文檔內容的渲染,即此時的DOM渲染就會被阻塞,呈現白屏。
b> 嵌入到body標簽底部——為了免去白屏現象,我們優先進行DOM的渲染,再去執行腳本,但問題又來了。先說第一個問題——如果DOM文檔內容比較大,交互事件綁定便有了延遲,體驗便差了些。當然,我們需要根據需求而定,讓重要的腳本優先執行。再說第二個問題——由於腳本文件至於body底部,導致對於這些腳本的加載相對於至於head中的腳本而言,它們的加載便有了延遲。所以,至於body底部,也並非是優化的終點。
c> 添加defer屬性——我們希望腳本盡早的進行並行加載,我們把這批腳本依舊放入head中。腳本的加載應該與文檔的加載同時進行,並且不影響DOM的渲染。這樣,一旦文檔就緒就可以運行腳本。所以便有了defer這樣屬性。但是要注意它的兼容性,對於不支持defer屬性的浏覽器,我們需要將代碼封裝在諸如jQuery之$(document).ready中。需要注意一點,所有的defer屬性的腳本,是按照其出場順序依次執行,因此,它同樣嚴格同步。
2> 上一點,講的都是同步執行腳本(要注意,這些腳本的加載過程是並行的,只不過是誰先觸發請求誰後觸發請求的區別而已),接下來的優化點便是“並行執行腳本”,當然,我們知道,一個時間點,只有執行一個js文件,這裡的“並行”是指,誰先加載完了,只要此時js引擎空閒,立馬執行之。這裡的優化分成兩種——
a> 添加async這個屬性——確實能夠完成上面我們所說的優化點,但是它有很高的局限性,即僅僅是針對非依賴性腳本加載,最恰當的例子便是引入多個第三方腳本了。還有就是與deffer屬性的合用,實在是讓人大費腦筋。當然,它也存在兼容性問題。以上三個問題便導致其應用並不廣泛。當使用async的時候,一定要嚴格注意依賴性問題。
b> 腳本加載腳本——很顯然,我們使用之來達到“並行執行腳本”的目的。同時,我們也方便去控制腳本依賴的問題,我們便使用了如requirejs中對於js異步加載的智能化加載管理。
好,寫到這兒。
這裡,我僅僅談的是異步加載腳本的相關內容。還有一塊兒內容,便是異步加載樣式文件或者其他靜態資源。待續......