前端 http 快取

wdapp發表於2020-02-23

前端面試常問第二大問題是http快取相關內容。說真的,http快取相關的細節比較多,並且 http 常用協議版本有1.0、1.1,(本文暫不討論http2.0)。

快取相關 header

我們先羅列一下和快取相關的請求響應頭。

Expires
複製程式碼

響應頭,代表該資源的過期時間。

Cache-Control
複製程式碼

請求/響應頭,快取控制欄位,精確控制快取策略。

If-Modified-Since
複製程式碼

請求頭,資源最近修改時間,由瀏覽器告訴伺服器。

Last-Modified
複製程式碼

響應頭,資源最近修改時間,由伺服器告訴瀏覽器。

Etag
複製程式碼

響應頭,資源標識,由伺服器告訴瀏覽器。

If-None-Match
複製程式碼

請求頭,快取資源標識,由瀏覽器告訴伺服器。

配對使用的欄位:

  • If-Modified-Since 和 Last-Modified
  • Etag 和 If-None-Match

今天著重介紹一下瀏覽器快取機制,我們知道,瀏覽器快取一般都是針對靜態資源,比如 js、css、圖片 等,所以我們下面的例子圍繞一個 javascript 檔案 a.js 來闡述。拋開理論式灌輸,我們從實際場景觸發,一點點完善快取機制,這種方式,相信大家會更容易理解。 做一些約定,方便以後比較。

  • a.js 大小為 10 KB
  • 請求頭約定為 1 KB
  • 響應頭約定為 1 KB

原始模型

瀏覽器請求靜態資源 a.js。(請求頭:1KB) 伺服器讀取磁碟檔案 a.js,返給瀏覽器。(10KB(a.js)+1KB(響應頭) = 11KB)。 瀏覽器再次請求,伺服器又重新讀取磁碟檔案 a.js,返給瀏覽器。 如此迴圈。。

執行一個往返,流量為 10(a.js)+1(請求頭)+1(響應頭) = 12KB。 訪問 10 次,流量大約為12 * 10 = 120KB。 所以,流量與訪問次數有關: L(流量) = N(訪問次數) * 12。 該方式缺點很明顯:

  • 浪費使用者流量。
  • 浪費伺服器資源,伺服器要讀磁碟檔案,然後傳送檔案到瀏覽器。
  • 瀏覽器要等待 a.js 下載並且執行後才能渲染頁面,影響使用者體驗。
js 執行時間相比下載時間要快的多,如果能優化下載時間,使用者體驗會提升很多。
複製程式碼

瀏覽器增加快取機制

  • 瀏覽器第一次請求 a.js,快取 a.js 到本地磁碟。(1+10+1 =12KB)
  • 瀏覽器再次請求 a.js,直接走瀏覽器快取(200,from cache),不再向伺服器發起請求。(0KB)
  • ...

第一次訪問,流量為 1+10+1 = 12KB。 第二次訪問,流量為 0。 。。。 第 10000 次訪問,流量依然為 0。 所以流量與訪問次數無關: L(流量) = 12KB。 優點:

  • 大大減少頻寬。
  • 由於減少了 a.js 下載時間,相應的提高了使用者體驗。

缺點:伺服器上 a.js 更新時,瀏覽器感知不到,拿不到最新的 js 資源。

伺服器和瀏覽器約定資源過期時間。

伺服器和瀏覽器約定檔案過期時間,用 Expires 欄位來控制,時間是 GMT 格式的標準時間,如 Fri, 01 Jan 1990 00:00:00 GMT。

  • 瀏覽器第一次請求一個靜態資源 a.js。(1KB)
  • 伺服器把 a.js 和 a.js 的快取過期時間(Expires:Mon, 26 Sep 2018 05:00:00 GMT)發給瀏覽器。(10+1=11KB)

