前端效能優化之載入技術

scq000發表於2017-09-12

在這個前端使用者體驗越來越重要的時代,你的頁面稍微有點卡頓,都難以挽留使用者。而作為一名有追求的前端,勢必要力所能及地優化我們前端頁面的效能。今天,就來談一談那些前端效能優化的載入技術,利用這些技術可以很好地提高網站的響應速度和使用者體驗。

頁面渲染

在理解真正的優化技術之前,我們需要先了解為什麼需要優化?這得從瀏覽器的渲染引擎談起。瀏覽器從獲取HTML文件開始,就進入了渲染引擎的工作階段,其目的是將網頁的內容顯示在瀏覽器螢幕上。大體可以描述為從解析HTML內容,構造DOM節點再到DOM元素佈局定位最後再繪製DOM元素的這樣一個過程。更加詳細的內容可以參考How browser works, 要看中文的童鞋可以看這篇譯文

在頁面渲染的這樣一個過程中,有一個關鍵點是如果在解析內容的過程中遇到了指令碼標籤,如:<script src="example.js"></script>,瀏覽器就會暫停內容的解析,轉而開始下載指令碼。並且只有等指令碼下載完並執行結束後,渲染引擎才會繼續解析。那麼這樣一來,頁面顯示的時間必然會被延長。因此我們需要優化的點就是儘可能地讓頁面更早地被渲染出來。

指令碼載入的優化

要解決上面說到的指令碼載入問題,通常有三種解決方案:將指令碼放在HTML末尾、動態載入指令碼以及非同步載入指令碼。最常用的應該就是將所有指令碼放置在HTML文件的末尾了。這應該是每個前端剛入門時,被教的最多的。對於這個方法,這裡就不多做介紹,直接上重頭戲。

動態載入

所謂動態載入指令碼就是利用javascript程式碼來載入指令碼,通常是手工建立script元素,然後等到HTML文件解析完畢後插入到文件中去。這樣就可以很好地控制指令碼載入的時機,從而避免阻塞問題。

function loadJS(src) {
  const script = document.createElement('script');
  script.src = src;
  document.getElementsByTagName('head')[0].appendChild(script);
}
loadJS('http://example.com/scq000.js');複製程式碼

非同步載入

我們都知道,在計算機程式中同步的模式會產生阻塞問題。所以為了解決同步解析指令碼會阻塞瀏覽器渲染的問題,採用非同步載入指令碼就成為了一種好的選擇。利用指令碼的async和defer屬性就可以實現這種需求:

<script type="text/javascript" src="./a.js" async></script>
<script type="text/javascript" src="./b.js" defer></script>複製程式碼

雖然利用了這兩個屬性的script標籤都可以實現非同步載入,同時不阻塞指令碼解析。但是使用async屬性的指令碼執行順序是不能得到保證的。而使用defer屬性的指令碼執行順序可以得到保證。另一方面,defer屬性是在html文件解析完成後,DOMContentLoaded事件之前就會執行js。async一旦載入完js後就會馬上執行,最遲不超過window.onload事件。所以,如果指令碼沒有操作DOM等元素,或者與DOM時候載入完成無關,直接使用async指令碼就好。如果需要DOM,就只能使用defer了。

這裡介紹的兩種方法在實際運用過程中需要權衡一下的,渲染速度變快也就意味著指令碼載入時間會變長。

解決非同步載入指令碼的問題

上面介紹的非同步載入指令碼並不是十分完美的。如何處理載入過程中這些指令碼的互相依賴關係,就成了實現非同步載入過程中所需要考慮的問題。一方面,對於頁面中那些獨立的指令碼,如使用者統計等外掛就可以放心大膽地使用非同步載入。而另一方面,對於那些確實需要處理依賴關係的指令碼,業界已經有很成熟的解決方案了。如採用AMD規範的RequireJS,甚至有采用了hack技術(通過欺騙瀏覽器下載但不執行指令碼)的labjs(已過時)。如果你熟悉promise的話,就知道這是在JS中處理非同步的一種強有力的工具。下面以promise技術來實現處理非同步指令碼載入過程中de的依賴問題:

// 執行指令碼
function exec(src) {
    const script = document.createElement('script');
    script.src = src;

      // 返回一個獨立的promise
    return new Promise((resolve, reject) => {
        var done = false;

        script.onload = script.onreadystatechange = () => {
            if (!done && (!script.readyState || script.readyState === "loaded" || script.readyState === "complete")) {
              done = true;

              // 避免記憶體洩漏
              script.onload = script.onreadystatechange = null;
              resolve(script);
            }
        }

        script.onerror = reject;
        document.getElementsByTagName('head')[0].appendChild(script);
    });
}

function asyncLoadJS(dependencies) {
    return Promise.all(dependencies.map(exec));
}

asyncLoadJS(['https://code.jquery.com/jquery-2.2.1.js', 'https://cdn.bootcss.com/bootstrap/3.3.7/js/bootstrap.min.js']).then(() => console.log('all done'));複製程式碼

