JavaScript 的效能優化:載入和執行

developerworks發表於2013-09-04

  簡介

  隨著 Web2.0 技術的不斷推廣,越來越多的應用使用 JavaScript 技術在客戶端進行處理,從而使 JavaScript 在瀏覽器中的效能成為開發者所面臨的最重要的可用性問題。而這個問題又因 JavaScript 的阻塞特性變的複雜,也就是說當瀏覽器在執行 JavaScript 程式碼時,不能同時做其他任何事情。本文詳細介紹瞭如何正確的載入和執行 JavaScript 程式碼,從而提高其在瀏覽器中的效能。

  概覽

  無論當前 JavaScript 程式碼是內嵌還是在外鏈檔案中,頁面的下載和渲染都必須停下來等待指令碼執行完成。JavaScript 執行過程耗時越久,瀏覽器等待響應使用者輸入的時間就越長。瀏覽器在下載和執行指令碼時出現阻塞的原因在於,指令碼可能會改變頁面或 JavaScript 的名稱空間,它們對後面頁面內容造成影響。一個典型的例子就是在頁面中使用document.write()。例如清單 1

  清單 1 JavaScript 程式碼內嵌示例

<html>
<head>
    <title>Source Example</title>
</head>
<body>
    <p>
    <script type="text/javascript">
        document.write("Today is " + (new Date()).toDateString());
    </script>
    </p>
</body>
</html>

  當瀏覽器遇到<script>標籤時,當前 HTML 頁面無從獲知 JavaScript 是否會向<p> 標籤新增內容,或引入其他元素,或甚至移除該標籤。因此,這時瀏覽器會停止處理頁面,先執行 JavaScript程式碼,然後再繼續解析和渲染頁面。同樣的情況也發生在使用 src 屬性載入 JavaScript的過程中,瀏覽器必須先花時間下載外鏈檔案中的程式碼,然後解析並執行它。在這個過程中,頁面渲染和使用者互動完全被阻塞了。

  指令碼位置

  HTML 4 規範指出 <script> 標籤可以放在 HTML 文件的<head>或<body>中,並允許出現多次。Web 開發人員一般習慣在 <head> 中載入外鏈的 JavaScript,接著用 <link> 標籤用來載入外鏈的 CSS 檔案或者其他頁面資訊。例如清單 2

  清單 2 低效率指令碼位置示例

<html>
<head>
    <title>Source Example</title>
    <script type="text/javascript" src="script1.js"></script>
    <script type="text/javascript" src="script2.js"></script>
    <script type="text/javascript" src="script3.js"></script>
    <link rel="stylesheet" type="text/css" href="styles.css">
</head>
<body>
    <p>Hello world!</p>
</body>
</html>

  然而這種常規的做法卻隱藏著嚴重的效能問題。在清單 2 的示例中,當瀏覽器解析到 <script> 標籤(第 4 行)時,瀏覽器會停止解析其後的內容,而優先下載指令碼檔案,並執行其中的程式碼,這意味著,其後的 styles.css 樣式檔案和<body>標籤都無法被載入,由於<body>標籤無法被載入,那麼頁面自然就無法渲染了。因此在該 JavaScript 程式碼完全執行完之前,頁面都是一片空白。圖 1 描述了頁面載入過程中指令碼和樣式檔案的下載過程。

  圖 1 JavaScript 檔案的載入和執行阻塞其他檔案的下載


 

  我們可以發現一個有趣的現象:第一個 JavaScript 檔案開始下載,與此同時阻塞了頁面其他檔案的下載。此外,從 script1.js 下載完成到 script2.js 開始下載前存在一個延時,這段時間正好是 script1.js 檔案的執行過程。每個檔案必須等到前一個檔案下載並執行完成才會開始下載。在這些檔案逐個下載過程中,使用者看到的是一片空白的頁面。

  從 IE 8、Firefox 3.5、Safari 4 和 Chrome 2 開始都允許並行下載 JavaScript 檔案。這是個好訊息,因為<script>標籤在下載外部資源時不會阻塞其他<script>標籤。遺憾的是,JavaScript 下載過程仍然會阻塞其他資源的下載,比如樣式檔案和圖片。儘管指令碼的下載過程不會互相影響,但頁面仍然必須等待所有 JavaScript 程式碼下載並執行完成才能繼續。因此,儘管最新的瀏覽器通過允許並行下載提高了效能,但問題尚未完全解決,指令碼阻塞仍然是一個問題。

  由於指令碼會阻塞頁面其他資源的下載,因此推薦將所有<script>標籤儘可能放到<body>標籤的底部,以儘量減少對整個頁面下載的影響。例如清單 3

  清單 3 推薦的程式碼放置位置示例

