1. 程式人生 > >web效能優化:詳說瀏覽器快取

web效能優化:詳說瀏覽器快取

TOC

  • 背景
  • 瀏覽器的總流程圖
  • 一步一步說快取
    • 樸素的靜態伺服器
    • 設定快取超時時間
    • html5 Application Cache
    • Last-Modified/If-Modified-Since
    • Etag/If-None-Match
      • 什麼是Etag
      • 為什麼有了Last-Modified還要Etag
      • Etag 的實現
  • 迷之瀏覽器
  • 總結

背景

在對頁面的效能優化時,特別是移動端的優化,快取是非常重要的一環。
瀏覽器快取機制設定眾多:html5 appcache,Expires,Cache-control,Last-Modified/If-Modified-Since,Etag/If-None-Match,max-age=0/no-cache...,
之前對某一個或幾個特性瞭解一二,但是混在一起再加上瀏覽器的行為,就迷(meng)糊(bi)了.

下面從實現一個簡單的靜態伺服器角度,一步一步說瀏覽器的快取策略。

瀏覽器快取總流程圖

對http請求來說,客戶端快取分三類:

  • 不發任何請求,直接從快取中取資料,代表的特性有: Expires ,Cache-Control=和appcache
  • 發請求確認是否新鮮,再決定是否返回304並從快取中取資料 :代表的特性有:Last-Modified/If-Modified-Since,Etag/If-None-Match
  • 直接傳送請求, 沒有快取,代表的特性有:Cache-Control:max-age=0/no-cache

時間寶貴,以下是最終的流程圖:

原始碼和流程圖原始檔在github

img

一步一步說快取

樸素的靜態伺服器

瀏覽的快取的依據是server http response header , 為了實現對http response 的完全控制,用nodejs實現了一個簡單的static 伺服器,得益於nodejs簡單高效的api,
不到60行就把一個可用的版本實現了:原始碼
可克隆程式碼,分支切換到step1, 進入根目錄,執行 node app.js,瀏覽器裡輸入:http://localhost:8888/index.html,檢視response header,返回正常,也沒有用任何快取。
伺服器每次都要呼叫fs.readFile方法去讀取硬碟上的檔案的。當伺服器的請求量一上漲,硬碟IO會成為效能瓶頸(設定了記憶體快取除外)。

response header:
HTTP/1.1 200 OK
Content-Type: text/html
Date: Fri, 03 Jun 2016 14:15:35 GMT
Connection: keep-alive
Transfer-Encoding: chunked

image

設定快取超時時間

對於指定字尾檔案和過期日期,為了保證可配置。建立一個config.js。

exports.Expires = {

    fileMatch: /^(gif|png|jpg|js|css|html)$/ig,

    maxAge: 60*60*24*365

};

為了把快取這個職責獨立出來,我們再新建一個cache.js,作為一箇中間件處理request.

加上超期時間,程式碼如下


module.exports = function (request, response) {
     var pathname = url.parse(request.url).pathname;
    var ext = path.extname(pathname);
    ext = ext ? ext.slice(1) : 'unknown';

    if (ext.match(config.Expires.fileMatch)) {

        var expires = new Date();

        expires.setTime(expires.getTime() + config.Expires.maxAge * 1000);

        response.setHeader("Expires", expires.toUTCString());
        
        response.setHeader("Cache-Control", "max-age=" + config.Expires.maxAge);

    }
}

這時我們重新整理頁面可以看到response header 變為這樣了:

HTTP/1.1 200 OK
Expires: Sat, 03 Jun 2017 15:07:23 GMT
Cache-Control: max-age=31536000
Content-Type: text/html
Date: Fri, 03 Jun 2016 15:07:23 GMT
Connection: keep-alive
Transfer-Encoding: chunked

多了expires,但這是第一次訪問,流程和上面一樣,還是需要從硬碟讀檔案,再response

再重新整理頁面,可以看到http header :

Request URL:http://127.0.0.1:8888/index.html
Request Method:GET
Status Code:200 OK (from cache)
Remote Address:127.0.0.1:8888

image

但是到這裡遇到一個問題,並沒有達到預期的效果,並沒有從快取讀取

快取並沒有生效。

GET /index.html HTTP/1.1
Host: 127.0.0.1:8888
Connection: keep-alive
Cache-Control: max-age=0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 Safari/537.36
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-CN,zh;q=0.8

檢視request header 發現 Cache-Control: max-age=0,瀏覽器強制不用快取。

瀏覽器會針對的使用者不同行為採用不同的快取策略:

