1. 程式人生 > >淺談瀏覽器 http 的快取機制

淺談瀏覽器 http 的快取機制

(點選上方公眾號,可快速關注)

原文:VaJoy Larn(@VaJoy_學霸模式重啟)

www.cnblogs.com/vajoy/p/5341664.html

針對瀏覽器的http快取的分析也算是老生常談了,每隔一段時間就會冒出一篇不錯的文章,其原理也是各大公司面試時幾乎必考的問題。

之所以還寫一篇這樣的文章,是因為近期都在搞新技術,想“迴歸”下基礎,也希望儘量總結的更詳盡些。

那麼你是否還需要閱讀本篇文章呢?可以試著回答下面這個問題:

我們在訪問百度首頁的時候,會發現不管怎麼重新整理頁面,靜態資源基本都是返回 200(from cache):

0?wx_fmt=gif&wxfrom=5&wx_lazy=1

隨便點開一個靜態資源是醬的:

0?wx_fmt=png

哎喲有Response報頭資料呢,看來伺服器也正常返回了etag什麼鬼的應有盡有,那狀態200不是應該對應的非快取狀態麼?要from cache的話不是應該返回304才合理麼?

難道是度孃的伺服器故障了嗎?

如果你知道答案,那就可以忽略本文了。

http報文中與快取相關的首部欄位

我們先來瞅一眼RFC2616規定的47種http報文首部欄位中與快取相關的欄位,事先了解一下能讓咱在心裡有個底:

1. 通用首部欄位(就是請求報文和響應報文都能用上的欄位)

0?wx_fmt=png

2. 請求首部欄位

0?wx_fmt=png

3. 響應首部欄位

0?wx_fmt=png

4. 實體首部欄位

0?wx_fmt=png

後續大體也會依次介紹它們。

場景模擬

為方便模擬各種快取效果,我們建個非常簡單的場景。

1. 頁面檔案

我們建個非常簡單的html頁面,上面只有一個本地樣式檔案和圖片:

<!DOCTYPE html>

<html>

<head>

<title>快取測試

</title>

<link rel="stylesheet"href="css/reset.css">

</head>

<body>

<h1>哥只是一個標題</h1>

<p><img src="img/dog.jpg" /></p>

</body>

</html>

2. 首部欄位修改

有時候一些瀏覽器會自行給請求首部加上一些欄位(如chrome使用F5會強制加上“cache-control:max-age=0”),會覆蓋掉一些欄位(比如pragma)的功能;另外有時候我們希望伺服器能多/少返回一些響應欄位。

這種情況我們就希望可以手動來修改請求或響應報文上的內容了。那麼如何實現呢?這裡我們使用Fiddler來完成任務。

在Fiddler中我們可以通過“bpu XXX”指令來攔截指定請求,然後手動修改請求內容再發給伺服器、修改響應內容再發給客戶端。

以我們的example為例,頁面檔案走nginx通過 http://localhost/ 可直接訪問,所以我們直接執行“bpu localhost”攔截所有地址中帶有該字樣的請求:

0?wx_fmt=gif

點選被攔截的請求,可以在右欄直接修改報文內容(上半區域是請求報文,下半區域是響應報文),點選黃色的“Break on Response”按鈕可以執行下一步(把請求發給伺服器),點選綠色的按鈕“Run to Completion”可以直接完成整個請求過程:

0?wx_fmt=gif

通過這個方法我們可以很輕鬆地模擬出各種http快取場景。

3. 瀏覽器的強制策略

如上述,當下大多數瀏覽器在點選重新整理按鈕或按F5時會自行加上“Cache-Control:max-age=0”請求欄位,所以我們先約定成俗——後文提及的“重新整理”多指的是選中url位址列並按回車鍵(這樣不會被強行加上Cache-Control)。

事實上有的瀏覽器還有一些更奇怪的行為,在後續我們回答文章開頭問題的時候會提到。

石器時代的快取方式

在 http1.0 時代,給客戶端設定快取方式可通過兩個欄位——“Pragma”和“Expires”來規範。雖然這兩個欄位早可拋棄,但為了做http協議的向下相容,你還是可以看到很多網站依舊會帶上這兩個欄位。

1. Pragma

當該欄位值為“no-cache”的時候(事實上現在RFC中也僅標明該可選值),會知會客戶端不要對該資源讀快取,即每次都得向伺服器發一次請求才行。

