1. 程式人生 > >探真無阻塞載入javascript指令碼技術,我們會發現很多意想不到的祕密

探真無阻塞載入javascript指令碼技術,我們會發現很多意想不到的祕密

  下面的圖片是我使用firefox和chrome瀏覽百度首頁時候記錄的http請求

下面是firefox:

下面是chrome:

  在瀏覽百度首頁前我都將瀏覽器的快取全部清理掉,讓這個場景最接近第一次訪問百度首頁的情景。

  在firefox的請求瀑布圖裡有個表現非常之明顯:就是javascript檔案下載完畢後,有一段時間是沒有網路請求被處理的,這段時間過後http請求才會接著執行,這段空閒時間就是所謂的http請求被阻塞。

  瀏覽器裡的http請求被阻塞一般都是由javascript所引起,具體原因是javascript下載完畢之後會立即執行,而javascript執行時候會阻塞瀏覽器的其他行為,例如阻塞其他javascript的執行以及其他的http請求的執行。這樣會導致頁面載入變慢,如果這個變慢很明顯,此時使用者操作網頁會發現頁面沒有反應會反應很慢,慢是網站使用者體驗的夢魘。

  我目前開發的一些系統,在開發環境裡經常碰到javascript阻塞頁面載入的問題,主要原因是我們網站很多靜態資源和指令碼都被獨立抽取在了一臺單獨的靜態資源伺服器上,而本地的開發環境模擬的靜態資源服務環境常常很不穩定(經常宕機),有時一些新建的指令碼沒有及時更新到開發環境上,因此某些js指令碼檔案無法正確獲取,這些問題導致頁面載入時候這些js指令碼就會阻塞頁面的載入,此時瀏覽器會反覆嘗試請求這些js檔案,直到請求超時才會認定該指令碼的url無效,如果中途你無法忍受這種等待,強制關閉瀏覽器的請求,會發現在瀏覽器控制檯裡很多指令碼都無法找到,這樣你就無法在控制檯裡設定js程式碼斷點除錯js,如果等待js載入完畢,時間又會被浪費,無奈之下只要找到那些無效的url將其註釋掉,哎,結果好幾次都將有註釋的錯誤程式碼提交到了svn伺服器上,這些事情真是很惱人。

  不管那種瀏覽器,也不管是新版本還是老版本的瀏覽器,它們都秉持瀏覽器的單執行緒特性,這個特性似乎是一個很難撼動的準則,當我們在瀏覽器的位址列裡輸入一個url地址,訪問一個新頁面時候,頁面展示的快慢就是由一個單執行緒所控制,這個執行緒叫做UI執行緒,UI執行緒會根據頁面裡資源(資源是指css檔案,圖片等等)書寫的先後順序來載入資源,載入資源也就是使用http請求獲取資源,像css外部檔案,html檔案以及圖片等資源http請求處理完畢也就意味著資源載入結束,但是像外部的javascript檔案則不同,它的載入過程被分為兩步,第一步和載入css檔案和圖片一樣,就是執行一個http請求下載外部的js檔案,但是javascript完成http操作後並不意味操作完畢,UI執行緒接著會執行它,如果你開發的頁面裡js程式碼執行時間過長,那麼使用者就會明顯感覺到頁面的延遲。為什麼瀏覽器不能把javascript程式碼的載入過程拆分為下載和執行兩個並行的過程,這樣就可以充分利用時間完成http請求,這樣不是就能提升頁面的載入效率了嗎?這個問題的答案當然是否定的,javascript是一個程式語言,js程式碼是有智力的,它除了可以完成邏輯性的工作,還可以通過操作頁面元素來改變頁面UI效果,如果我們忽略javascript對UI的影響,讓它延遲執行,結果會造成頁面展示的混亂。那麼他會產生什麼樣的混亂呢?這個混亂的描述如下:

  最簡單最好理解和最好掌握的思路是線性思路,對於瀏覽器UI顯示要按線性思路理解即放在頁面前部的資源會優先載入和執行,瀏覽器還會認為前一步的內容都可能會是後一步頁面展示前提,如果瀏覽器擅自停止中間某個程式碼的執行,很有可能頁面最終呈現的效果和設計者設計的不同,這樣我們就無法開發出正確的頁面。而且按線性思路當你碰到頁面UI效果出問題時候,你很容易定位問題所在,如果我們將js程式碼的載入和執行分隔開來,這就好比把線性思路變成了樹狀結構,那麼你掌握頁面載入的思路和解決UI載入問題時候就會變得更加困難,到時很多人都會抓狂和思路混亂,所以我在這裡說混亂。

  綜上所述,js指令碼下載和執行是一個完整的操作,是絕對不能被割裂的。

  瀏覽器為了提升使用者體驗,加快UI執行緒的執行是一個無法迴避的問題,看來拆分js的下載和執行是不可行的,如是乎瀏覽器換了種方式,這個方式也就是在同一個時間能下載多個資源,我們再看上圖,在同一個域名下,firefox可以同時下載兩種圖片,chrome可以同時下載4個靜態資源,不過這是針對圖片和css檔案,對於js檔案似乎還是一個接著一個的下載,下載一個執行一個,不過在ie8以上版本,js可以和圖片一樣並行載入,ie這樣做就提升了js檔案下載的效率,不過到了js執行時候還是要嚴格按照順序執行。

  多個http連線並行下載資源就好比多個執行緒共同完成某個任務,如果並行http連線更多,那麼能有更多http資源同時被下載,但是瀏覽器提供並行執行的http連線實在太少了,例如上面firefox才兩個,chrome也只有4個,那如何突破瀏覽器的連線個數的限制了?方法很簡單就是將常用的,穩定的靜態資源統一放在一個靜態資源伺服器上,由統一的域名對外提供,這個域名要和主體請求的域名不一樣,原理是因為瀏覽器只通過域名來限制連線的個數,如果一個頁面裡有兩個不同的域的,那麼並行的http請求個數也會變成兩倍。這個做法有點討瀏覽器的巧,是程式設計師發現瀏覽器的特點而總結出的技術,它類似一個hack技術,而hack技術不會是標準技術,所以它肯定有它的瓶頸區,所以這樣的技術都是會有個度的,瀏覽器限制請求個數絕對不是無緣無故的,我們看百度頁面並行下載圖片的http協議的版本都是1.1,http1.1特點就是長連線,長連線的好處是在頁面和服務端頻繁互動時候效率很好,但是瀏覽器的頁面操作並不是總是頻繁的請求伺服器,而為了載入靜態資源而建立很多長連線,伺服器會不得不維護大量無用的長連線,給伺服器的壓力是可想而知的。相比之下http1.0協議,它不使用長連線,而是短連線,因此早期版本的瀏覽器會給http1.0協議開啟的連線數要高於http1.1連線數,因此有些網站將靜態資源伺服器提供的http協議版本降低到1.0,這樣可以有效的提升瀏覽器的併發連線數,這個做法會給網頁效能帶來意想不到的提升,不過現代的瀏覽器似乎更願意平等對待兩個不同版本的協議了,新版的瀏覽器有些將兩類協議的併發數變成一樣了。而對於處於客戶端地位的瀏覽器維護多個連結對於資源消耗是龐大的,而且域名過多也會增加dns解析的開銷,所以併發連線開啟越多,並不一定真的會達到提升效能的目的,那麼多少個域最合適了?雅虎的前端工程師給了一個答案:2個是最佳的,這個資料怎麼得來的我就不太清楚了,不過結果很簡單很好用,記住就行了。

  下面我就要聊聊如何解決js阻塞頁面載入的問題了,js之所以會阻塞UI執行緒的執行,是因為js能控制UI的展示,而頁面載入的規則是要順序執行,所以在碰到js程式碼時候UI執行緒會首先執行它,而這點很多程式設計師不知道或者知道但被忽視,因此導致編寫程式碼時候將用於展示的程式碼和用於處理邏輯的程式碼混淆在一起,這樣做的後果是使js程式碼造成的阻塞更加嚴重,所以雅虎公司的前端團隊提出了一個前端優化的規則:將js指令碼放置到html文件的末尾,這樣就能有效的避免UI的阻塞。但是這個方法太簡單了,不利於我們對網站進行更加深入的優化。為了做的深入,我們要需要更進一步分析,首先我們知道js指令碼按出現的位置分為兩類一類是行內指令碼即寫在頁面裡的指令碼,一類是js的外部檔案,行內指令碼的優化比較簡單,就是儘量在頁面寫更少的程式碼,就算要寫程式碼也主要是控制頁面載入的UI顯示的程式碼,沒必要的程式碼就放在外部的js檔案裡,js外部檔案優化就比較複雜,為了精簡行內指令碼,我們就不得不將大量的js程式碼放到外部檔案裡,早先時候我都會盡量將所有外部js檔案合併成一個js檔案,但是現在我發現一個複雜的外部指令碼很有可能會讓頁面的阻塞情況變得更加的嚴重,因此外部指令碼要根據其功能拆分為展示指令碼和邏輯指令碼,但是事實上展示程式碼和邏輯程式碼很難分離,其實有個更加簡單的標準讓我們拆分程式碼:將所有外部程式碼分為UI初始化程式碼和其他程式碼,,UI初始化程式碼是在頁面載入時候執行的程式碼,我們現在只要判斷哪些程式碼在頁面載入時候執行就行了,這個標準就容易多了。

  另外,上文我提到過我碰到頁面被js阻塞的情況,有時我為了除錯js程式碼會一直等待瀏覽器判斷無效的js載入失敗,那麼我是怎麼判斷瀏覽器已經判斷外部js載入無效了?很簡單就是檢視瀏覽器忙指示結束,瀏覽器的忙指示如下圖所示:

  忙指示(忙指示現象包括:瀏覽器選項卡的旋轉圓圈,滑鼠變成漏斗滑鼠,瀏覽器左下的狀態列顯示正在載入某某url以及老版的ie顯示頁面載入進度的現象)標記結束了,就表明頁面的載入操作結束了,為了防止js指令碼阻塞頁面載入,那麼我們要做到的就是讓那些不會用於頁面初始化展示的js程式碼的載入和執行操作在瀏覽器忙指示結束後觸發,為了做到這一點我們就得知道忙指示結束後會觸發什麼命令,這個命令就是瀏覽器的onload事件,因此我們讓那些和頁面載入無關的js指令碼在onload方法裡執行,在onload事件裡我就會使用dom技術,構建script節點,設定它的src指向需要載入的指令碼路徑,然後將這個srcipt節點加入到html文件的head裡,為了完全確保這個js在忙指示結束後執行,我使用setTimeout方法呼叫動態載入指令碼的方法,進一步確保程式碼在忙指示結束後執行,實踐下來感覺效果的確不錯。

  理解到這裡我本來很高興,認為自己又理解了一個前端開發的難點,並且有一個好的解決方案,但是等我回味一下發現有點不對頭,我經常使用的jQuery定義了ready方法,ready方法會在dom載入完畢後執行,而我自己的方案卻是在onload後執行,程式碼執行遠遠落後jQuery的ready方法執行時機,這個感覺讓我很不舒服,其次,在頁面開發裡我們會使用很多第三方庫,雖然我現在開發儘量做到只用jQuery這一個第三方庫,但是其他人則不盡然,他們會使用很多第三方庫,很多庫有大量UI操作的通用方法,這些方法非常好用,經常使用這些庫會導致我們自己寫的控制UI的js程式碼常常會依賴它們,那麼拆分UI控制指令碼和其他指令碼就無從談起了。總之,現在web前端開發太依賴第三方庫,就算一個牛逼的前端團隊,拒絕使用第三方庫,什麼都自己開發,當web應用變複雜後,通用庫和業務程式碼的耦合度都是很難解決的問題,這也會導致我們沒法真正將UI展示程式碼和邏輯程式碼真正的分離。

  我的方案其實滿足不了實際生產的需求,不夠完美,所以本文到這裡沒有推匯出通用規則,真令人失望,面對上面的新問題那我們該怎麼辦了?這個問題不是無解的,現行技術就有它的解決方案,那就是無阻塞指令碼的載入。無阻塞載入指令碼技術的核心就是:載入js指令碼時候,被載入的js指令碼不會阻塞UI執行緒的執行和以阻塞方式載入的指令碼。

  下面是無阻塞載入指令碼的技術方案:

  XHR Eval

  顧名思義,通過XHR讀取指令碼,通過Eval令指令碼生效。

  程式碼如下:

var xhrObj = new XMLHttpRequest();
xhrObj.onreadystatechange = function(){
    if(xhrObj.readyState == 4 && 200 == xhrObj.status){
        eval(xhrObj.responseText);
    }
};

xhrObj.open("GET", "A.js", true);
xhrObj.send("");

  由於XMLHttpRequest本身不能跨域,所以該方法不能跨域。

  XHR Injection

  使用動態建立script元素,來寫入指令碼,在某些情況下可能比上一種方法要快些。

  程式碼如下:

var xhrObj = new XMLHttpRequest();
xhrObj.onreadystatechange = function(){
    if(xhrObj.readyState == 4){
        var scriptElem = document.createElement("script");
        document.getElementsByTagName("head")[0].appendChild(scriptElem);
        scriptElem.text = xhrObj.responseText;
    }
};
xhrObj.open("GET", "A.js", true);
xhrObj.send("");

  Script in Iframe

  由於Iframe是開銷最高的DOM元素,這種方法還是儘量避免使用,而且這種方法也無法實現跨域。

  Script DOM Element

  可跨域方案,利用動態插入script元素來讓指令碼讀取、生效。

  程式碼如下:

var scriptElem = document.createElement("script");
scriptElem.src = "http://anydomain.com/A.js";
document.getElementByTagName("head")[0].appendChild(scriptElem);

  Script Defer

  原生方案。利用defer屬性來防止指令碼阻塞。

  程式碼如下:

<script defer src="A.js"></script>

  不過許多瀏覽器不支援該屬性。

  document.write Script Tag

  動態寫指令碼的另一種方案,不過只在IE中是並行下載的。

  程式碼如下:

document.write("<script type='text/javascript' src='A.js'></script>");

  script defer和document.write Srcipt Tag不是跨瀏覽器的方案這裡不推薦。
  頁面巢狀iframe方案我沒有詳述,原因是我現在很討厭iframe,iframe是dom元素裡開銷最大的元素,有它就意味著慢,而且我最近碰到一個生產問題就是iframe引起,原因就是對iframe跨域造成,iframe跨域以後,父窗體和子窗體程式碼就不能互訪了,而且iframe寫法的不正確(寫的很類似跨站指令碼挾持)還會導致瀏覽器啟動預設的安全機制,最終出現使用者無法正常使用頁面的情況,所以我也不推薦使用iframe。
  xhr eval也是我不會去使用的方式,因為它用eval命令,而eval的使用常常會為黑客留下破壞你網站的漏洞。

  因此最好的方案就是xhr 注入和script dom element了,這兩個方案不存在瀏覽器相容問題,而且後者還能跨域,不過跨域的選擇也是要謹慎的,跨域指令碼也會帶來隱形的安全風險,不管怎麼說這兩個方案使用場景基本上可以包括所有阻塞指令碼載入的場景。

  注意:無阻塞載入指令碼的核心技術就是動態的建立script的dom節點。

  無阻塞指令碼載入技術還有個好處就是,那些和頁面展示無關的指令碼無須非要放在onload事件裡執行,它隨時隨地可以執行簡直就是完美。

  不過無阻塞指令碼有個很大的隱患,這個隱患是很多會使用無阻塞指令碼技術的程式設計師都會忽視的問題,這個問題就是無阻塞指令碼很容易產生“變數未定義”的問題,這個問題的本質就是無阻塞指令碼會破壞js指令碼載入順序的問題,當某個指令碼依賴於另一個指令碼時候,而另一個指令碼又沒有載入執行完畢,最後就會產生“變數未定義”的問題,例如jQuery沒有提前載入,因此使用$時候提示$變數未定義。


  那麼我們該如何解決這個問題了,我們的思路就是讓那些依賴於無阻塞載入的指令碼的js程式碼在指令碼載入完畢後才會執行,我們需要一個辦法將無序的指令碼載入變得有序,上面我推薦的兩種方法都是使用dom技術建立script節點,然後將該節點加入到文件的head頭部,對於script節點,在非ie瀏覽器下有一個onload事件,該事件會在script載入完畢後才會執行,ie瀏覽器下有onreadystatechange事件,而ie下script的dom節點有一個readystate屬性,它的取值如下:
  1.uninitialized(未初始化):物件存在尚未初始化;
  2.loading(正在載入):物件正在載入資料;
  3.loaded(載入完畢):物件資料載入完成
  4.interactive(互動):可以操作物件,但是還沒有完全載入;
  5.complete(完成):物件已經載入完畢。
  具體用法如下所示:

scriptNode.onreadystatechange = function(){
	if (scriptNode.readystate == 'complete'){// todo......}
}

  這個做法就是為dom載入定義了一個回撥函式,當dom載入完畢後回撥函式才會執行,這樣就解決了程式碼執行順序的問題了。

  另外還有一個方式就是使用setTimeout,具體使用就是定義一個輪詢,判斷需要使用的變數是否存在,如果不存在,就繼續輪詢,如果變數存在則停止輪詢,程式碼模式如下所示:

  程式碼如下:

function lunxun(){
	if ("undefined" == typeof(XXXX)){
		setTimeout(lunxun,300);
	}else{
		ftn();
	}
}
lunxun();

  無阻塞指令碼的好處就是不會阻塞UI的執行,也不會影響其他同步js程式碼的執行,不過無阻塞指令碼改變了指令碼的載入順序,所以在使用無阻塞指令碼時候一定要更加註意指令碼之間的依賴關係,保證整個頁面的指令碼都能正常執行。

  在以前的文章裡我多次提到了js的模組載入技術,時下流行的模組載入技術有進口貨requirejs和國產貨seajs,使用這些技術,我們會發現js檔案載入都是按模組載入的,也就是說你頁面定義了多少個js模組,那麼這個頁面就有多少個js檔案,剛開始使用它們時候我很詫異,按照前端優化原則http請求越少越好,為什麼先進的模組技術卻會讓js檔案變得更多了,接著我分析了下它們載入js的請求,終於明白了,它們都使用的無阻塞指令碼載入技術,即使用script節點方式載入指令碼,這樣就很容易遮蔽js帶來的阻塞問題了。

  上面的例項中我使用script節點將指令碼都是嵌入到head節點裡,這個似乎和將指令碼置於html文件末尾的原則不同,這個是不是需要改進了,答案是不需要改進,將指令碼置於文件末尾目的是為了避免js的阻塞,而我們使用無阻塞指令碼了,這個問題不是解決了嗎?所以程式碼置於head標籤還是html文件底部也就無關緊要了。

  最後我要糾正一個錯誤的觀點,頁面載入的總時間是衡量頁面載入快捷的標準嗎?答案是,的確是個標準,但是不是最精確的標準,頁面同步阻塞載入的時間才是衡量頁面載入效率的準確標準,非阻塞指令碼載入可能會增加整個頁面載入的時間,但是它可以減少頁面阻塞載入的時間,而頁面阻塞才是影響使用者體驗的元凶,頁面優化最重要的關注點就是你所看到的的東西要載入的更加快。

   無阻塞指令碼可以分割外部指令碼的下載和執行操作,這是程式設計師使用的hack技術,它很酷,但是會導致程式的複雜度增加,可讀性下降,所以它應該是web前端架構師的技術,日常開發我們要慎用它。