Effective前端7:加快頁面打開速度

ramonzheng 7年前發布 | 12K 次閱讀 CSS 前端技術

頁面的打開速度對網站的優化有極大的意義,如果打開一個頁面一直處于白屏狀態,若超過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/

 

 本文由用戶 ramonzheng 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!