瀏覽器的載入與頁面效能優化

shupan001發表於2011-10-24

http://www.baiduux.com/blog/2011/02/15/browser-loading/


本文將探討瀏覽器渲染的loading過程,主要有2個目的:

  • 瞭解瀏覽器在loading過程中的實現細節,具體都做了什麼
  • 研究如何根據瀏覽器的實現原理進行優化,提升頁面響應速度

由於loading和parsing是相互交織、錯綜複雜的,這裡面有大量的知識點,為了避免過於發散本文將不會對每個細節都深入研究,而是將重點放在開發中容易控制的部分(Web前端和Web Server),同時由於瀏覽器種類繁多且不同版本間差距很大,本文將側重一些較新的瀏覽器特性

現有知識

提升頁面效能方面已經有很多前人的優秀經驗了,如Best Practices for Speeding Up Your Web SiteWeb Performance Best Practices

本文主要專注其中載入部分的優化,總結起來主要有以下幾點:

  • 頻寬
    • 使用CDN
    • 壓縮js、css,圖片優化
  • HTTP優化
    • 減少轉向
    • 減少請求數
    • 快取
    • 儘早Flush
    • 使用gzip
    • 減少cookie
    • 使用GET
  • DNS優化
    • 減少域名解析時間
    • 增多域名提高併發
  • JavaScript
    • 放頁面底部
    • defer/async
  • CSS
    • 放頁面頭部
    • 避免@import
  • 其它
    • 預載入

接下來就從瀏覽器各個部分的實現來梳理效能優化方法

network

首先是網路層部分,這方面的實現大部分是通過呼叫作業系統或gui框架提供的api

DNS

為了應對DNS查詢的延遲問題,一些新的瀏覽器會快取或預解析DNS,如當Chrome訪問google頁面的搜尋結果時,它會取出連結中的域名進行預解析

當然,Chrome並不是每次都將頁面中的所有連結的域名都拿來預解析,為了既提升使用者體驗又不會對DNS造成太大負擔,Chrome做了很多細節的優化,如通過學習使用者之前的行為來進行判斷

Chrome在啟動時還會預先解析使用者常去的網站,具體可以參考DNS Prefetching,當前Chrome中的DNS快取情況可以通過net-internals頁面來察看

為了幫助瀏覽器更好地進行DNS的預解析,可以在html中加上以下這句標籤來提示瀏覽器

<link rel="dns-prefetch" href="//HOSTNAME.com">

除此之外還可以使用HTTP header中的X-DNS-Prefetch-Control來控制瀏覽器是否進行預解析,它有on和off兩個值,更詳細的資訊請參考Controlling DNS prefetching

CDN

本文不打算詳細討論這個話題,感興趣的讀者可以閱讀Content delivery network

在效能方面與此相關的一個問題是使用者可能使用自定義的DNS,如OpenDNS或Google的8.8.8.8,需要注意對這種情況進行處理

link prefetch

由於Web頁面載入是同步模型,這意味著瀏覽器在執行js操作時需要將後續html的載入和解析暫停,因為js中有可能會呼叫document.write來改變dom節點,很多瀏覽器除了html之外還會將css的載入暫停,因為js可能會獲取dom節點的樣式資訊,這個暫停會導致頁面展現速度變慢,為了應對這個問題,Mozilla等瀏覽器會在執行js的同時簡單解析後面的html,提取出連結地址提前下載,注意這裡僅是先下載內容,並不會開始解析和執行

這一行為還可以通過在頁面中加入以下標籤來提示瀏覽器

<link rel="prefetch" href="http://">

但這種寫法目前並沒有成為正式的標準,也只有Mozilla真正實現了該功能,可以看看Link prefetching FAQ

WebKit也在嘗試該功能,具體實現是在HTMLLinkElement的process成員函式中,它會呼叫ResourceHandle::prepareForURL()函式,目前從實現看它是僅僅用做DNS預解析的,和Mozilla對這個屬性的處理不一致