Pragma屬於通用首部欄位,在客戶端上使用時,常規要求我們往html上加上這段meta元標籤(而且可能還得做些hack放到body後面去):

<meta http-equiv="Pragma" content="no-cache">

它告訴瀏覽器每次請求頁面時都不要讀快取,都得往伺服器發一次請求才行。

BUT!!! 事實上這種禁用快取的形式用處很有限:

1. 僅有IE才能識別這段meta標籤含義,其它主流瀏覽器僅能識別“Cache-Control: no-store”的meta標籤(見出處)。

2. 在IE中識別到該meta標籤含義,並不一定會在請求欄位加上Pragma,但的確會讓當前頁面每次都發新請求(僅限頁面,頁面上的資源則不受影響)。

做了測試後發現也的確如此,這種客戶端定義Pragma的形式基本沒起到多少作用。

不過如果是在響應報文上加上該欄位就不一樣了:

0?wx_fmt=png

如上圖紅框部分是再次重新整理頁面時生成的請求,這說明禁用快取生效,預計瀏覽器在收到伺服器的Pragma欄位後會對資源進行標記,禁用其快取行為,進而後續每次重新整理頁面均能重新發出請求而不走快取。

2. Expires

有了Pragma來禁用快取,自然也需要有個東西來啟用快取和定義快取時間,對http1.0而言,Expires就是做這件事的首部欄位。

Expires的值對應一個GMT(格林尼治時間),比如“Mon, 22 Jul 2002 11:12:01 GMT”來告訴瀏覽器資源快取過期時間,如果還沒過該時間點則不發請求。

在客戶端我們同樣可以使用meta標籤來知會IE(也僅有IE能識別)頁面(同樣也只對頁面有效,對頁面上的資源無效)快取時間:

<meta http-equiv="expires" content="mon, 18 apr 2016 14:30:00 GMT">

如果希望在IE下頁面不走快取,希望每次重新整理頁面都能發新請求,那麼可以把“content”裡的值寫為“-1”或“0”。

注意的是該方式僅僅作為知會IE快取時間的標記,你並不能在請求或響應報文中找到Expires欄位。

如果是在服務端報頭返回Expires欄位,則在任何瀏覽器中都能正確設定資源快取的時間:

0?wx_fmt=png

在上圖裡,快取時間設定為一個已過期的時間點(見紅框),則重新整理頁面將重新發送請求(見藍框)。

那麼如果Pragma和Expires一起上陣的話,聽誰的?我們試一試就知道了:

0?wx_fmt=png

我們通過Pragma禁用快取,又給Expires定義一個還未到期的時間(紅框),重新整理頁面時發現均發起了新請求(藍框),這意味著Pragma欄位的優先順序會更高。

BUT,響應報文中Expires所定義的快取時間是相對伺服器上的時間而言的,如果客戶端上的時間跟伺服器上的時間不一致(特別是使用者修改了自己電腦的系統時間),那快取時間可能就沒啥意義了。

Cache-Control

針對上述的“Expires時間是相對伺服器而言,無法保證和客戶端時間統一”的問題,http1.1新增了 Cache-Control 來定義快取過期時間,若報文中同時出現了 Pragma、Expires 和 Cache-Control,會以 Cache-Control 為準。

Cache-Control也是一個通用首部欄位,這意味著它能分別在請求報文和響應報文中使用。在RFC中規範了 Cache-Control 的格式為:

"Cache-Control" ":" cache-directive

作為請求首部時,cache-directive 的可選值有:

0?wx_fmt=png

作為響應首部時,cache-directive 的可選值有:

0?wx_fmt=png

我們依舊可以在HTML頁面加上meta標籤來給請求報頭加上 Cache-Control 欄位:

另外 Cache-Control 允許自由組合可選值,例如:

Cache-Control: max-age=3600, must-revalidate

它意味著該資源是從原伺服器上取得的,且其快取(新鮮度)的有效時間為一小時,在後續一小時內,使用者重新訪問該資源則無須傳送請求。

當然這種組合的方式也會有些限制,比如 no-cache 就不能和 max-age、min-fresh、max-stale 一起搭配使用。