<html>
<head>
    <title>Source Example</title>
    <link rel="stylesheet" type="text/css" href="styles.css">
</head>
<body>
    <p>Hello world!</p>

    <!-- Example of efficient script positioning -->
    <script type="text/javascript" src="script1.js"></script>
    <script type="text/javascript" src="script2.js"></script>
    <script type="text/javascript" src="script3.js"></script>
</body>
</html>

  這段程式碼展示了在 HTML 文件中放置<script>標籤的推薦位置。儘管指令碼下載會阻塞另一個指令碼,但是頁面的大部分內容都已經下載完成並顯示給了使用者,因此頁面下載不會顯得太慢。這是優化 JavaScript 的首要規則:將指令碼放在底部。

  組織指令碼

  由於每個<script>標籤初始下載時都會阻塞頁面渲染,所以減少頁面包含的<script>標籤數量有助於改善這一情況。這不僅針對外鏈指令碼,內嵌指令碼的數量同樣也要限制。瀏覽器在解析 HTML 頁面的過程中每遇到一個<script>標籤,都會因執行指令碼而導致一定的延時,因此最小化延遲時間將會明顯改善頁面的總體效能。

  這個問題在處理外鏈 JavaScript 檔案時略有不同。考慮到 HTTP 請求會帶來額外的效能開銷,因此下載單個 100Kb 的檔案將比下載 5 個 20Kb 的檔案更快。也就是說,減少頁面中外鏈指令碼的數量將會改善效能。

  通常一個大型網站或應用需要依賴數個 JavaScript 檔案。您可以把多個檔案合併成一個,這樣只需要引用一個<script>標籤,就可以減少效能消耗。檔案合併的工作可通過離線的打包工具或者一些實時的線上服務來實現。

  需要特別提醒的是,把一段內嵌指令碼放在引用外鏈樣式表的<link>之後會導致頁面阻塞去等待樣式表的下載。這樣做是為了確保內嵌指令碼在執行時能獲得最精確的樣式資訊。因此,建議不要把內嵌指令碼緊跟在<link>標籤後面。

  無阻塞的指令碼

  減少 JavaScript 檔案大小並限制 HTTP 請求數在功能豐富的 Web 應用或大型網站上並不總是可行。Web 應用的功能越豐富,所需要的 JavaScript 程式碼就越多,儘管下載單個較大的 JavaScript 檔案只產生一次 HTTP 請求,卻會鎖死瀏覽器的一大段時間。為避免這種情況,需要通過一些特定的技術向頁面中逐步載入 JavaScript 檔案,這樣做在某種程度上來說不會阻塞瀏覽器。

  無阻塞指令碼的祕訣在於,在頁面載入完成後才載入 JavaScript 程式碼。這就意味著在 window 物件的 onload事件觸發後再下載指令碼。有多種方式可以實現這一效果。

  延遲載入指令碼

  HTML 4 為<script>標籤定義了一個擴充套件屬性:defer。Defer 屬性指明本元素所含的指令碼不會修改 DOM,因此程式碼能安全地延遲執行。defer 屬性只被 IE 4 和 Firefox 3.5 更高版本的瀏覽器所支援,所以它不是一個理想的跨瀏覽器解決方案。在其他瀏覽器中,defer 屬性會被直接忽略,因此<script>標籤會以預設的方式處理,也就是說會造成阻塞。然而,如果您的目標瀏覽器支援的話,這仍然是個有用的解決方案。清單 4 是一個例子

  清單 4 defer 屬性使用方法示例

<script type="text/javascript" src="script1.js" defer></script>

  帶有 defer 屬性的<script>標籤可以放置在文件的任何位置。對應的 JavaScript 檔案將在頁面解析到<script>標籤時開始下載,但不會執行,直到 DOM 載入完成,即onload事件觸發前才會被執行。當一個帶有 defer 屬性的 JavaScript 檔案下載時,它不會阻塞瀏覽器的其他程式,因此這類檔案可以與其他資原始檔一起並行下載。

  任何帶有 defer 屬性的<script>元素在 DOM 完成載入之前都不會被執行,無論內嵌或者是外鏈指令碼都是如此。清單 5 的例子展示了defer屬性如何影響指令碼行為:

  清單 5 defer 屬性對指令碼行為的影響

<html>
<head>
    <title>Script Defer Example</title>
</head>
<body>
    <script type="text/javascript" defer>
        alert("defer");
    </script>
    <script type="text/javascript">
        alert("script");
    </script>
    <script type="text/javascript">
        window.onload = function(){
            alert("load");
        };
    </script>
