本文將探討瀏覽器渲染的loading過程,主要有2個目的:
- 了解瀏覽器在loading過程中的實現細節,具體都做了什么
- 研究如何根據瀏覽器的實現原理進行優化,提升頁面響應速度
由於loading和parsing是相互交織、錯綜復雜的,這里面有大量的知識點,為了避免過於發散本文將不會對每個細節都深入研究,而是將重點 放在開發中容易控制的部分(Web前端和Web Server),同時由於瀏覽器種類繁多且不同版本間差距很大,本文將側重一些較新的瀏覽器特性
現有知識
提升頁面性能方面已經有很多前人的優秀經驗了,如Best Practices for Speeding Up Your Web Site和Web 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則是當本地有緩存時的請求,它有兩種:
- 使用了Expires或Cache-Control,如果本地版本沒有過期,瀏覽器不會發出請求
- 如果過期了且使用了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 Compressor或Closure 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
- Browser Performance Wishlist
- HTML5
- Testing Page Load Speed
- Technically speaking, what makes Google Chrome fast?
- Optimizing Page Load Time
- An Engineer’s Guide to Bandwidth
- An Engineer’s Guide to DNS
- EricLaw’s IEInternals
- Internet Explorer Platform for Privacy Preferences (P3P) Standards Support Document
- COMET Streaming in Internet Explorer
- Internet Explorer Cookie Internals (FAQ)
- Fiddler PowerToy – Part 2: HTTP Performance
- Frontend SPOF
- XMLHttpRequest (XHR) Uses Multiple Packets for HTTP POST?
- WebKit Page Cache I – The Basics
- WebKit Page Cache II – The unload Event