對於不在當前頁面中的連結,如果需要預下載後續內容可以用js來實現,請參考這篇文章Preload CSS/JavaScript without execution

預下載後續內容還能做很多細緻的優化,如在Velocity China
2010
中,來自騰訊的黃希彤介紹了騰訊產品中使用的交叉預下載方案,利用空閒時間段的流量來預載入,這樣即提升了使用者訪問後續頁面的速度,又不會影響到高峰期的流量,值得借鑑

預渲染

預渲染比預下載更進一步,不僅僅下載頁面,而且還會預先將它渲染出來,目前在Chrome(9.0.597.0)中有實現,不過需要在about:flags中將’Web Page Prerendering’開啟

不得不說Chrome的效能優化做得很細緻,各方面都考慮到了,也難怪Chrome的速度很快

http

在網路層之上我們主要關注的是HTTP協議,這裡將主要討論1.1版本,如果需要了解1.0和1.1的區別請參考Key Differences between HTTP/1.0 and HTTP/1.1

header

首先來看http中的header部分

header大小

header的大小一般會有500 多位元組,cookie內容較多的情況下甚至可以達到1k以上,而目前一般寬頻都是上傳速度慢過下載速度,所以如果小檔案多時,甚至會出現頁面效能瓶頸出在使用者上傳速度上的情況,所以縮小header體積是很有必要的,尤其是對不需要cookie的靜態檔案上,最好將這些靜態檔案放到另一個域名上

將靜態檔案放到另一個域名上會出現的現象是,一旦靜態檔案的域名出現問題就會對頁面載入造成嚴重影響,尤其是放到頂部的js,如果它的載入受阻會導致頁面展現長時間空白,所以對於流量大且內容簡單的首頁,最好使用內嵌的js和css

header的擴充套件屬性

header中有些擴充套件屬性可以用來保護站點,瞭解它們是有益處的

  • X-Frame-Options
    • 這個屬性可以避免網站被使用frame、iframe的方式嵌入,解決使用js判斷會被var location;破解的問題,IE8、Firefox3.6、Chrome4以上的版本都支援
  • X-XSS-Protection
    • 這是IE8引入的擴充套件header,在預設情況下IE8會自動攔截明顯的XSS攻擊,如query中寫script標籤並在返回的內容中包含這項標籤,如果需要禁止可以將它的值設為0,因為這個XSS過濾有可能導致問題,如IE8 XSS Filter Bug
  • X-Requested-With
    • 用來標識Ajax請求,大部分js框架都會加入這個header
  • X-Content-Type-Options
    • 如果是html內容的檔案,即使用Content-Type: text/plain;的header,IE仍然會識別成html來顯示,為了避免它所帶來的安全隱患,在IE8中可以通過在header中設定X-Content-Type-Options: nosniff來關閉它的自動識別功能

使用get請求來提高效能

首先效能因素不應該是考慮使用get還是post的主要原因,首先關注的應該是否符合HTTP中標準中的約定,get應該用做資料的獲取而不是提交

之所以用get效能更好的原因是有測試表明,即使資料很小,大部分瀏覽器(除了Firefox)在使用post時也會傳送兩個TCP的packet,所以效能上會有損失

連線數

在HTTP/1.1協議下,單個域名的最大連線數在IE6中是2個,而在其它瀏覽器中一般4-8個,而整體最大連結數在30左右

而在HTTP/1.0協議下,IE6、7單個域名的最大連結數可以達到4個,在Even Faster Web Sites一書中的11章還推薦了對靜態檔案服務使用HTTP/1.0協議來提高IE6、7瀏覽器的速度

瀏覽器連結數的詳細資訊可以在Browserscope上查到

使用多個域名可以提高併發,但前提是每個域名速度都是同樣很快的,否則就會出現某個域名很慢會成為效能瓶頸的問題

cache

主流瀏覽器都遵循http規範中的Caching in HTTP來實現的

從HTTP cache的角度來看,瀏覽器的請求分為2種型別:conditional requests 和 unconditional requests