</body>
</html>

  這段程式碼在頁面處理過程中彈出三次對話方塊。不支援 defer 屬性的瀏覽器的彈出順序是:“defer”、“script”、“load”。而在支援 defer 屬性的瀏覽器上,彈出的順序則是:“script”、“defer”、“load”。請注意,帶有 defer 屬性的<script>元素不是跟在第二個後面執行,而是在 onload 事件被觸發前被呼叫。

  如果您的目標瀏覽器只包括 Internet Explorer 和 Firefox 3.5,那麼 defer 指令碼確實有用。如果您需要支援跨領域的多種瀏覽器,那麼還有更一致的實現方式。

  HTML 5 為<script>標籤定義了一個新的擴充套件屬性:async。它的作用和 defer 一樣,能夠非同步地載入和執行指令碼,不因為載入指令碼而阻塞頁面的載入。但是有一點需要注意,在有 async 的情況下,JavaScript 指令碼一旦下載好了就會執行,所以很有可能不是按照原本的順序來執行的。如果 JavaScript 指令碼前後有依賴性,使用 async 就很有可能出現錯誤。

  動態指令碼元素

  文件物件模型(DOM)允許您使用 JavaScript 動態建立 HTML 的幾乎全部文件內容。<script>元素與頁面其他元素一樣,可以非常容易地通過標準 DOM 函式建立:

  清單 6 通過標準 DOM 函式建立<script>元素

var script = document.createElement ("script");
   script.type = "text/javascript";
   script.src = "script1.js";
   document.getElementsByTagName("head")[0].appendChild(script);

  新的<script>元素載入 script1.js 原始檔。此檔案當元素新增到頁面之後立刻開始下載。此技術的重點在於:無論在何處啟動下載,檔案的下載和執行都不會阻塞其他頁面處理過程。您甚至可以將這些程式碼放在<head>部分而不會對其餘部分的頁面程式碼造成影響(除了用於下載檔案的 HTTP 連線)。

  當檔案使用動態指令碼節點下載時,返回的程式碼通常立即執行(除了 Firefox 和 Opera,他們將等待此前的所有動態指令碼節點執行完畢)。當指令碼是“自執行”型別時,這一機制執行正常,但是如果指令碼只包含供頁面其他指令碼呼叫呼叫的介面,則會帶來問題。這種情況下,您需要跟蹤指令碼下載完成並是否準備妥善。可以使用動態 <script> 節點發出事件得到相關資訊。

  Firefox、Opera, Chorme 和 Safari 3+會在<script>節點接收完成之後發出一個 onload 事件。您可以監聽這一事件,以得到指令碼準備好的通知:

  清單 7 通過監聽 onload 事件載入 JavaScript 指令碼

var script = document.createElement ("script")
script.type = "text/javascript";

//Firefox, Opera, Chrome, Safari 3+
script.onload = function(){
    alert("Script loaded!");
};

script.src = "script1.js";
document.getElementsByTagName("head")[0].appendChild(script);

  Internet Explorer 支援另一種實現方式,它發出一個 readystatechange 事件。<script>元素有一個 readyState 屬性,它的值隨著下載外部檔案的過程而改變。readyState 有五種取值:

  • “uninitialized”:預設狀態
  • “loading”:下載開始
  • “loaded”:下載完成
  • “interactive”:下載完成但尚不可用
  • “complete”:所有資料已經準備好

  微軟文件上說,在<script>元素的生命週期中,readyState 的這些取值不一定全部出現,但並沒有指出哪些取值總會被用到。實踐中,我們最感興趣的是“loaded”和“complete”狀態。Internet Explorer 對這兩個 readyState 值所表示的最終狀態並不一致,有時<script>元素會得到“loader”卻從不出現“complete”,但另外一些情況下出現“complete”而用不到“loaded”。最安全的辦法就是在 readystatechange 事件中檢查這兩種狀態,並且當其中一種狀態出現時,刪除 readystatechange 事件控制程式碼(保證事件不會被處理兩次):

  清單 8 通過檢查 readyState 狀態載入 JavaScript 指令碼

var script = document.createElement("script")
script.type = "text/javascript";

//Internet Explorer
script.onreadystatechange = function(){
     if (script.readyState == "loaded" || script.readyState == "complete"){
           script.onreadystatechange = null;
           alert("Script loaded.");
     }
};

script.src = "script1.js";
document.getElementsByTagName("head")[0].appendChild(script);

  大多數情況下,您希望呼叫一個函式就可以實現 JavaScript 檔案的動態載入。下面的函式封裝了標準實現和 IE 實現所需的功能:

  清單 9 通過函式進行封裝