可以看到,我們針對每個指令碼依賴都會建立一個promise物件來管理其狀態。採用動態插入指令碼的方式來管理指令碼,然後利用指令碼onload和onreadystatechange(相容性處理)事件來監聽指令碼是否載入完成。一旦載入完畢,就會觸發promise的resovle方法。最後,針對依賴的處理,是promise的all方法,這個方法只有在所有promise物件都resolved的時候才會觸發resolve方法,這樣一來,我們就可以確保在執行回撥之前,所有依賴的指令碼都已經載入並執行完畢。

懶載入(lazyload)

懶載入是一種按需載入的方式,也通常被稱為延遲載入。主要思想是通過延遲相關資源的載入,從而提高頁面的載入和響應速度。在這裡主要介紹兩種實現懶載入的技術:虛擬代理技術以及惰性初始化技術。

虛擬代理載入

所謂虛擬代理載入,即為真正載入的物件事先提供一個代理或者說佔位符。最常見的場景是在圖片的懶載入中,先用一種loading的圖片佔位,然後再用非同步的方式載入圖片。等真正圖片載入完成後就填充進圖片節點中去。

// 頁面中的圖片url事先先存在其data-src屬性上
const lazyLoadImg = function() {
  const images = document.getElementsByTagName('img');
  for(let i = 0; i < images.length; i++) {
      if(images[i].getAttribute('data-src')) {
          images[i].setAttribute('src', images[i].getAttribute('data-src'));
          images[i].onload = () => images[i].removeAttribute('data-src');
      }
  }
}複製程式碼

惰性初始化

惰性初始模式是在程式設計過程中常用的一種設計模式。顧名思義,這個模式就是一種將程式碼初始化的時機推遲(特別是那些初始化消耗較大的資源),從而來提升效能的技術。

jQuery中大名鼎鼎的ready方法就用到了這項技術,其目的是為了在頁面DOM元素載入完成後就可以做相應的操作,而不需要等待所有資源載入完畢後。與瀏覽器中原生的onload事件相比,可以更加提前地介入對DOM的干涉。當頁面中包含大量圖片等資源時,這個方法就顯出它的好處了。在jQuery內部的實現原理上,它會設定一個標誌位來判斷頁面是否載入完畢,如果沒有載入完成,會將要執行的函式快取起來。當頁面載入完畢後,再一一執行。這樣一來,就將原本應該馬上執行的程式碼,延遲到頁面載入完畢後再執行。感興趣的可以去閱讀這一部分的原始碼,裡面還包括了瀏覽器相容等處理。

選擇時機

選擇時機:比較常見的兩種

  1. 滾動條監聽
  1. 事件回撥(需要使用者互動的地方)

當然,你也可以根據具體的業務場景選擇延遲載入的時機。

滾動條監聽

滾動條監聽,常常用在大型圖片流等場景下。通過對使用者滾動結束的區域進行計算,從而只載入目標區域中的資源。這樣就可以實現節流的目的。


// 簡單的節流函式
function throttle(func, wait, mustRun) {
    var timeout,
        startTime = new Date();

    return function() {
        var context = this,
            args = arguments,
            curTime = new Date();

        clearTimeout(timeout);
        // 如果達到了規定的觸發時間間隔,觸發 handler
        if(curTime - startTime >= mustRun){
            func.apply(context,args);
            startTime = curTime;
        // 沒達到觸發間隔,重新設定定時器
        }else{
            timeout = setTimeout(func, wait);
        }
    };
};

// 判斷元素是否在可視範圍內
function elementInViewport(element) {
    const rect = element.getBoundingClientRect();
    return (rect.top >= 0 && rect.left >= 0 && rect.top <= (window.innerHeight || document.documentElement.clientHeight));
}

function lazyLoadImgs() {
    const count = 0;
      return function() {
          [].slice.call(images, count).forEach(image => {
              if(elementInViewport(elementInViewport(image))) {
                image.setAttribute('src', image.getAttribute('data-src'));
                  count++;
              }
          });
    }
}

const images = document.getElementByTagName('img');
// 採用了節流函式, 載入圖片
window.addEventListener('scroll',throttle(lazyLoadImgs(images),500,1000));複製程式碼

事件回撥

這種場景就是那些需要使用者互動的地方,如點選載入更多之類的。這些資源往往通過在使用者互動的瞬間(如點選一個觸發按鈕),發起ajax請求來獲取資源。比較簡單,在此不再贅述。

利用webpack實現指令碼載入優化

現如今,對於大型專案大家都會用上打包工具。現代化的工具使得我們不必再寫那些又長又難懂的程式碼。針對懶載入,webpack也提供了十分友好的支援。這裡主要介紹兩種方式。

import()方法