unconditional請求是當本地沒有快取或強制重新整理時發的請求,web server返回200的heder,並將內容傳送給瀏覽器

而conditional則是當本地有快取時的請求,它有兩種:

  1. 使用了Expires或Cache-Control,如果本地版本沒有過期,瀏覽器不會發出請求
  2. 如果過期了且使用了ETag或Last-Modified,瀏覽器會發起conditional請求,附上If-Modified-Since或If-None-Match的header,web server根據它來判斷檔案是否過期,如果沒有過期就返回304的header(不返回內容),瀏覽器見到304後會直接使用本地快取中的檔案

以下是IE傳送conditional requests的條件,從MSDN上抄來

  • The cached item is no longer fresh according to Cache-Control or Expires
  • The cached item was delivered with a VARY header
  • The containing page was navigated to via META REFRESH
  • JavaScript in the page called reload on the location object, passing TRUE
  • The request was for a cross-host HTTPS resource on browser startup
  • The user refreshed the page

簡單的來說,點選重新整理按鈕或按下F5時會發出conditional請求,而按下ctrl的同時點選重新整理按鈕或按下F5時會發出unconditional請求

需要進一步學習請閱讀:

前進後退的處理

瀏覽器會盡可能地優化前進後退,使得在前進後退時不需要重新渲染頁面,就好像將當前頁面先“暫停”了,後退時再重新執行這個“暫停”的頁面

不過並不是所有頁面都能“暫停”的,如當頁面中有函式監聽unload事件時,所以如果頁面中的連結是原視窗開啟的,對於unload事件的監聽會影響頁面在前進後時的效能

在新版的WebKit裡,在事件的物件中新增了一個persisted屬性,可以用它來區分首次載入和通過後退鍵載入這兩種不同的情況,而在Firefox中可以使用pageshow和pagehide這兩個事件

unload事件在瀏覽器的實現中有很多不確定性因素,所以不應該用它來記錄重要的事情,而是應該通過定期更新cookie或定期儲存副本(如使用者備份編輯文章到草稿中)等方式來解決問題

具體細節可以參考WebKit上的這2篇文章:

cookie

瀏覽器中對cookie的支援一般是網路層庫來實現的,瀏覽器不需要關心,如IE使用的是WinINET

需要注意IE對cookie的支援是基於pre-RFC Netscape draft spec for cookies的,和標準有些不同,在設定cookie時會出現轉義不全導致的問題,如在ie和webkit中會忽略“=”,不過大部分web開發程式(如php語言)都會處理好,自行編寫http互動時則需要注意

p3p問題

在IE中預設情況下iframe中的頁面如果域名和當前頁面不同,iframe中的頁面是不會收到cookie的,這時需要通過設定p3p來解決,具體可以察看微軟官方的文件,加上如下header即可

P3P:CP="IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT"

這對於用iframe嵌入到其它網站中的第三方應用很重要

編碼識別

頁面的編碼可以在http header或meta標籤中指明,對於沒有指明編碼的頁面,瀏覽器會根據是否設定了auto detect來進行編碼識別(如在chrome中的View-Encoding選單)

關於編碼識別,Mozilla開源了其中的Mozilla Charset Detectors模組,感興趣的可以對其進行學習

建議在http
header中指定編碼,如果是在meta中指定,瀏覽器在得到html頁面後會首先讀取一部分內容,進行簡單的meta標籤解析來獲得頁面編碼,如WebKit程式碼中的HTMLMetaCharsetParser.cpp,可以看出它的實現是查詢charset屬性的值,除了WebKit以外的其它瀏覽器也是類似的做法,這就是為何HTML5中直接使用如下的寫法瀏覽器都支援

<meta charset="utf-8">

需要注意不設定編碼會導致不可預測的問題,應儘可能做到明確指定

chunked

瀏覽器在載入html時,只要網路層返回一部分資料後就會開始解析,並下載其中的js、圖片,而不需要等到所有html都下載完成才開始,這就意味著如果可以分段將資料傳送給瀏覽器,就能提高頁面的效能,這就是chunked的作用,具體協議細節請參考Chunked Transfer Coding

