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=<number!=0>和appcache
- 發請求確認是否新鮮,再決定是否返回304並從緩存中取數據 :代表的特性有:Last-Modified/If-Modified-Since,Etag/If-None-Match
- 直接發送請求, 沒有緩存,代表的特性有:Cache-Control:max-age=0/no-cache
時間寶貴,以下是最終的流程圖:
源碼和流程圖源文件在github
一步一步說緩存
朴素的靜態服務器
瀏覽的緩存的依據是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
設置緩存超時時間
對於指定后綴文件和過期日期,為了保證可配置。建立一個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
但是到這里遇到一個問題,並沒有達到預期的效果,並沒有從緩存讀取
緩存並沒有生效。
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。
代碼詳細可查看源碼:https://github.com/etoah/BrowserCachePolicy/tree/step2
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">
在瀏覽器輸入:http://localhost:8888/entry.html,可以看到appcache ,已經在緩存文件了:
從瀏覽器的Resources標簽也可以看到已緩存的文件:
這時再刷新瀏覽器,可以看到即使沒有 Expires 和Cache-Control 也是 from cache ,
而index.html 由於沒有加Expires ,Cache-Control和appcache 還是直接從服務器端取文件。
這時緩存的控制如下
本例子的源碼為分支 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();
}
})
如果沒有發送或者跟磁盤上的文件修改時間不相符合,則發送回磁盤上的最新文件。
同樣我們清緩存,刷新兩次就能看到效果如下:
服務器請求確認了文件是否新鮮,直接返回header, 網絡負載特別較小:
這時我們的緩存控制流程圖如下:
本例子的源碼為分支 step4:代碼詳細可查看源碼:https://github.com/etoah/BrowserCachePolicy/tree/step4
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,並讀取緩存
更改文件,etag發生不匹配,返回200
還有一部份功能特性,由於支持度不廣(部份客戶端不支持(chrome,firefox,緩存代理服務器)不支持,或主流服務器不支持,如nginx, Appache)沒有特別的介紹。
到這里最終主要的瀏程圖已完畢,最終的流程圖:
最終代碼可查看源碼
迷之瀏覽器
每個瀏覽器對用戶行為(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
W3C ETag
rfc2616
What takes precedence: the ETag or Last-Modified HTTP header?
出處:http://www.cnblogs.com/etoah/
歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面保留此段聲明。