組合的形式還能做一些瀏覽器行為不一致的相容處理。例如在IE我們可以使用 no-cache 來防止點選“後退”按鈕時頁面資源從快取載入,但在 Firefox 中,需要使用 no-store 才能防止歷史回退時瀏覽器不從快取中去讀取資料,故我們在響應報頭加上如下組合值即可做相容處理:

Cache-Control: no-cache, no-store

快取校驗欄位

上述的首部欄位均能讓客戶端決定是否向伺服器傳送請求,比如設定的快取時間未過期,那麼自然直接從本地快取取資料即可(在chrome下表現為200 from cache),若快取時間過期了或資源不該直接走快取,則會發請求到伺服器去。

我們現在要說的問題是,如果客戶端向伺服器發了請求,那麼是否意味著一定要讀取回該資源的整個實體內容呢?

我們試著這麼想——客戶端上某個資源儲存的快取時間過期了,但這時候其實伺服器並沒有更新過這個資源,如果這個資源資料量很大,客戶端要求伺服器再把這個東西重新發一遍過來,是否非常浪費頻寬和時間呢?

答案是肯定的,那麼是否有辦法讓伺服器知道客戶端現在存有的快取檔案,其實跟自己所有的檔案是一致的,然後直接告訴客戶端說“這東西你直接用快取裡的就可以了,我這邊沒更新過呢,就不再傳一次過去了”。

為了讓客戶端與伺服器之間能實現快取檔案是否更新的驗證、提升快取的複用率,Http1.1新增了幾個首部欄位來做這件事情。

1. Last-Modified

伺服器將資源傳遞給客戶端時,會將資源最後更改的時間以“Last-Modified: GMT”的形式加在實體首部上一起返回給客戶端。

客戶端會為資源標記上該資訊,下次再次請求時,會把該資訊附帶在請求報文中一併帶給伺服器去做檢查,若傳遞的時間值與伺服器上該資源最終修改時間是一致的,則說明該資源沒有被修改過,直接返回304狀態碼即可。

至於傳遞標記起來的最終修改時間的請求報文首部欄位一共有兩個:

⑴ If-Modified-Since: Last-Modified-value

示例為  If-Modified-Since: Thu, 31 Mar 2016 07:07:52 GMT

該請求首部告訴伺服器如果客戶端傳來的最後修改時間與伺服器上的一致,則直接回送304 和響應報頭即可。

當前各瀏覽器均是使用的該請求首部來向伺服器傳遞儲存的 Last-Modified 值。

⑵ If-Unmodified-Since: Last-Modified-value

告訴伺服器,若Last-Modified沒有匹配上(資源在服務端的最後更新時間改變了),則應當返回412(Precondition Failed) 狀態碼給客戶端。

當遇到下面情況時,If-Unmodified-Since 欄位會被忽略:

1. Last-Modified值對上了(資源在服務端沒有新的修改);

2. 服務端需返回2XX和412之外的狀態碼;

3. 傳來的指定日期不合法

Last-Modified 說好卻也不是特別好,因為如果在伺服器上,一個資源被修改了,但其實際內容根本沒發生改變,會因為Last-Modified時間匹配不上而返回了整個實體給客戶端(即使客戶端快取裡有個一模一樣的資源)。

2. ETag

為了解決上述Last-Modified可能存在的不準確的問題,Http1.1還推出了 ETag 實體首部欄位。

伺服器會通過某種演算法,給資源計算得出一個唯一標誌符(比如md5標誌),在把資源響應給客戶端的時候,會在實體首部加上“ETag: 唯一識別符號”一起返回給客戶端。

客戶端會保留該 ETag 欄位,並在下一次請求時將其一併帶過去給伺服器。伺服器只需要比較客戶端傳來的ETag跟自己伺服器上該資源的ETag是否一致,就能很好地判斷資源相對客戶端而言是否被修改過了。

如果伺服器發現ETag匹配不上,那麼直接以常規GET 200回包形式將新的資源(當然也包括了新的ETag)發給客戶端;如果ETag是一致的,則直接返回304知會客戶端直接使用本地快取即可。

那麼客戶端是如何把標記在資源上的 ETag 傳去給伺服器的呢?請求報文中有兩個首部欄位可以帶上 ETag 值:

⑴ If-None-Match: ETag-value

示例為  If-None-Match: "56fcccc8-1699"

告訴服務端如果 ETag 沒匹配上需要重發資源資料,否則直接回送304 和響應報頭即可。