在具體實現上,php中可以通過flush函式來實現,不過其中有不少需要注意的問題,如php的配置、web server、某些IE版本的問題等,具體請參考php文件及評論

注意這種方式只適用於html頁面,對於xml型別的頁面,由於xml的嚴格語法要求,瀏覽器只能等到xml全部下載完成後才會開始解析,這就意味著同等情況下,xml型別的頁面展現速度必然比html慢,所以不推薦使用xml

即使不使用這種http傳輸方式,瀏覽器中html載入也是邊下載邊解析的,而不需等待所有html內容都下載完才開始,所以實際上chunked主要節省的是等待伺服器響應的時間,因為這樣可以做到伺服器計算完一部分頁面內容後就立刻返回,而不是等到所有頁面都計算都完成才返回,將操作並行

另外Facebook所使用的BigPipe實際上是在應用層將頁面分為了多個部分,從而做到了服務端和瀏覽器計算的並行

keepalive

keepalive使得在完成一個請求後可以不關閉socket連線,後續可以重複使用該連線傳送請求,在HTTP/1.0和HTTP/1.1中都有支援,在HTTP/1.1中預設是開啟的

keepalive在瀏覽器中都會有超時時間,避免長期和伺服器保持連線,如IE是60秒

另外需要注意的是如果使用阻塞IO(如apache),開啟keepalive保持連線會很消耗資源,可以考慮使用nginx、lighttpd等其它web server,具體請參考相關文件,這裡就不展開描述

pipelining

pipelining是HTTP/1.1協議中的一個技術,能讓多個HTTP請求同時通過一個socket傳輸,注意它和keepalive的區別,keepalive能在一個socket中傳輸多個HTTP,但這些HTTP請求都是序列的,而pipelining則是並行的

可惜目前絕大部分瀏覽器在預設情況下都不支援,已知目前只有opera是預設支援的,加上很多網路代理對其支援不好導致容易出現各種問題,所以並沒有廣泛應用

SPDY

SPDY是google提出的對HTTP協議的改進,主要是目的是提高載入速度,主要有幾點:

  • Mutiplexed streams
    • 可以在一個TCP中傳輸各種資料,減少連結的耗時
  • Request prioritization
    • 請求分級,便於傳送方定義哪些請求是重要的
  • HTTP header compression
    • header壓縮,減少資料量

frame

從實現上看,frame類(包括iframe和frameset)的標籤是最耗時的,而且會導致多一個請求,所以最好減少frame數量

resticted

如果要嵌入不信任的網站,可以使用這個屬性值來禁止頁面中js、ActiveX的執行,可以參考msdn的文件

<iframe security="restricted" src=""></iframe>

javascript

載入

對於html的script標籤,如果是外鏈的情況,如:

<script src="a.js"></script>

瀏覽器對它的處理主要有2部分:下載和執行

下載在有些瀏覽器中是並行的,有些瀏覽器中是序列的,如IE8、Firefox3、Chrome2都是序列下載的

執行在所有瀏覽器中預設都是阻塞的,當js在執行時不會進行html解析等其它操作,所以頁面頂部的js不宜過大,因為那樣將導致頁面長時間空白,對於這些外鏈js,有2個屬性可以減少它們對頁面載入的影響,分別是:

  • async
    • 標識js是否非同步執行,當有這個屬性時則不阻塞當前頁面的載入,並在js下載完後立刻執行
    • 不能保證多個script標籤的執行順序
  • defer
    • 標示js是否延遲執行,當有這個屬性時js的執行會推遲到頁面解析完成之後
    • 可以保證多個script標籤的執行順序

下圖來自Asynchronous and deferred JavaScript execution explained,清晰地解釋了普通情況和這2種情況下的區別

需要注意的是這兩個屬性目前對於內嵌的js是無效的

而對於dom中建立的script標籤在瀏覽器中則是非同步的,如下所示:

var script = document.createElement('script');
script.src = 'a.js';
document.getElementsByTagName('head')[0].appendChild(script);