伺服器告訴瀏覽器:你把我發給你的 a.js 檔案快取到你那裡,在 2018年9月26日5點之前不要再發請求煩我,直接使用你自己快取的 a.js 就行了。

  • 瀏覽器接收到 a.js,同時記住了過期時間。
  • 在2018年9月26日5點之前,瀏覽器再次請求 a.js,便不再請求伺服器,直接使用上一次快取的 a.js 檔案。(0KB)
  • 在2018年9月26日5點01分,瀏覽器請求 a.js,發現 a.js 快取時間過了,於是不再使用本地快取,而是請求伺服器,伺服器又重新讀取磁碟檔案 a.js,返給瀏覽器,同時告訴瀏覽器一個新的過期時間。(1+10+1=12KB)。
  • 如此往復。。。

該種方式較之前的方式有了很大的改善:

在過期時間以內,為使用者省了很多流量。 減少了伺服器重複讀取磁碟檔案的壓力。 快取過期後,能夠得到最新的 a.js 檔案。

缺點還是有:

  • 快取過期以後,伺服器不管 a.js有沒有變化,都會再次讀取 a.js檔案,並返給瀏覽器。

伺服器告訴瀏覽器資源上次修改時間。

為了解決上個方案的問題,伺服器和瀏覽器經過磋商,制定了一種方案,伺服器每次返回 a.js 的時候,還要告訴瀏覽器 a.js 在伺服器上的最近修改時間 Last-Modified (GMT標準格式)。

  • 瀏覽器訪問 a.js 檔案。(1KB)
  • 伺服器返回 a.js 的時候,告訴瀏覽器 a.js 檔案。(10+1=11KB) 在伺服器的上次修改時間 Last-Modified(GMT標準格式)以及快取過期時間 Expires(GMT標準格式)
  • 當 a.js 過期時,瀏覽器帶上 If-Modified-Since(等於上一次請求的Last-Modified) 請求伺服器。(1KB)
  • 伺服器比較請求頭裡的 Last-Modified 時間和伺服器上 a.js的上次修改時間:
    • 如果一致,則告訴瀏覽器:你可以繼續用本地快取(304)。此時,伺服器不再返回 a.js 檔案。(1KB)
    • 如果不一致,伺服器讀取磁碟上的 a.js 檔案返給瀏覽器,同時告訴瀏覽器 a.js 的最近的修改時間 Last-Modified 以及過期時間 Expires。(1+10=11KB)
    • 如此往復。

此種方案比上一個方案有了更進一步的優化:

  • 快取過期後,伺服器檢測如果檔案沒變化,不再把a.js發給瀏覽器,省去了 10KB 的流量。
  • 快取過期後,伺服器檢測檔案有變化,則把最新的 a.js 發給瀏覽器,瀏覽器能夠得到最新的 a.js。

缺點:

  • Expires 過期控制不穩定,因為瀏覽器端可以隨意修改時間,導致快取使用不精準。
  • Last-Modified 過期時間只能精確到秒。

精確到秒存在兩個問題:

  1. 如果 a.js 在一秒時間內經常變動,同時伺服器給 a.js 設定無快取,那瀏覽器每次訪問 a.js,都會請求伺服器,此時伺服器比較發給瀏覽器的上次修改時間和 a.js 的最近修改時間,發現都是在同一時間(因為精確到秒),因此返回給瀏覽器繼續使用本地快取的訊息(304),但事實上伺服器上的 a.js 已經改動了好多次了。所以這種情況,瀏覽器拿不到最新的 a.js 檔案。

  2. 如果在伺服器上 a.js 被修改了,但其實際內容根本沒發生改變,會因為 Last-Modified 時間匹配不上而重新返回 a.js 給瀏覽器。

繼續改進,增加相對時間的控制,引入 Cache-Contorl

為了相容已經實現了上述方案的瀏覽器,同時加入新的快取方案,伺服器除了告訴瀏覽器 Expires ,同時告訴瀏覽器一個相對時間 Cache-Control:max-age=10秒。意思是在10秒以內,使用快取到瀏覽器的 a.js 資源。

瀏覽器先檢查 Cache-Control,如果有,則以 Cache-Control 為準,忽略 Expires。如果沒有 Cache-Control,則以 Expires 為準。

繼續改進,增加檔案內容對比,引入Etag

