前端效能優化:細說JavaScript的載入與執行

bs123發表於2019-03-04

本文主要是從效能優化的角度來探討JavaScript在載入與執行過程中的優化思路與實踐方法,既是細說,文中在涉及原理性的地方,不免會多說幾句,還望各位讀者保持耐心,仔細理解,請相信,您的耐心付出一定會讓您得到與之匹配的回報。

緣起

隨著使用者體驗的日益重視,前端效能對使用者體驗的影響備受關注,但由於引起效能問題的原因相對複雜,我們很難但從某一方面或某幾個方面來全面解決它,這也是我行此文的原因,想以此文為起點,用一系列文章來深層次探討與梳理有關Javascript效能的方方面面,以填補並夯實自己的知識結構。

目錄結構

本文大致的行文思路,包含但不侷限:

  • 不得不說的JavaScript阻塞特性

  • 合理放置指令碼位置,以優化載入體驗,js指令碼放在 <body>標籤閉合之前。

  • 減少HTTP請求次數,壓縮精簡指令碼程式碼。

  • 無阻塞載入JavaScript指令碼:

    • 使用<script>標籤的defer屬性。

    • 使用HTML5的async屬性。

    • 動態建立<script>元素載入JavaScript。

    • 使用XHR物件載入JavaScript。

不得不說的JavaScript的阻塞特性

前端開發者應該都知道,JavaScript是單執行緒執行的,也就是說,在JavaScript執行一段程式碼塊的時候,頁面中其他的事情(UI更新或者別的指令碼載入執行等)在同一時間段內是被掛起的狀態,不能被同時處理的,所以在執行一段js指令碼的時候,這段程式碼會影響其他的操作。這是JavaScript本身的特性,我們無法改變。

我們把JavaScript的這一特性叫做阻塞特性,正因為這個阻塞特性,讓前端的效能優化尤其是在對JavaScript的效能優化上變得相對複雜。

為什麼要阻塞?

也許你還會問,既然JavaScript的阻塞特性會產生這麼多的問題,為什麼JavaScript語言不能像Java等語言一樣,採用多執行緒,不就OK了麼?

要徹底理解JavaScript的單執行緒設計,其實並不難,簡單總結就是:最初設計JavaScript的目的只是用來在瀏覽器端改善網頁的使用者體驗,去處理一些頁面中類似表單驗證的簡單任務。所以,那個時候JavaScript所做的事情很少,並且程式碼不會太多,這也奠定了JavaScript和介面操作的強關聯性。

既然JavaScript和介面操作強相關,我們不妨這樣理解:試想,如果在某個頁面中有兩段js指令碼都會去更改某一個dom元素的內容,如果JavaScript採用了多執行緒的處理方式,那麼最終頁面元素顯示的內容到底是哪一段js指令碼操作的結果就不確定了,因為兩段js是通過不同執行緒載入的,我們無法預估誰先處理完,這是我們不想要的結果,而這種介面資料更新的操作在JavaScript中比比皆是。因此,我們就不難理解JavaScript單執行緒的設計原因:JavaScript採用單執行緒,是為了避免在執行過程中頁面內容被不可預知的重複修改

關於JavaScript的更多“身世”之謎,可以看阮一峰老師的Javascript誕生記

從載入上優化:合理放置指令碼位置

由於JavaScript的阻塞特性,在每一個<script>出現的時候,無論是內嵌還是外鏈的方式,它都會讓頁面等待指令碼的載入解析和執行,並且<script>標籤可以放在頁面的<head>或者<body>中,因此,如果我們頁面中的css和js的引用順序或者位置不一樣,即使是同樣的程式碼,載入體驗都是不一樣的。舉個例子:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>js引用的位置效能優化</title>
    <script type="text/javascript" src="index-1.js"></script>
    <script type="text/javascript" src="index-2.js"></script>
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>
複製程式碼

以上程式碼是一個簡單的html介面,其中載入了兩個js指令碼檔案和一個css樣式檔案,由於js的阻塞問題,當載入到index-1.js的時候,其後面的內容將會被掛起等待,直到index-1.js載入、執行完畢,才會執行第二個指令碼檔案index-2.js,這個時候頁面又將被掛起等待指令碼的載入和執行完成,一次類推,這樣使用者開啟該介面的時候,介面內容會明顯被延遲,我們就會看到一個空白的頁面閃過,這種體驗是明顯不好的,因此我們應該儘量的讓內容和樣式先展示出來,將js檔案放在<body>最後,以此來優化使用者體驗

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>js引用的位置效能優化</title>
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <div id="app"></div>
    <script type="text/javascript" src="index-1.js"></script>
    <script type="text/javascript" src="index-2.js"></script>
  </body>
</html>
複製程式碼

從請求次數上優化: 減少請求次數