Chrome does something quite different: 'Cache-Control' is always set to 'max-age=0′, no matter if you press enter, f5 or ctrl+f5. Except if you start Chrome and enter the url and press enter.
其它的瀏覽器特性可以檢視文末的【迷之瀏覽器】

所以新增檔案entry.html,通過連結跳轉的方式進入就可以看到cache的效果了。

瀏覽器在傳送請求之前由於檢測到Cache-Control和Expires(Cache-Control的優先順序高於Expires,但有的瀏覽器不支援Cache-Control,這時採用Expires),
如果沒有過期,則不會發送請求,而直接從快取中讀取檔案。

Cache-Control與Expires的作用一致,都是指明當前資源的有效期,控制瀏覽器是否直接從瀏覽器快取取資料還是重新發請求到伺服器取資料。
只不過Cache-Control的選擇更多,設定更細緻,如果同時設定的話,其優先順序高於Expires。

html5 Application Cache

除了Expires 和Cache-Control 兩個特性的快取可以讓browser完全不發請求的話,別忘了還有一個html5的新特性 Application Cache,
在我的另一篇文章中有簡單的介紹HTML5 Application cache初探和企業應用啟示.
同時在自己寫的程式碼編輯器中,也用到了此特性,可離線檢視,坑也比較多。

為了消除 expires cache-control 的影響,先註釋掉這兩行,並消除瀏覽器的快取。

       // response.setHeader("Expires", expires.toUTCString());
        //response.setHeader("Cache-Control", "max-age=" + config.Expires.maxAge);

新增檔案app.manifest,由於appcache 會快取當前檔案,我們可不指定快取檔案,只需輸入CACHE MANIFEST,並在entry.html引用這個檔案。

<html lang="en" manifest="app.manifest">

從瀏覽器的Resources標籤也可以看到已快取的檔案:
img

這時再重新整理瀏覽器,可以看到即使沒有 Expires 和Cache-Control 也是 from cache ,

img

而index.html 由於沒有加Expires ,Cache-Control和appcache 還是直接從伺服器端取檔案。

這時快取的控制如下

img

本例子的原始碼為分支 step3:程式碼詳細可檢視原始碼

Last-Modified/If-Modified-Since

Last-Modified/If-Modified-Since。

  • Last-Modified:標示這個響應資源的最後修改時間。web伺服器在響應請求時,告訴瀏覽器資源的最後修改時間。
  • If-Modified-Since:當資源過期時(使用Cache-Control標識的max-age),發現資源具有Last-Modified宣告,則再次向web伺服器請求時帶上頭 If-Modified-Since,表示請求時間。web伺服器收到請求後發現有頭If-Modified-Since 則與被請求資源的最後修改時間進行比對。若最後修改時間較新,說明資源又被改動過,則響應整片資源內容(寫在響應訊息包體內),HTTP 200;若最後修改時間較舊,說明資源無新修改,則響應HTTP 304 (無需包體,節省瀏覽),告知瀏覽器繼續使用所儲存的cache。

所以我們需要把 Cache-Control 設定的儘可能的短,讓資源過期:

exports.Expires = {
    fileMatch: /^(gif|png|jpg|js|css|html)$/ig,
    maxAge: 1
};

同時需要識別出文件的最後修改時間,並返回給客戶端,我們同時也要檢測瀏覽器是否傳送了If-Modified-Since請求頭。如果傳送而且跟檔案的修改時間相同的話,我們返回304狀態。
程式碼如下:

        fs.stat(realPath, function (err, stat) {
            var lastModified = stat.mtime.toUTCString();
            var ifModifiedSince = "If-Modified-Since".toLowerCase();
            response.setHeader("Last-Modified", lastModified);
            if (request.headers[ifModifiedSince] && lastModified == request.headers[ifModifiedSince]) {
                response.writeHead(304, "Not Modified");
                response.end();
            }
        })

如果沒有傳送或者跟磁碟上的檔案修改時間不相符合,則傳送回磁碟上的最新檔案。

同樣我們清快取,重新整理兩次就能看到效果如下:

img

伺服器請求確認了檔案是否新鮮,直接返回header, 網路負載特別較小:

img

這時我們的快取控制流程圖如下:

img

Etag/If-None-Match

除了有Last-Modified/If-Modified-Since組合,還有Etag/if-None-Match,

什麼是Etag