當前各瀏覽器均是使用的該請求首部來向伺服器傳遞儲存的 ETag 值。

⑵ If-Match: ETag-value

告訴伺服器如果沒有匹配到ETag,或者收到了“*”值而當前並沒有該資源實體,則應當返回412(Precondition Failed) 狀態碼給客戶端。否則伺服器直接忽略該欄位。

If-Match 的一個應用場景是,客戶端走PUT方法向服務端請求上傳/更替資源,這時候可以通過 If-Match 傳遞資源的ETag。

需要注意的是,如果資源是走分散式伺服器(比如CDN)儲存的情況,需要這些伺服器上計算ETag唯一值的演算法保持一致,才不會導致明明同一個檔案,在伺服器A和伺服器B上生成的ETag卻不一樣。

如果 Last-Modified 和 ETag 同時被使用,則要求它們的驗證都必須通過才會返回304,若其中某個驗證沒通過,則伺服器會按常規返回資源實體及200狀態碼。

在較新的 nginx 上預設是同時開啟了這兩個功能的:

0?wx_fmt=gif

上圖的前三條請求是原始請求,接著的三條請求是重新整理頁面後的新請求,在發新請求之前我們修改了 reset.css 檔案,所以它的 Last-Modified 和 ETag 均發生了改變,伺服器因此返回了新的檔案給客戶端(狀態值為200)。

而 dog.jpg 我們沒有做修改,其Last-Modified 和 ETag在服務端是保持不變的,故伺服器直接返回了304狀態碼讓客戶端直接使用快取的 dog.jpg 即可,沒有把實體內容返回給客戶端(因為沒必要)。

快取實踐

當我們在一個專案上做http快取的應用時,我們還是會把上述提及的大多數首部欄位均使用上,例如使用 Expires 來相容舊的瀏覽器,使用 Cache-Control 來更精準地利用快取,然後開啟 ETag 跟 Last-Modified 功能進一步複用快取減少流量。

那麼這裡會有一個小問題——Expires 和 Cache-Control 的值應設定為多少合適呢?

答案是不會有過於精準的值,均需要進行按需評估。

例如頁面連結的請求常規是無須做長時間快取的,從而保證回退到頁面時能重新發出請求,百度首頁是用的 Cache-Control:private,騰訊首頁則是設定了60秒的快取,即 Cache-Control:max-age=60。

而靜態資源部分,特別是圖片資源,通常會設定一個較長的快取時間,而且這個時間最好是可以在客戶端靈活修改的。以騰訊的某張圖片為例:

http://i.gtimg.cn/vipstyle/vipportal/v4/img/common/logo.png?max_age=2592000

客戶端可以通過給圖片加上“max_age”的引數來定義伺服器返回的快取時間:

0?wx_fmt=png

當然這需要有一個前提——靜態資源能確保長時間不做改動。如果一個指令碼檔案響應給客戶端並做了長時間的快取,而服務端在近期修改了該檔案的話,快取了此指令碼的客戶端將無法及時獲得新的資料。

解決該困擾的辦法也簡單——把服務側ETag的那一套也搬到前端來用——頁面的靜態資源以版本形式釋出,常用的方法是在檔名或引數帶上一串md5或時間標記符:

https://hm.baidu.com/hm.js?e23800c454aa573c0ccb16b52665ac26

http://tb1.bdstatic.com/tb/_/tbean_safe_ajax_94e7ca2.js

http://img1.gtimg.com/ninja/2/2016/04/ninja145972803357449.jpg

如果檔案被修改了,才更改其標記符內容,這樣能確保客戶端能及時從伺服器收取到新修改的檔案。

關於開頭的問題

現在回過頭來看文章開頭的問題,可能會覺得答案很容易回答出來。

百度首頁的資源在重新整理後實際沒有傳送任何請求,因為 Cache-Control 定義的快取時間段還沒到期。在Chrome中即使沒傳送請求,但只要從本地的快取中取,都會在Network面板顯示一條狀態為200且註明“from cache”的偽請求,其Response內容只是上一次回包留下的資料。

然而這並不是問題的全部答案,我們前面提到過,在Chrome中如果點選“重新整理”按鈕,Chrome會強制給所有資源加上“Cache-Control: max-age=0”的請求首部並向伺服器傳送驗證請求的,而在文章開頭的動圖中,我們的確點選了“重新整理”按鈕,卻不見瀏覽器發去新請求(並返回304)。