我們知道,在原生es6的語法中,提供了import和export的方式來管理模組。而其import關鍵字是被設定成靜態的,因此不支援動態繫結。不過在es6的stage 3規範中,引入了一個新的方法import()使得動態載入模組成為可能。所以,你可以在專案中使用這樣的程式碼:

$('#button').click(function() {
  import('./dialog.js')
    .then(dialog => {
        //do something
    })
    .catch(err => {
        console.log('模組載入錯誤');
    });
});

//或者更優雅的寫法
$('#button').click(async function() {
    const dialog = await import('./dialog.js');
  //do something with dialog

});複製程式碼

由於該語法是基於promise的,所以如果需要相容舊瀏覽器,請確保在專案中使用es6-promise或者promise-polyfill。同時,如果使用的是babel,需要新增syntax-dynamic-import外掛。

require.ensure

require.ensure與import()類似,同樣也是基於promise的非同步載入模組的一種方法。這是在webpack 1.x時代官方提供的懶載入方案。現在,已經被import()語法取代了。為了文章的完整性,這裡也做一些介紹。

在webpack編譯過程中,會靜態地解析require.ensure中的模組,並將其新增到一個單獨的chunk中,從而實現程式碼的按需載入。

語法如下:

require.ensure(dependencies: String[], callback: function(require), errorCallback: function(error), chunkName: String)複製程式碼

一個十分常見的例子是在寫單頁面應用的時候,使用該技術實現基於不同路由的按需載入:

const routes = [
    {path: '/comment', component: r => require.ensure([], r(require('./Comment')), 'comment')}
];複製程式碼

預載入

首屏載入的問題解決後,使用者在具體的頁面使用過程中的體驗也很重要。如果能夠通過預判使用者的行為,提前載入所需要的資源,則可以快速地響應使用者的操作,從而打造更加良好的使用者體驗。另一方面,通過提前發起網路請求,也可以減少由於網路過慢導致的使用者等待時間。因此,“預載入”的技術就閃亮登場了。

preload規範

preload 是w3c新出的一個標準。利用link的rel屬性來宣告相關“proload",從而實現預載入的目的。就像這樣:

<link rel="preload" href="example.js" as="script">複製程式碼

其中rel屬性是用來告知瀏覽器啟用preload功能,而as屬性是用來明確需要預載入資源的型別,這個資源型別不僅僅包括js指令碼(script),還可以是圖片(image),css(style),視訊(media)等等。瀏覽器檢測到這個屬性後,就會預先載入資源。

這個規範目前相容性方面還不是很好,所以可以先稍微瞭解一下。webpack現在也已經有相關的外掛,如果感興趣的話,請移步preload-webpack-plugin。對於更加詳細的技術細節,這裡推薦一篇部落格www.smashingmagazine.com/2016/02/pre…

DNS Prefetch 預解析

還有一個可以優化網頁速度的方式是利用dns的預解析技術。同preload類似,DNS Prefetch在網路層面上優化了資源載入的速度。我們知道,針對DNS的前端優化,主要分為減少DNS的請求次數,還有就是進行DNS預先獲取。DNS prefetch就是為了實現這後者。其用法也很簡單,只要在link標籤上加上對應的屬性就行了。

<meta http-equiv="x-dns-prefetch-control" content="on" /> /* 這是用來告知瀏覽器當前頁面要做DNS預解析 */
<link rel="dns-prefetch" href="//example.com">複製程式碼

在支援該標準的瀏覽器上,會自動對連結中的地址域名做DNS解析快取。不過,像Goolge、火狐這樣的現代瀏覽器即使不設定這個屬性,也能在後臺做自動預解析。如果你的頁面中需要大量訪問不同域名的資源,可以利用這項技術加快資源的獲取,從而獲得更好的使用者體驗。需要注意的是,DNS預解析雖好,但是也不能濫用。如果對多頁面重複DNS預解析,會增加DNS的查詢次數。

總結

通常對於大型應用來說,完整載入所有javascript程式碼是十分耗時的工作。因此,通常會將JavaScript分為兩個部分(一部分是渲染初始化頁面所必須的,另一部分則是剩下的指令碼)來進行載入。這樣就可以儘可能快速地渲染出網頁。通過監聽onload事件,可以很好地控制回撥的時機,同時採用非同步載入等技術能夠同時並行載入多個指令碼,從而大大提高最終頁面的渲染速度。最好是把在onload事件之前執行的程式碼拆分成一個單獨的檔案。當然,在處理指令碼載入這一過程中還存在著幾個問題:1.如何找到需要拆分的程式碼? 2 怎樣處理競爭狀態 ?3.如何延遲載入其餘部分的程式碼?希望這篇文章能夠給你啟發!對於文中有錯漏之處,歡迎指出。鑑於本人水平有限,也歡迎大家來多多交流。

參考資料

《Javascript效能優化》

bubkoo.com/2015/11/19/…

2ality.com/2017/01/imp…

segmentfault.com/a/119000000…

perishablepress.com/3-ways-prel…

www.youtube.com/watch?v=wKC…

相關文章