function loadScript(url, callback){
    var script = document.createElement ("script")
    script.type = "text/javascript";
    if (script.readyState){ //IE
        script.onreadystatechange = function(){
            if (script.readyState == "loaded" || script.readyState == "complete"){
                script.onreadystatechange = null;
                callback();
            }
        };
    } else { //Others
        script.onload = function(){
            callback();
        };
    }
    script.src = url;
    document.getElementsByTagName("head")[0].appendChild(script);
}

  此函式接收兩個引數:JavaScript 檔案的 URL,和一個當 JavaScript 接收完成時觸發的回撥函式。屬性檢查用於決定監視哪種事件。最後一步,設定 src 屬性,並將<script>元素新增至頁面。此 loadScript() 函式使用方法如下:

  清單 10 loadScript()函式使用方法

loadScript("script1.js", function(){
    alert("File is loaded!");
});

  您可以在頁面中動態載入很多 JavaScript 檔案,但要注意,瀏覽器不保證檔案載入的順序。所有主流瀏覽器之中,只有 Firefox 和 Opera 保證指令碼按照您指定的順序執行。其他瀏覽器將按照伺服器返回它們的次序下載並執行不同的程式碼檔案。您可以將下載操作串聯在一起以保證他們的次序,如下:

  清單 11 通過 loadScript()函式載入多個 JavaScript 指令碼

loadScript("script1.js", function(){
    loadScript("script2.js", function(){
        loadScript("script3.js", function(){
            alert("All files are loaded!");
        });
    });
});

  此程式碼等待 script1.js 可用之後才開始載入 script2.js,等 script2.js 可用之後才開始載入 script3.js。雖然此方法可行,但如果要下載和執行的檔案很多,還是有些麻煩。如果多個檔案的次序十分重要,更好的辦法是將這些檔案按照正確的次序連線成一個檔案。獨立檔案可以一次性下載所有程式碼(由於這是非同步進行的,使用一個大檔案並沒有什麼損失)。

  動態指令碼載入是非阻塞 JavaScript 下載中最常用的模式,因為它可以跨瀏覽器,而且簡單易用。

  使用 XMLHttpRequest(XHR)物件

  此技術首先建立一個 XHR 物件,然後下載 JavaScript 檔案,接著用一個動態 <script> 元素將 JavaScript 程式碼注入頁面。清單 12 是一個簡單的例子:

  清單 12 通過 XHR 物件載入 JavaScript 指令碼

var xhr = new XMLHttpRequest();
xhr.open("get", "script1.js", true);
xhr.onreadystatechange = function(){
    if (xhr.readyState == 4){
        if (xhr.status >= 200 && xhr.status < 300 || xhr.status == 304){
            var script = document.createElement ("script");
            script.type = "text/javascript";
            script.text = xhr.responseText;
            document.body.appendChild(script);
        }
    }
};
xhr.send(null);

  此程式碼向伺服器傳送一個獲取 script1.js 檔案的 GET 請求。onreadystatechange 事件處理函式檢查 readyState 是不是 4,然後檢查 HTTP 狀態碼是不是有效(2XX 表示有效的回應,304 表示一個快取響應)。如果收到了一個有效的響應,那麼就建立一個新的<script>元素,將它的文字屬性設定為從伺服器接收到的 responseText 字串。這樣做實際上會建立一個帶有內聯程式碼的<script>元素。一旦新<script>元素被新增到文件,程式碼將被執行,並準備使用。

  這種方法的主要優點是,您可以下載不立即執行的 JavaScript 程式碼。由於程式碼返回在<script>標籤之外(換句話說不受<script>標籤約束),它下載後不會自動執行,這使得您可以推遲執行,直到一切都準備好了。另一個優點是,同樣的程式碼在所有現代瀏覽器中都不會引發異常。

  此方法最主要的限制是:JavaScript 檔案必須與頁面放置在同一個域內,不能從 CDN 下載(CDN 指"內容投遞網路(Content Delivery Network)",所以大型網頁通常不採用 XHR 指令碼注入技術。

  總結

  減少 JavaScript 對效能的影響有以下幾種方法:

  • 將所有的<script>標籤放到頁面底部,也就是</body>閉合標籤之前,這能確保在指令碼執行前頁面已經完成了渲染。
  • 儘可能地合併指令碼。頁面中的<script>標籤越少,載入也就越快,響應也越迅速。無論是外鏈指令碼還是內嵌指令碼都是如此。
  • 採用無阻塞下載 JavaScript 指令碼的方法:
    • 使用<script>標籤的 defer 屬性(僅適用於 IE 和 Firefox 3.5 以上版本);
    • 使用動態建立的<script>元素來下載並執行程式碼;
    • 使用 XHR 物件下載 JavaScript 程式碼並注入頁面中。

  通過以上策略,可以在很大程度上提高那些需要使用大量 JavaScript 的 Web 網站和應用的實際效能。

相關文章