有一點我們需要知道:頁面載入的過程中,最耗時間的不是js本身的載入和執行,相比之下,每一次去後端獲取資源,客戶端與後臺建立連結才是最耗時的,也就是大名鼎鼎的Http三次握手,當然,http請求不是我們這一次討論的主題,想深入瞭解的自行搜尋,網路上相關文章很多。

因此,減少HTTP請求,是我們著重優化的一項,事實上,在頁面中js指令碼檔案載入很很多情況下,它的優化效果是很顯著的。要減少HTTP的請求,就不得不提起檔案的精簡壓縮了。

檔案的精簡與壓縮

要減少訪問請求,則必然會用到js的**精簡(minifucation)和壓縮(compression)**了,需要注意的是,精簡檔案實際並不複雜,但不適當的使用也會導致錯誤或者程式碼無效的問題,因此在實際的使用中,最好在壓縮之前對js進行語法解析,幫我們避免不必要的問題(例如檔案中包含中文等unicode轉碼問題)。

解析型的壓縮工具常用有三:YUI Compressor、Closure Complier、UglifyJs

YUI Compressor: YUI Compressor的出現曾被認為是最受歡迎的基於解析器的壓縮工具,它將去去除程式碼中的註釋和額外的空格並且會用單個或者兩個字元去代替區域性變數以節省更多的位元組。但預設會關閉對可能導致錯誤的替換,例如with或者eval();

Closure Complier: Closure Complier同樣是一個基於解析器的壓縮工具,他會試圖去讓你的程式碼變得儘可能小。它會去除註釋和額外的空格並進行變數替換,而且會分析你的程式碼進行相應的優化,比如他會刪除你定義了但未使用的變數,也會把只使用了一次的變數變成行內函數。

UglifyJs:UglifyJs被認為第一個基於node.js的壓縮工具,它會去除註釋和額外的空格,替換變數名,合併var表示式,也會進行一些其他方式的優化

每種工具都有自己的優勢,比如說YUI壓縮後的程式碼準確無誤,Closure壓縮的程式碼會更小,而UglifyJs不依靠於Java而是基於JavaScript,相比Closure錯誤更少,具體用哪個更好我覺得沒有個確切的答案,開發者應該根據自己專案實際情況酌情選擇。

從載入方式上優化:無阻塞指令碼載入

在JavaScript效能優化上,減少指令碼檔案大小並限制HTTP請求的次數僅僅是讓介面響應迅速的第一步,現在的web應用功能豐富,js指令碼越來越多,光靠精簡原始碼大小和減少次數不總是可行的,即使是一次HTTP請求,但檔案過於龐大,介面也會被鎖死很長一段時間,這明顯不好的,因此,無阻塞載入技術應運而生。

簡單來說,就是頁面在載入完成後才載入js程式碼,也就是在window物件的load事件觸發後才去下載指令碼。 要實現這種方式,常用以下幾種方式:

延遲指令碼載入(defer)

HTML4以後為<script>標籤定義了一個擴充套件屬性:defer。defer屬性的作用是指明要載入的這段指令碼不會修改DOM,因此程式碼是可以安全的去延遲執行的,並且現在主流瀏覽器已經全部對defer支援。

<script type="text/javascript" src="index-1.js" defer></script>
複製程式碼

帶defer屬性的<script>標籤在DOM完成載入之前都不會去執行,無論是內嵌還是外鏈方式。

延遲指令碼載入(async)

HTML5規範中也引入了async屬性,用於非同步載入指令碼,其大致作用和defer是一樣的,都是採用的並行下載,下載過程中不會有阻塞,但不同點在於他們的執行時機,async需要載入完成後就會自動執行程式碼,但是defer需要等待頁面載入完成後才會執行

從載入方式上優化:動態新增指令碼元素

把程式碼以動態的方式新增的好處是:無論這段指令碼是在何時啟動下載,它的下載和執行過程都不會則色頁面的其他程式,我們甚至可以直接新增帶頭部head標籤中,都不會影響其他部分。

因此,作為開發的你肯定見到過諸如此類的程式碼塊:

var script = document.createElement(`script`);
script.type = `text/javascript`;
script.src = `file.js`;
document.getElementsByTagName(`head`)[0].appendChild(script);
複製程式碼

這種方式便是動態建立指令碼的方式,也就是我們現在所說的動態指令碼建立。通過這種方式下載檔案後,程式碼就會自動執行。但是在現代瀏覽器中,這段指令碼會等待所有動態節點載入完成後再執行。這種情況下,為了確保當前程式碼中包含的別的程式碼的介面或者方法能夠被成功呼叫,就必須在別的程式碼載入前完成這段程式碼的準備。解決的具體操作思路是:

現代瀏覽器會在script標籤內容下載完成後接收一個load事件,我們就可以在load事件後再去執行我們想要執行的程式碼載入和執行,在IE中,它會接收loaded和complete事件,理論上是loaded完成後才會有completed,但實踐告訴我們他兩似乎並沒有個先後,甚至有時候只會拿到其中的一個事件,我們可以單獨的封裝一個專門的函式來體現這個功能的實踐性,因此一個統一的寫法是:

 function LoadScript(url, callback) {
        var script = document.createElement(`script`);
        script.type = `text/javascript`;

        // IE瀏覽器下
        if (script.readyState) {
          script.onreadystatechange = function () {
            if (script.readyState == `loaded` || script.readyState == `complete`) {
              // 確保執行兩次
              script.onreadystatechange = null;
              // todo 執行要執行的程式碼
              callback()
            }
          }
        } else {
          script.onload = function () {
            callback();
          }
        }

        script.src = `file.js`;
        document.getElementsByTagName(`head`)[0].appendChild(script);
      }

複製程式碼

LoadScript函式接收兩個引數,分別是要載入的指令碼路徑和載入成功後需要執行的回撥函式,LoadScript函式本身具有特徵檢測功能,根據檢測結果(IE和其他瀏覽器),來決定指令碼處理過程中監聽哪一個事件。

實際上這裡的LoadScript()函式,就是我們所說的LazyLoad.js(懶載入)的原型。

有了這個方法,我們可以實現一個簡單的多檔案按某一固定順序載入程式碼塊:

LoadScript(`file-1.js`, function(){
  LoadScript(`file-2.js`, function(){
    LoadScript(`file-3.js`, function(){
        console.log(`loaded all`)
    })
  })
})
複製程式碼

以上程式碼執行的時候,將會首先載入file-1.js,載入完成後再去載入file-2.js,以此類推。當然這種寫法肯定是有待商榷的(多重回撥巢狀寫法簡直就是地獄),但這種動態指令碼新增的思想,和載入過程中需要注意的和避免的問題,都在LoadScript函式中得以澄清解決。

當然,如果檔案過多,並且載入的順序有要求,最好的解決方法還是建議按照正確的順序合併一起載入,這從各方面講都是更好的法子。

從載入方式上優化:XMLHttpRequest指令碼注入

通過XMLHttpRequest物件來獲取指令碼並注入到頁面也是實現無阻塞載入的另一種方式,這個我覺得不難理解,這其實和動態新增指令碼的方式是一樣的思想,來看具體程式碼:

var xhr = new XMLHttpRequest();
xhr.open(`get`, `file-1.js`, true);
xhr.onreadystatechange = function() {
  if(xhr.readyState === 4) {
    if(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304){
      // 如果從後臺或者快取中拿到資料,則新增到script中並載入執行。
      var script = document.createElement(`script`);
      script.type = `text/javascript`;
      script.text = xhr.responseText;
      // 將建立的script新增到文件頁面
      document.body.appendChild(script);
    }
  }
}

複製程式碼

通過這種方式拿到的資料有兩個優點:其一,我們可以控制指令碼是否要立即執行,因為我們知道新建立的script標籤只要新增到文件介面中它就會立即執行,因此,在新增到文件介面之前,也就是在appendChild()之前,我們可以根據自己實際的業務邏輯去實現需求,到了想要讓它執行的時候,再appendChild()即可。其二:它的相容性很好,所有主流瀏覽器都支援,它不需要想動態新增指令碼的方式那樣,我們自己去寫特性檢測程式碼;

但由於是使用了XHR物件,所以不足之處是獲取這種資源有“域”的限制。資源 必須在同一個域下才可以,不可以跨域操作。

最後總結

文章主要從JavaScript的載入和執行這一過程中挖掘探討對前端優化的解決方案,並較細緻的羅列了各個解決方案的優勢和不足之處,當然,前端效能優化本就相對複雜,要想徹底理解其各中原由,還有很長一段路要走!

本文主要行文思路:

  • 不得不說的JavaScript阻塞特性

  • 合理放置指令碼位置,以優化載入體驗,js指令碼放在 <body>標籤閉合之前。

  • 減少HTTP請求,壓縮精簡指令碼程式碼。

  • 無阻塞載入JavaScript指令碼:

    • 使用<script>標籤的defer屬性。

    • 使用HTML5的async屬性。

    • 動態建立<script>元素載入JavaScript。

    • 使用XHR物件載入JavaScript。

最後,由於個人水平原因,若有行文不全或疏漏錯誤之處,懇請各位讀者批評指正,一路有你,不勝感激!。

感謝這個時代,讓我們可以站在巨人的肩膀上,窺探程式世界的巨集偉壯觀,我願以一顆赤子心,踏遍程式世界的千山萬水!願每一個行走在程式世界的同仁,都活成心中想要的樣子,加油!

相關文章