Effective前端7:加快頁面打開速度
頁面的打開速度對網站的優化有極大的意義,如果打開一個頁面一直處于白屏狀態,若超過5s,可能大部份人都會把它關了。或者是頁面加載出來了,但是比較慢,頁面顯示不完整,標簽欄一直在轉圈,頁面長期處于不可交互的狀態,這也是一種很不好的體驗。
如何評價一個頁面打開得快不快,可以用兩個指標描述,一個是ready的時間,另一個是load的時間。這個可以從Chrome的控制臺看到,如打開stackoverflow的首頁:
一共是加載490KB,ready時間是7.36s,load時間是17.35s。再來看下打開谷歌的情況:
雖然兩個頁面的內容差別比較大,但是從時間來看的話,很明顯谷歌的速度要明顯優于stackoverflow,谷歌的ready時間只有2.22s,也就是說2.22秒之后帶個頁面就是布局完整可交互的了,而stackoverflow打開的時候較長時間處于空白狀態,可交互時間達要到7.36s。
從load時間來看的話,兩者差別不太,都比較長,可能因為它們是境外的服務器。finish時間比load時間長,是因為load完后又去動態加載了其它的js。
為什么stackoverflow的ready時間要這么長呢?下面分點介紹優化的策略
1. 減少渲染堵塞
(1)避免head標簽JS堵塞
所有放在head標簽里的css和js都會堵塞渲染。如果這些CSS和JS需要加載和解析很久的話,那么頁面就空白了。看stackoverflow的html結構:
<head>
<title>Stack Overflow</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
</head>
它把jquery放到了head標簽里,這個jquery加載了3s:
相比之下,html文件只加載了0.83s,所以這個js文件至少使頁面停留了3s的空白狀態。
它的解析花了20ms不到:
這個解析時間還是可以忽略,相對于加載時間而言。并且我們注意到即使把jquery給刪了,stackoverflow的頁面還是可以完整顯示的,樣式無異,這個可以復制它的源代碼到本地然后刪掉head里面的script標簽打開頁面觀察。也就是話,把js放頭部是沒太大必要的。最關鍵的是它用的是谷歌的cdn,這樣就導致了大陸的小伙伴們無法在正常的環境下看stackoverflow,一打開整個頁面一兩分鐘都保持空白態。
有兩種解決辦法,第一種是把script放到body后面,這也是很多網站采取的方法。第二種是給script加defer的屬性,defer是html5新增的屬性。一旦script是defer延遲的,那么這個script將會異步加載,但不會馬上執行,會在 readystatechange變為 Interactive后按順序依次執行,用以下demo做說明:
<!DOCType html>
<html>
<head>
<metacharset="utf-8">
<script src="defer.js" defer></script>
</head>
<body>
<script>
document.onreadystatechange = function(){
console.log(document.readyState);
};
</script>
<script>
window.onload = function(){
console.log("window is loaded");
};
window.addEventListener("DOMContentLoaded", function(){
console.log("dom is ready");
});
</script>
<imgsrc="test.jpg" alt="">
<script src="normal.js"></script>
<script>
console.log("dom almost built");
</script>
</body>
</html>
defer.js的內容為:
console.log("I'm defered");
for(var i = 0; i < 10000000; i++){
new Date();
}
console.log("defer script end");
中間讓它執行一段較長的時間(約3秒),normal.js內容類似,打印的log:
可以看到,正常的script最先執行,然后緊接著的內聯script依次執行,說明正常script是串行執行的,不過它們可以是并行加載,例如stackoverflow寫在head標簽里面的這兩個正常的script是并行加載的:
回到上面,第四行readystatechange變成interactive,然后開始執行defer的script,執行完后依次觸發ready和load事件。
也就是說,defer的腳本會正常加載,但是延后執行,在interactive后執行,所以不會block頁面渲染。因此放在head標簽的script可以加一個defer,但是加上defer的腳本發生了重大的變化,不能夠影響渲染DOM的過程,只能是在渲染完了,例如綁的click事件在整個頁面沒渲染好之前不能生效。并且很多人要把它寫在head里面,是為了在頁面中間的script能調用到這些庫,影響DOM的渲染,加上defer就違背本意了。但是把script寫在head標簽始終是不推薦的,所以在頁面中間的script要么使用原生的API,要么把一些用到的函數寫成head標簽里面的內聯script。
head標簽里面的defer腳本跟放在body后面的腳本有什么區別呢,區別在于defer的腳本會馬上加載,一旦頁面interactive了便可以馬上執行,而放body后面是document快interactive的時候才去加載。我們知道,瀏覽器同時加載資源數是有限的,例如Chrome對同一個域名的資源,每次最多同時只能建立6個TCP連接(讀者可以試試)。所以寫在head標簽里面的外鏈腳本會影響頁面圖片的加載,特別是當腳本很多時。因此,如果頁面的交互比圖片的展示更重要,那么把script寫在head標簽加上defer是可取的,如果頁面的展示更為重要,那么應該把腳本放在body后面。
另外,defer可能會有兼容性問題,在老的瀏覽器上某些行為表現可能會不一致。
同樣地,head標簽里面的CSS資源也會block頁面渲染。
(2)減少head標簽里的CSS資源
由于CSS必須要放在head標簽里面,如果放在body里面,一旦加載好之后,又會對layout好的dom進行重排,樣式可能又會發生閃爍。但是一旦放在head標簽里面又會堵塞頁面渲染,若要加載很久,頁面就會保持空白狀態。所以要盡可能地減少CSS的代碼量。
a) 不要放太多base64放在CSS里面
放太多base64放在CSS里面,會導致CSS極速膨脹,把一張3k的圖片轉成base64,體積將會變成4k:
假設放了10張3k的圖片,那么CSS將增大40K,這已經和一個普通大小的CSS文件相仿。筆者曾為了解決一個hover變色的問題:
原始圖片都是svg的格式,hover的時候變成藍色,如果這樣寫的話:
.img{
background: url(black.svg) 0 0 no-repeat;
}
.img:hover{
background: url(blue.svg) 0 0 no-repeat;
}
會導致hover的時候才去加載blue.svg,第一次hover的時候不會馬上變藍,要稍微等到圖片下載下來了,在產品角度上是不可接受的。所以第一種解決辦法是把hover寫在svg里面,如下:
<svg>
<style type="text/css">
.st0{fill:#282828;}
.st0:hover{fill: #3399cc;}
</style>
</svg>
但是由于下面的文字也要跟著變藍,文字又不能放在svg里面控制,svg變成外鏈引進來之后,它就跨域了,無法在外面的html用CSS控制一個跨域的svg的樣式,如果把svg變成內聯的,又會導致html體積過大,同時對緩存也是不利的。所以當時提議,把svg轉成base64放到CSS里面,黑色和藍色的各轉成base64,總共要轉6 * 2 = 12個,由于原始的svg本來就比較大,再轉成base64就更大了,7k變成了9k,再乘以12,整個CSS就增加了100多K。這樣就導致了CSS要加載很久。
最后壓縮后的CSS文件有179Kb:
-rw-r–r– 1 yincheng staff 179K Mar 10 17:45 common-fbc013bb2526235952078ccd72a7fc97.css
好在開啟了gzip壓縮,實際傳輸的大小為30K:
不管怎樣,這種方法依舊是不推薦的。最后采取了圖標字體的解決方案,將svg轉成字體icon,方便顏色控制。脫離圖片后的CSS文件只有22Kb:
-rw-r–r– 1 yincheng staff 22K Mar 10 17:45 common-def3ac6078614e995ca8.js
gzip壓縮后不到10Kb。
上面的例子是避免動態加載,而有時候要動態加載,當使用媒體查詢的時候,如下所示:
@media(min-width: 501px){
.img{
background-picture: url(large.png);
}
}
@media(min-width: 501px){
.img{
background-picture: url(small.png);
}
}
上面大屏的時候加載large的圖片,小屏的時候加載small的圖片,瀏覽器會根據屏幕大小自動加載相應的圖片。而一旦你把它轉成了base64之后,它們都在CSS里面了,就沒有自動加載的優勢了。
b)把CSS寫成內聯的:
如果你的CSS只有10K或者20K,把它寫成內聯的,也未嘗不可,谷歌和淘寶PC就是這樣干的,直接把頁面幾乎所有的CSS都寫成style標簽放到html里面:
這個原始的CSS有66Kb:
-rw-r–r– 1 yincheng staff 66K Mar 12 10:00 taobao.css
gzip之后為16Kb:
這樣雖然對緩存不利,但是對于首次加載是有很大的作用的。因為如果你把CSS放到CDN上,為了得到這個CSS,它首先需要進行域名解析,然后建立http/https連接,其次才是下載。而用來做域名解析和建立連接的時候很可能早已把放在html里面的CSS下載下來了,這個時間可以從Chrome的控制臺觀察到:
為了加載這個資源,DNS查找花掉了0.5s,建立tcp連接花掉了0.95s,建立https連接花掉了0.6s,從發請求到收到第一個字節的數據(Time To First Byte)又花掉了1.27s,總的時間接近3s。
所以這個開銷還是很大的。還不如直接把CSS嵌到HTML里面。如果你的CSS體積沒達到3位數,加上gzip壓縮,放到html里面應該是可取的。
2. 優化圖片
(1)使用響應式圖片
響應式圖片的優點是瀏覽器能夠根據屏幕大小、設備像素比ppi、橫豎屏自動加載合適的圖片,如下使用srcset:
<imgsrcset="photo_w350.jpg 1x, photo_w640.jpg 2x" src="photo_w350.jpg" alt="">
如果屏幕的ppi = 1的話則加載1倍圖,而ppi = 2則加載2倍圖,手機和mac基本上ppi都達到了2以上,這樣子對于普通屏幕來說不會浪費流量,而對于視網膜屏來說又有高清的體驗。
如果瀏覽器不支持srcset,則默認加載src里面的圖片。
但是你會發現實際情況并不是如此,在Mac上的Chrome它會同時加載srcset里面的那張2x的,還會再去加載src里面的那張,加載兩張圖片。順序是先把所有srcset里面的加載完了,再去加載src的。這個策略比較奇怪,它居然會加載兩張圖片,如果不寫src,則不會加載兩張,但是兼容性就沒那么好。這個可能是因為瀏覽器認為,既然有srcset就不用寫src了,如果寫了src,用戶可能是有用的。
而使用picture就不會加載兩張:
<picture>
<sourcesrcset="banner_w1000.jpg" media="(min-width: 801px)">
<sourcesrcset="banner_w800.jpg" media="(max-width: 800px)">
<imgsrc="banner_w800.jpg" alt="">
</picture>
如上,如果頁面寬度大于800px(PC),則加載大圖,而在手機上加載小圖。這樣寫瀏覽器就只會加載source里面的一張圖片。但是如果是用js動態插進去的,它還是會去加載兩張,只有寫在html里面加載初始化頁面的時候才只加載一張。
這個的解決方法很簡單,瀏覽器支不支持srcset,可以用js判斷。如果支持,則不寫src的屬性了,如果不支持就不用寫srcset了:
var supportSrcset = 'srcset' in document.createElement('img');
var surportPicture = 'HTMLPictureElement' in window;
picture必須要寫img標簽,否則無法顯示,對picture的操作最后都是在img上面,例如onload事件是在img標簽觸發的,picture和source是不會進行layout的,它們的寬和高都是0。
另外使用source,還可以對圖片格式做一些兼容處理:
<picture>
<sourcetype="image/webp" srcset="banner.webp">
<imgsrc="banner.jpg" alt="">
</picture>
上面Chrome瀏覽器將會加載webp格式的圖片:
webp在保持同等清晰度的情況下,體積可以減少一半,但是目前只有Chrome支持,Safari和firefox一直處于實驗階段,所以其它的瀏覽器如firefox將會加載jpg格式的照片:
可以看到原圖是68k,轉成webp之后變成了45k,如果你把jpg有損壓得比較厲害,例如質量壓為0.3,可以比webp更小,但是失真也比較厲害了。
(2)延遲加載圖片
對于很多網站來說,圖片往往是占據最多流量和帶寬的的資源。特別是那種瀑布式展示性的網站,一個頁面展示50本書,50張圖片,如果一口氣全部放出來,那么頁面的Loaded時間將會較長,并且由于并行加載資源數是有限,圖片太多會導致放body后面的js解析比較慢,頁面將較長時間處于不可交互狀態。所以不能一下子把全部圖片都放出來,這對于手機上的流量也是不利的。
為此,筆者做了個懶惰加載圖片的嘗試,初始加載頁面的時候并不去加載圖片,只有當用戶下滑到相應位置的時候才把圖片放出來。首先,渲染頁面的時候別把圖片地址放到src上,放到一個data的屬性:
<picture>
<sourcedata-srcset="photo_w350.jpg 1x, photo_w640.jpg 2x">
<imgdata-src="photo_w350.jpg" src="about:blank" alt="">
</picture>
如上,放到data-src和data-srcset里面,上面把src的屬性寫成了”about:blank”,這是因為不能隨便寫一個不存在的地址,否則控制臺會報錯:加載失敗,如果寫成空或不寫,那么它會認為src就是當前頁面。如果寫成about:blank,大家相安無事,并且不同瀏覽器兼容性好。
接下來進行位置判斷,監聽scroll事件,回調函數為:
showImage(leftSpace = 500){
var scrollTop = $window.scrollTop();
var $containers = this.$imgContainers,
scrollPosition = $window.scrollTop() + $window.height();
for(var i = 0; i < $containers.length; i++){
//如果快要滑到圖片的位置了
var $container = $containers.eq(i);
if($container.offset().top - scrollPosition < leftSpace){
this.ensureImgSrc($container);
}
}
}
第5行for循環,依次對所有的圖片做處理,第8行的if判斷,如果滑動的位置快要到那張圖片了,則把src放出來,這個位置差默認為500px,如果圖片加載得快的話,這種行為對于用戶來說是透明的,他可能不知道圖片是往下滑的時候才放出來的,幾乎不會影響體驗,如果用戶滑得很快,本身不做這樣的處理,也不可能加載得這么快,也是loading的狀態。
下面的函數把圖片放出來:
ensureImgSrc($container){
var $source = $container.find("source");
if($source.length && !$source.attr("srcset")){
$source.attr("srcset", $source.data("srcset"));
}
var $img = $container.find("img:not(.loading)");
if($img.length && $img.attr("src").indexOf("http://") < 0){
$img.attr("src", $img.data("src"));
this.shownCount++;
}
}
代碼里面判斷src是不是有”//”,即為正常的地址,如果沒有給它賦值,觸發瀏覽器加載圖片。并記錄已經放出來的個數,這樣可以做個優化,當圖片全部都加載或者開始加載了,把scroll事件取消掉:
init(){
//初始化
var leftSpace = 0;
this.showImage(leftSpace);
//滑動
$window.on("scroll", this, this.throttleShow);
}
ensureImgSrc($container){
//如果全部顯示,off掉window.scroll
if(this.shownCount >= this.allCount){
$window.off("scroll", this.throttleShow);
}
}
這樣可以大大減少打開頁面的流量,加快ready和load的時間。
3. 壓縮和緩存
(1)gzip壓縮
上文已提及,使用gzip壓縮可以大大減少文件的體積,一個180k的CSS文件被壓成了30k,減少了83%的體積。如何開啟壓縮呢,這個很簡單,只要在nginx的配置里面添加這個選項就好了:
server{
gzipon;
gzip_types text/plainapplication/javascriptapplication/x-javascripttext/javascripttext/xmltext/css;
}
(2)Cache-Control
如果沒有任何緩存策略,那么對于以下頁面:
<!DOCType html>
<html>
<head>
<linkhref="test.css" rel="stylesheet">
</head>
<body>
<picture>
<sourcetype="image/webp" srcset="banner.webp">
<imgsrc="banner.jpg" alt="">
</picture>
<script src="normal.js"></script>
</body>
</html>
總共有4個資源,html、css、img和js各一個,第一次加載都為200:
刷新頁面第二次加載時:
除了html,其它三個文件都是直接在本地緩存取的,這個是Chrome的默認策略。html是重新去請求,nginx返回了304 Not Modified。
為什么nginx知道沒有修改呢,因為在第一次請求的時候,nginx的http響應頭里面返回了html的最近修改時間:
在第二次請求的時候,瀏覽器會把這個Last-Modified帶上,變成If-Modified-Since字段:
這樣nginx就可以取本地文件信息里的修改時間和這個進行比較,一旦時間一致或者在此之前,直返回304,告訴客戶端從緩存取。筆者的nginx版本默認是開啟last-modified,有些網站并沒有開啟這個,每次都是200重新請求。如果把文件編輯了保存,nginx會重新返回一個最近修改時間。
除了last-modified字段之外,還可以手動控制緩存時間,那就是使用Cache-Control,例如設置圖片緩存30天,而js/css緩存7天:
location ~* \.(jpg|jpeg|png|gif|webp)$ {
expires 30d;
}
location ~* \.(css|js)$ {
expires 7d;
}
這樣響應頭就會加一個 Cache-Control: max-age=604800(s) :
這個和last-modified有什么區別呢,如果把expires改成3s:
location ~* \.(css|js)$ {
expires 3s;
}
不斷刷新,觀察加載情況:
第一次請求還是200,第二次請求css/js都是cached,過了3秒之后的第三次請求,css/js變成了304:
從這里可以看出max-age的優先級要大于last-modified。如果要強制不緩存,則把expires時間改成0.
上面的結果都是用Chrome實驗,firefox的和Chrome比較一致,而Safari差別比較大,即使設置Cache-Control,仍然還是會有304的請求,并且html永遠是200重新加載,它沒有把last-modified和cache-control帶上。
綜上,設置緩存的作用一個是把200變成304,避免資源重新傳輸,第二個是讓瀏覽器直接從緩存取,連http請求都不用了,這樣對于第二次訪問頁面是極為有利的。設置緩存還有第三種技術,使用etag。
(3)使用etag
上面的兩種辦法都有缺點,由于很多網站使用模板渲染,每次請求都是重新渲染,生成的文件的last-modified肯定是不一樣的,所以last-modified在這種場景下失效,而使用max-age你無法知道精確控制頁面的數據什么時候會發生變化,所以max-age不太好使。這個時候etag就派上用場了。nginx開啟etag只需要在server配置里面加上一行:
etagon;
所謂etag就是對文件做的一個校驗和,第一次訪問的時候,響應頭里面返回這個文件的etag,瀏覽器第二次訪問的時候把etag帶上,nginx根據這個etag和新渲染的文件計算出的etag進行比較,如果相等則返回304。
如下,第一次訪問返回etag:
第二次訪問帶上etag,在If-None-Match字段:
服務返回304,如果我把html文件修改了,那么這個etag就會發生變化,服務返回200:
由于etag要使用少數的字符表示一個不定大小的文件,所以etag是有重合的風險,如果網站的信息特別重要,連很小的概率如百萬分之一都不允許,那么就不要使用etag了。
我們可以看到youku就是用的etag:
使用etag的代價是增加了服務器的計算負擔,特別是當文件比較大時。
4. 其它優化方案
(1)DNS預讀取
上文已提到域名解析可能會花很長的時間,而一個網站可能會加載很多個域的東西,例如使用了三個自已子域名的服務,再使用了兩個第三方的CDN,再使用了百度統計/谷歌統計的代碼,還使用了其它網站上的圖片,一個網站很可能要加載七、八個域的資源,第一次打開時,要做七、八次的DNS查找,這個時間是非常可觀的。因此,DNS預讀取技術能夠加快打開速度,方法是在head標簽里面寫上幾個link標簽:
<linkrel="dns-prefection" >
<linkrel="dns-prefection" >
<linkrel="dns-prefection" href="https://connect.非死book.net">
<linkrel="dns-prefection" >
<linkrel="dns-prefection" href="https://staticxx.非死book.com">
<linkrel="dns-prefection" >
如上,對以上向個網站提前解析DNS,由于它是并行的,不會堵塞頁面渲染。這樣可以縮短資源加載的時間。
(2) html優化
把本地的html布署到服務器上前,可以先對html做一個優化,例如把注釋remove掉,把行前縮進刪掉,如下處理前的文件:
<!DOCType html>
<html>
<head>
<meatacharset="utf-8">
</head>
</html>
<body>
<!-main content-->
<div>hello, world</div>
</body>
</html>
處理后的文件:
<!DOCTypehtml>
<html>
<head>
<meatacharset="utf-8">
</head>
</html>
<body>
<div>hello, world</div>
</body>
</html>
這樣處理的文件可以明顯減少html的體積,特別是當一個tab是4個空格或者8個空格時。
可以作一個比較,以youku為例,把它的html復制出來,然后再把它每行的行首空格去掉:
從687K減少了200Kb,約為1/3,這個量還是很可觀的。對其它網頁的實驗,可以發現這樣處理普遍減少1/3的體積。而且這樣做幾乎是沒有風險,除了pre標簽不能夠去掉行首縮進之外,其它的都正常。
(3)代碼優化
對自己寫的代碼做優化,提高運行速度,例如說html別嵌套太多層,否則加重頁面layout的壓力,CSS的選擇器別寫太復雜,不然匹配的計算量會比較大,對JS,別濫用閉包,閉包會加深作用域鏈,加長變量查找的時間,如下:
var a = 15;
function foo(){
var b = a + 3;
function bar(){
var c = b + a;
}
return bar();
}
在bar這個函數里面,它為了查找a這個變量,需要在bar的作用域,再到foo的作用域,再到全局作用域進行查找。
上文從頁面堵塞、圖片優化、開啟緩存、代碼優化等角度介紹了優化頁面加載的方案,但其實上面的只是一些參考建議,可能不能放之四海皆通,讀者應該要結合自己網站的實際情況做一些分析,找到瓶頸問題。如果不確實就反復實踐,直到發現一些合適的方法。
來自:http://www.renfed.com/2017/03/12/page-speed/