為了解決檔案修改時間只能精確到秒帶來的問題,我們給伺服器引入 Etag 響應頭,a.js 內容變了,Etag 才變。內容不變,Etag 不變,可以理解為 Etag 是檔案內容的唯一 ID。 同時引入對應的請求頭 If-None-Match,每次瀏覽器請求伺服器的時候,都帶上If-None-Match欄位,該欄位的值就是上次請求 a.js 時,伺服器返回給瀏覽器的 Etag。

  • 瀏覽器請求 a.js。
  • 伺服器返回 a.js,同時告訴瀏覽器過期絕對時間(Expires)以及相對時間(Cache-Control:max-age=10),以及a.js上次修改時間Last-Modified,以及 a.js 的Etag。
  • 10秒內瀏覽器再次請求 a.js,不再請求伺服器,直接使用本地快取。
  • 11秒時,瀏覽器再次請求 a.js,請求伺服器,帶上上次修改時間 If-Modified-Since 和上次的 Etag 值 If-None-Match。
  • 伺服器收到瀏覽器的If-Modified-Since和Etag,發現有If-None-Match,則比較 If-None-Match 和 a.js 的 Etag 值,忽略If-Modified-Since的比較。
  • a.js 檔案內容沒變化,則Etag和If-None-Match 一致,伺服器告訴瀏覽器繼續使用本地快取(304)。
  • 如此往復。

結束了嗎?

到此就結束了嗎? 是的,http的快取機制就是如此了,但是仍然存在一個問題:

瀏覽器無法主動得知伺服器上的 a.js 資源變化了。

不管用 Expires 還是 Cache-Control,他們都只能夠控制快取是否過期,但是在快取過期之前,瀏覽器是無法得知伺服器上的資源是否變化的。只有當快取過期後,瀏覽器才會發請求詢問伺服器。

最終方案

大家可以想象我們使用 a.js 的場景,我們一般都是輸入網址,訪問一個 html 檔案,html檔案中會引入 js、css 、圖片等資源。

所以呢,我們在html上做些手腳。

我們不讓 html 檔案快取,每次訪問 html 都去請求伺服器。所以瀏覽器每次都能拿到最新的html資源。

a.js 內容更新的時候,我們修改一下 html 中 a.js 的版本號。

  • 第一次訪問 html
<script src="http://test.com/a.js?version=0.0.1"></script>
複製程式碼
  • 瀏覽器下載0.0.1版本的a.js檔案。
  • 瀏覽器再次訪問 html,發現還是0.0.1版本的a.js檔案,則使用本地快取。
  • 某一天a.js變了,我們的html檔案也相應變化如下:
<script src="http://test.com/a.js?version=0.0.2"></script>
複製程式碼
  • 瀏覽器再次訪問html,發現【test.com/a.js?versio… a.js。
  • 如此往復。

所以,通過設定html不快取,html引用資源內容變化則改變資源路徑的方式,就解決了無法及時得知資源更新的問題。

當然除了以版本號來區分,也可以以 MD5hash 值來區分。 如

<script src="http://test.com/a.【hash值】.js"></script>
複製程式碼

使用webpack打包的話,藉助外掛可以很方便的處理。

除此以外的東東

Cache-Control 除了可以設定 max-age 相對過期時間以外,還可以設定成如下幾種值:

  • public,資源允許被中間伺服器快取。

瀏覽器請求伺服器時,如果快取時間沒到,中間伺服器直接返回給瀏覽器內容,而不必請求源伺服器。

  • private,資源不允許被中間代理伺服器快取。

瀏覽器請求伺服器時,中間伺服器都要把瀏覽器的請求透傳給伺服器。

  • no-cache,瀏覽器不做快取檢查。

每次訪問資源,瀏覽器都要向伺服器詢問,如果檔案沒變化,伺服器只告訴瀏覽器繼續使用快取(304)。

  • no-store,瀏覽器和中間代理伺服器都不能快取資源。

每次訪問資源,瀏覽器都必須請求伺服器,並且,伺服器不去檢查檔案是否變化,而是直接返回完整的資源。

  • must-revalidate,可以快取,但是使用之前必須先向源伺服器確認。
  • proxy-revalidate,要求快取伺服器針對快取資源向源伺服器進行確認。
  • s-maxage:快取伺服器對資源快取的最大時間。

Cache-Control 對快取的控制粒度更細,包括快取代理伺服器的快取控制。

相關文章