ETag ,全稱Entity Tag.

  • Etag:web伺服器響應請求時,告訴瀏覽器當前資源在伺服器的唯一標識(生成規則由伺服器決定,具體下文中介紹)。
  • If-None-Match:當資源過期時(使用Cache-Control標識的max-age),發現資源具有Etage宣告,則再次向web伺服器請求時帶上頭If-None-Match (Etag的值)。
    web伺服器收到請求後發現有頭If-None-Match 則與被請求資源的相應校驗串進行比對,決定返回200或304。

為什麼有了Last-Modified還要Etag

你可能會覺得使用Last-Modified已經足以讓瀏覽器知道本地的快取副本是否足夠新,為什麼還需要Etag(實體標識)呢?HTTP1.1中Etag的出現主要是為了解決幾個Last-Modified比較難解決的問題:

  • Last-Modified標註的最後修改只能精確到秒級,如果某些檔案在1秒鐘以內,被修改多次的話,它將不能準確標註檔案的修改時間
  • 如果某些檔案會被定期生成,當有時內容並沒有任何變化,但Last-Modified卻改變了,導致檔案沒法使用快取
  • 有可能存在伺服器沒有準確獲取檔案修改時間,或者與代理伺服器時間不一致等情形

Etag 的實現

在node 的後端框架express 中引用的是npm包etag,etag 支援根據傳入的引數支援兩種etag的方式:
一種是檔案狀態(大小,修改時間),另一種是檔案內容的雜湊值。
詳情可相看etag原始碼

由上面的目的,也很容易想到怎麼簡單實現,這裡我們對檔案內容雜湊得到Etag值。
雜湊會用到node 中的Crypto模組 ,先引用var crypto = require('crypto');,並在響應時加上Etag:

var hash = crypto.createHash('md5').update(file).digest('base64');
                    response.setHeader("Etag", hash);
                    if (
                        (request.headers['if-none-match'] && request.headers['if-none-match'] === hash)
                        // ||
                        // (request.headers[ifModifiedSince] && new Date(lastModified) <= new Date(request.headers[ifModifiedSince]))
                    ) {
                        response.writeHead(304, "Not Modified");
                        response.end();
                        return;
                    }

為了消除 Last-Modified/If-Modified-Since的影響,測試時可以先註釋此 header,這裡寫的是 strong validator,詳細可檢視W3C ETag
第二次訪問時,正常的返回304,並讀取快取

img

更改檔案,etag發生不匹配,返回200

img

還有一部份功能特性,由於支援度不廣(部份客戶端不支援(chrome,firefox,快取代理伺服器)不支援,或主流伺服器不支援,如nginx, Appache)沒有特別的介紹。
到這裡最終主要的瀏程圖已完畢,最終的流程圖:

img

最終程式碼可檢視原始碼

迷之瀏覽器

每個瀏覽器對使用者行為(F5,Ctrl+F5,位址列回車等)的處理都不一樣,詳細請檢視Clientside Cache Control
以下摘抄一段:

So I tried this for different browsers. Unfortunately it's specified nowhere what a browser has to send in which situation.

  • Internet Explorer 6 and 7 do both send only cache refresh hints on ctrl+F5. On ctrl+F5 they both send the header field 'Cache-Control' set to 'no-cache'.
  • Firefox 3 do send the header field 'Cache-Control' with the value 'max-age=0′ if the user press f5. If you press ctrl+f5 Firefox sends the 'Cache-Control' with 'no-cache' (hey it do the same as IE!) and send also a field 'Pragma' which is also set to 'no-cache'.
  • Firefox 2 does send the header field 'Cache-Control' with the value 'max-age=0′ if the user press f5. ctrl+f5 does not work.
  • Opera/9.62 does send 'Cache-Control' with the value 'max-age=0′ after f5 and ctrl+f5 does not work.
  • Safari 3.1.2 behaves like Opera above.
  • Chrome does something quite different: 'Cache-Control' is always set to 'max-age=0′, no matter if you press enter, f5 or ctrl+f5. Except if you start Chrome and enter the url and press enter.

總結

這只是一篇原理或是規則性的文章,初看起來比較複雜,但現實應用可能只用到了很少的一部份特性就能達到較好的效果:
我們只需在打包的時候用gulp生成md5戳或時間戳,過期時間設定為10年,更新版本時更新戳,快取策略簡單高效。
關於快取配置的實戰這些問題,
比如,appcache,Expires/Cache-Control 都是不需發任何請求,適用於什麼場景,怎麼選擇?
配置時,不是配置express,配的是nginx,怎麼配置 ,下篇《詳說瀏覽器快取-實戰篇》更新。

Reference