關於這個問題其實在組內跟小夥伴們討論過,通過Fiddler抓包發現,如果關閉Chrome的開發者面板再點選“重新整理”按鈕,瀏覽器是會按預期傳送驗證請求且接收返回的304響應的,另外這個奇怪的情況在不同的網站甚至不同的電腦下出現頻率都不一致,所以暫時將其歸咎於瀏覽器的怪異反應。

那麼有這麼一個問題——是否有辦法在瀏覽器點選“重新整理”按鈕的時候不讓瀏覽器去發新的驗證請求呢?

辦法還是有的,就是不怎麼實用——在頁面載入完畢後通過指令碼動態地新增資源:

$(window).load(function(){

varbg='http://img.infinitynewtab.com/wallpaper/100.jpg';

setTimeout(function(){//setTimeout是必須的

$('#bgOut').css('background-image','url('+bg+')');

},0);

});

出處來自知乎,更具體的解釋可以去看看。

其它相關的首部欄位

事實上較常用和重要的快取相關欄位我們都介紹完了,這裡順帶講講幾個跟快取有關係,但沒那麼主要的響應首部欄位。

1. Vary

“vary”本身是“變化”的意思,而在http報文中更趨於是“vary from”(與。。。不同)的含義,它表示服務端會以什麼基準欄位來區分、篩選快取版本。

我們先考慮這麼一個問題——在服務端有著這麼一個地址,如果是IE使用者則返回針對IE開發的內容,否則返回另一個主流瀏覽器版本的內容。這很簡單,服務端獲取到請求的 User-Agent 欄位做處理即可。但是使用者請求的是代理伺服器而非原伺服器,且代理伺服器如果直接把快取的IE版本資源發給了非IE的客戶端,這就出問題了。

因此 Vary 便是著手處理該問題的首部欄位,我們可以在響應報文加上:

Vary: User-Agent

便能知會代理伺服器需要以 User-Agent 這個請求首部欄位來區別快取版本,防止傳遞給客戶端的快取不正確。

Vary 也接受條件組合的形式:

Vary: User-Agent, Accept-Encoding

這意味著伺服器應以 User-Agent 和 Accept-Encoding 兩個請求首部欄位來區分快取版本。

2. Date 和 Age

HTTP並沒有提供某種方法來幫使用者區分其收到的資源是否命中了代理伺服器的快取,但在客戶端我們可以通過計算響應報文中的 Date 和 Age 欄位來得到答案。

Date 理所當然是原伺服器傳送該資源響應報文的時間(GMT格式),如果你發現 Date 的時間與“當前時間”差別較大,或者連續F5刷新發現 Date 的值都沒變化,則說明你當前請求是命中了代理伺服器的快取。

上述的“當前時間”自然是相對於原伺服器而言的時間,那麼如何獲悉原伺服器的當前時間呢?

常規從頁面地址請求的響應報文中可獲得,以部落格園首頁為例:

0?wx_fmt=png

每次你重新整理頁面,瀏覽器都會重新發出這條url的請求,你會發現其 Date 值是不斷變化的,這說明該連結沒有命中快取,都是從原伺服器返回過來的資料。

因此我們可以拿頁面上其它靜態資源請求回包中的 Date 與其進行對比,若靜態資源的 Date 早於原服務端時間,則說明命中了代理伺服器快取。

通常還滿足這麼個條件:

靜態資源Age + 靜態資源Date = 原服務端Date

這裡的 Age 也是響應報文中的首部欄位,它表示該檔案在代理伺服器中存在的時間(秒),如檔案被修改或替換,Age會重新由0開始累計。

我們在上面那張部落格園首頁報文截圖的同個場景下,看看某個檔案(jQuery.js)命中代理伺服器快取的回包資料:

0?wx_fmt=png

會發現它滿足我們上述的規則:

//return true

newDate('Mon, 04 Apr 2016 07:03:17 GMT')/1000 == newDate('Sat, 19 Dec 2015 01:29:14 GMT')/1000 + 9264843

不過這條規則也不一定準確,特別是當原伺服器經常修改系統時間的情況下。

關於http快取原理的知識就整理到這,希望能讓你有所收穫,共勉~

覺得本文對你有幫助?請分享給更多人

關注「前端大全」,提升前端技能

640?wx_fmt=png