為了解決js阻塞頁面的問題,可以利用瀏覽器不認識的屬性來先下載js後再執行,如ControlJS就是這樣做的,它能提高頁面的相應速度,不過需要注意處理在js未載入完時的顯示效果

document.write

document.write是不推薦的api,對於標示有async或defer屬性的script標籤,使用它會導致不可預料的結果,除此之外還有以下場景是不應該使用它的:

  • 使用document.createElement建立的script
  • 事件觸發的函式中,如onclick
  • setTimeout/setInterval

簡單來說,document.write只適合用在外鏈的script標籤中,它最常見的場景是在廣告中,由於廣告可能包含大量html,這時需要注意標籤的閉合,如果寫入的內容很多,為了避免受到頁面的影響,可以使用類似Google AdSense的方式,通過建立iframe來放置廣告,這樣做還能減少廣告中的js執行對當前頁面效能的影響

另外,可以使用ADsafe等方案來保證嵌入第三方廣告的安全,請參考如何安全地嵌入第三方js – FBML/caja/sandbox/ADsafe簡介

script標籤放底部

將script標籤放底部可以提高頁面展現給使用者的速度,然而很多時候事情並沒那麼簡單,如頁面中的有些功能是依賴js的,所以更多的還需要根據實際需求進行調整

  • 嘗試用Doloto分析出哪些JS和初始展現是無關的,將那些不必要的js延遲載入
  • 手工進行分離,如可以先顯示出按鈕,但狀態是不可點,等JS載入完成後再改成可點的

傳輸

js壓縮可以使用YUI CompressorClosure Compiler

gwt中的js壓縮還針對gzip進行了優化,進一步減小傳輸的體積,具體請閱讀On Reducing the Size of Compressed Javascript

css

比起js放底部,css放頁面頂部就比較容易做到

@import

使用@import在IE下會由於css載入延後而導致頁面展現比使用link標籤慢,不過目前幾乎沒有人使用@import,所以問題不大,具體細節請參考don’t use @import

selector的優化

瀏覽器在構建DOM樹的過程中會同時構建Render樹,我們可以簡單的認為瀏覽器在遇到每一個DOM節點時,都會遍歷所有selector來判斷這個節點會被哪些selector影響到

不過實際上瀏覽器一般是從右至左來判斷selector是否命中的,對於ID、Class、Tag、Universal和Page的規則是通過hashmap的方式來查詢的,它們並不會遍歷所有selector,所以selector越精確越好,google page-speed中的一篇文件Use efficient CSS selectors詳細說明了如何優化selector的寫法

另一個比較好的方法是從架構層面進行優化,將頁面不同部分的模組和樣式繫結,通過不同組合的方式來生成頁面,避免後續頁面頂部的css只增不減,越來越複雜和混亂的問題,可以參考Facebook的靜態檔案管理

工具

以下整理一些效能優化相關的工具及方法

Browserscope

之前提到的http://www.browserscope.org收集了各種瀏覽器引數的對比,如最大連結數等資訊,方便參考

Navigation Timing

Navigation Timing是還在草案中的獲取頁面效能資料api,能方便頁面進行效能優化的分析

傳統的頁面分析方法是通過javascript的時間來計算,無法獲取頁面在網路及渲染上所花的時間,使用Navigation Timing就能很好地解決這個問題,具體它能取到哪些資料可以通過下圖瞭解(來自w3c)

目前這個api較新,目前只在一些比較新的瀏覽器上有支援,如Chrome、IE9,但也佔用一定的市場份額了,可以現在就用起來

boomerang

yahoo開源的一個頁面效能檢測工具,它的原理是通過監聽頁面的onbeforeunload事件,然後設定一個cookie,並在另一個頁面中設定onload事件,如果cookie中有設定且和頁面的refer保持一致,則通過這兩個事件的事件來衡量當前頁面的載入時間

另外就是通過靜態圖片來衡量頻寬和網路延遲,具體可以看boomerang

檢測工具

reference

Tags: browser, loading


相關文章