一文讀懂前端快取

小蘑菇哥哥發表於2018-12-26

大家都知道快取的英文叫做 cache。但我發現一個有趣的現象:這個單詞在不同人的口中有不同的讀音。為了全面瞭解快取,我們得先從讀音開始,這樣才能夠在和其他同事(例如 PM)交(zhuāng)流(bī)時體現自己的修(bī)養(gé)。

友情提示:文章有些長,您可能需要分批次讀完,當中可以喝個咖啡或者啤酒當作中場休息。

cache 怎麼念

在國外 IT 圈和大部分國外視訊中,cache 的發音是 /kæʃ/(同 cash),這也是一個廣泛認可的發音。但我發現在中國的 IT 圈還有相當一部分程式設計師(比如我自己……)讀作 /kætʃ/(同 catch)。雖然不太正確,但並不妨礙互相交流。(不過為了純正,還是應該向正確的方向靠攏)

此外還有一些小眾的讀法,例如 /keɪʃ/(同 kaysh),甚至 /kæʃeɪ/(像個法語發音,重音在後面)。這些因為太小眾了,可能會引起溝通障礙,估計只有在特定場合或者特定圈子才能順暢使用。

前端快取/後端快取

扯了些沒用的,我們先進入定義環節:什麼是前端快取?與之相對的什麼又是後端快取?

request&response

基本的網路請求就是三個步驟:請求,處理,響應。

後端快取主要集中於“處理”步驟,通過保留資料庫連線,儲存處理結果等方式縮短處理時間,儘快進入“響應”步驟。當然這不在本文的討論範圍之內。

而前端快取則可以在剩下的兩步:“請求”和“響應”中進行。在“請求”步驟中,瀏覽器也可以通過儲存結果的方式直接使用資源,直接省去了傳送請求;而“響應”步驟需要瀏覽器和伺服器共同配合,通過減少響應內容來縮短傳輸時間。這些都會在下面進行討論。

本文主要包含

  • 按快取位置分類 (memory cache, disk cache, Service Worker 等)
  • 按失效策略分類 (Cache-Control, ETag 等)
  • 幫助理解原理的一些案例
  • 快取的應用模式

按快取位置分類

我看過的大部分討論快取的文章會直接從 HTTP 協議頭中的快取欄位開始,例如 Cache-Control, ETag, max-age 等。但偶爾也會聽到別人討論 memory cache, disk cache 等。那這兩種分類體系究竟有何關聯?是否有交叉?(我個人認為這是本文的最大價值所在,因為在寫之前我自己也是被兩種分類體系搞的一團糟)

實際上,HTTP 協議頭的那些欄位,都屬於 disk cache 的範疇,是幾個快取位置的其中之一。因此本著從全域性到區域性的原則,我們應當先從快取位置開始討論。等講到 disk cache 時,才會詳細講述這些協議頭的欄位及其作用。

我們可以在 Chrome 的開發者工具中,Network -> Size 一列看到一個請求最終的處理方式:如果是大小 (多少 K, 多少 M 等) 就表示是網路請求,否則會列出 from memory cache, from disk cachefrom ServiceWorker

它們的優先順序是:(由上到下尋找,找到即返回;找不到則繼續)

  1. Service Worker
  2. Memory Cache
  3. Disk Cache
  4. 網路請求

memory cache

memory cache 是記憶體中的快取,(與之相對 disk cache 就是硬碟上的快取)。按照作業系統的常理:先讀記憶體,再讀硬碟。disk cache 將在後面介紹 (因為它的優先順序更低一些),這裡先討論 memory cache。

幾乎所有的網路請求資源都會被瀏覽器自動加入到 memory cache 中。但是也正因為數量很大但是瀏覽器佔用的記憶體不能無限擴大這樣兩個因素,memory cache 註定只能是個“短期儲存”。常規情況下,瀏覽器的 TAB 關閉後該次瀏覽的 memory cache 便告失效 (為了給其他 TAB 騰出位置)。而如果極端情況下 (例如一個頁面的快取就佔用了超級多的記憶體),那可能在 TAB 沒關閉之前,排在前面的快取就已經失效了。

剛才提過,幾乎所有的請求資源 都能進入 memory cache,這裡細分一下主要有兩塊:

  1. preloader。如果你對這個機制不太瞭解,這裡做一個簡單的介紹,詳情可以參閱這篇文章

    熟悉瀏覽器處理流程的同學們應該瞭解,在瀏覽器開啟網頁的過程中,會先請求 HTML 然後解析。之後如果瀏覽器發現了 js, css 等需要解析和執行的資源時,它會使用 CPU 資源對它們進行解析和執行。在古老的年代(大約 2007 年以前),“請求 js/css - 解析執行 - 請求下一個 js/css - 解析執行下一個 js/css” 這樣的“序列”操作模式在每次開啟頁面之前進行著。很明顯在解析執行的時候,網路請求是空閒的,這就有了發揮的空間:我們能不能一邊解析執行 js/css,一邊去請求下一個(或下一批)資源呢?

    這就是 preloader 要做的事情。不過 preloader 沒有一個官方標準,所以每個瀏覽器的處理都略有區別。例如有些瀏覽器還會下載 css 中的 @import 內容或者 <video>poster等。

    而這些被 preloader 請求夠來的資源就會被放入 memory cache 中,供之後的解析執行操作使用。

  2. preload (雖然看上去和剛才的 preloader 就差了倆字母)。實際上這個大家應該更加熟悉一些,例如 <link rel="preload">。這些顯式指定的預載入資源,也會被放入 memory cache 中。

memory cache 機制保證了一個頁面中如果有兩個相同的請求 (例如兩個 src 相同的 <img>,兩個 href 相同的 <link>)都實際只會被請求最多一次,避免浪費。

不過在匹配快取時,除了匹配完全相同的 URL 之外,還會比對他們的型別,CORS 中的域名規則等。因此一個作為指令碼 (script) 型別被快取的資源是不能用在圖片 (image) 型別的請求中的,即便他們 src 相等。

在從 memory cache 獲取快取內容時,瀏覽器會忽視例如 max-age=0, no-cache 等頭部配置。例如頁面上存在幾個相同 src 的圖片,即便它們可能被設定為不快取,但依然會從 memory cache 中讀取。這是因為 memory cache 只是短期使用,大部分情況生命週期只有一次瀏覽而已。而 max-age=0 在語義上普遍被解讀為“不要在下次瀏覽時使用”,所以和 memory cache 並不衝突。

但如果站長是真心不想讓一個資源進入快取,就連短期也不行,那就需要使用 no-store。存在這個頭部配置的話,即便是 memory cache 也不會儲存,自然也不會從中讀取了。(後面的第二個示例有關於這點的體現)

disk cache

disk cache 也叫 HTTP cache,顧名思義是儲存在硬碟上的快取,因此它是持久儲存的,是實際存在於檔案系統中的。而且它允許相同的資源在跨會話,甚至跨站點的情況下使用,例如兩個站點都使用了同一張圖片。

disk cache 會嚴格根據 HTTP 頭資訊中的各類欄位來判定哪些資源可以快取,哪些資源不可以快取;哪些資源是仍然可用的,哪些資源是過時需要重新請求的。當命中快取之後,瀏覽器會從硬碟中讀取資源,雖然比起從記憶體中讀取慢了一些,但比起網路請求還是快了不少的。絕大部分的快取都來自 disk cache。

關於 HTTP 的協議頭中的快取欄位,我們會在稍後進行詳細討論。

凡是永續性儲存都會面臨容量增長的問題,disk cache 也不例外。在瀏覽器自動清理時,會有神祕的演算法去把“最老的”或者“最可能過時的”資源刪除,因此是一個一個刪除的。不過每個瀏覽器識別“最老的”和“最可能過時的”資源的演算法不盡相同,可能也是它們差異性的體現。

Service Worker

上述的快取策略以及快取/讀取/失效的動作都是由瀏覽器內部判斷 & 進行的,我們只能設定響應頭的某些欄位來告訴瀏覽器,而不能自己操作。舉個生活中去銀行存/取錢的例子來說,你只能告訴銀行職員,我要存/取多少錢,然後把由他們會經過一系列的記錄和手續之後,把錢放到金庫中去,或者從金庫中取出錢來交給你。

但 Service Worker 的出現,給予了我們另外一種更加靈活,更加直接的操作方式。依然以存/取錢為例,我們現在可以繞開銀行職員,自己走到金庫前(當然是有別於上述金庫的一個單獨的小金庫),自己把錢放進去或者取出來。因此我們可以選擇放哪些錢(快取哪些檔案),什麼情況把錢取出來(路由匹配規則),取哪些錢出來(快取匹配並返回)。當然現實中銀行沒有給我們開放這樣的服務

Service Worker 能夠操作的快取是有別於瀏覽器內部的 memory cache 或者 disk cache 的。我們可以從 Chrome 的 F12 中,Application -> Cache Storage 找到這個單獨的“小金庫”。除了位置不同之外,這個快取是永久性的,即關閉 TAB 或者瀏覽器,下次開啟依然還在(而 memory cache 不是)。有兩種情況會導致這個快取中的資源被清除:手動呼叫 API cache.delete(resource) 或者容量超過限制,被瀏覽器全部清空。

如果 Service Worker 沒能命中快取,一般情況會使用 fetch() 方法繼續獲取資源。這時候,瀏覽器就去 memory cache 或者 disk cache 進行下一次找快取的工作了。注意:經過 Service Worker 的 fetch() 方法獲取的資源,即便它並沒有命中 Service Worker 快取,甚至實際走了網路請求,也會標註為 from ServiceWorker。這個情況在後面的第三個示例中有所體現。

請求網路

如果一個請求在上述 3 個位置都沒有找到快取,那麼瀏覽器會正式傳送網路請求去獲取內容。之後容易想到,為了提升之後請求的快取命中率,自然要把這個資源新增到快取中去。具體來說:

  1. 根據 Service Worker 中的 handler 決定是否存入 Cache Storage (額外的快取位置)。
  2. 根據 HTTP 頭部的相關欄位(Cache-control, Pragma 等)決定是否存入 disk cache
  3. memory cache 儲存一份資源 的引用,以備下次使用。

按失效策略分類

memory cache 是瀏覽器為了加快讀取快取速度而進行的自身的優化行為,不受開發者控制,也不受 HTTP 協議頭的約束,算是一個黑盒。Service Worker 是由開發者編寫的額外的指令碼,且快取位置獨立,出現也較晚,使用還不算太廣泛。所以我們平時最為熟悉的其實是 disk cache,也叫 HTTP cache (因為不像 memory cache,它遵守 HTTP 協議頭中的欄位)。平時所說的強制快取,對比快取,以及 Cache-Control 等,也都歸於此類。

強制快取 (也叫強快取)

強制快取的含義是,當客戶端請求後,會先訪問快取資料庫看快取是否存在。如果存在則直接返回;不存在則請求真的伺服器,響應後再寫入快取資料庫。

強制快取直接減少請求數,是提升最大的快取策略。 它的優化覆蓋了文章開頭提到過的請求資料的全部三個步驟。如果考慮使用快取來優化網頁效能的話,強制快取應該是首先被考慮的。

可以造成強制快取的欄位是 Cache-controlExpires

Expires

這是 HTTP 1.0 的欄位,表示快取到期時間,是一個絕對的時間 (當前時間+快取時間),如

Expires: Thu, 10 Nov 2017 08:45:11 GMT
複製程式碼

在響應訊息頭中,設定這個欄位之後,就可以告訴瀏覽器,在未過期之前不需要再次請求。

但是,這個欄位設定時有兩個缺點:

  1. 由於是絕對時間,使用者可能會將客戶端本地的時間進行修改,而導致瀏覽器判斷快取失效,重新請求該資源。此外,即使不考慮自信修改,時差或者誤差等因素也可能造成客戶端與服務端的時間不一致,致使快取失效。

  2. 寫法太複雜了。表示時間的字串多個空格,少個字母,都會導致非法屬性從而設定失效。

Cache-control

已知Expires的缺點之後,在HTTP/1.1中,增加了一個欄位Cache-control,該欄位表示資源快取的最大有效時間,在該時間內,客戶端不需要向伺服器傳送請求

這兩者的區別就是前者是絕對時間,而後者是相對時間。如下:

Cache-control: max-age=2592000
複製程式碼

下面列舉一些 Cache-control 欄位常用的值:(完整的列表可以檢視 MDN)

  • max-age:即最大有效時間,在上面的例子中我們可以看到
  • must-revalidate:如果超過了 max-age 的時間,瀏覽器必須向伺服器傳送請求,驗證資源是否還有效。
  • no-cache:雖然字面意思是“不要快取”,但實際上還是要求客戶端快取內容的,只是是否使用這個內容由後續的對比來決定。
  • no-store: 真正意義上的“不要快取”。所有內容都不走快取,包括強制和對比。
  • public:所有的內容都可以被快取 (包括客戶端和代理伺服器, 如 CDN)
  • private:所有的內容只有客戶端才可以快取,代理伺服器不能快取。預設值。

這些值可以混合使用,例如 Cache-control:public, max-age=2592000。在混合使用時,它們的優先順序如下圖:(圖片來自 developers.google.com/web/fundame…)

cache-control

這裡有一個疑問:max-age=0no-cache 等價嗎?從規範的字面意思來說,max-age 到期是 應該(SHOULD) 重新驗證,而 no-cache必須(MUST) 重新驗證。但實際情況以瀏覽器實現為準,大部分情況他們倆的行為還是一致的。(如果是 max-age=0, must-revalidate 就和 no-cache 等價了)

順帶一提,在 HTTP/1.1 之前,如果想使用 no-cache,通常是使用 Pragma 欄位,如 Pragma: no-cache(這也是 Pragma 欄位唯一的取值)。但是這個欄位只是瀏覽器約定俗成的實現,並沒有確切規範,因此缺乏可靠性。它應該只作為一個相容欄位出現,在當前的網路環境下其實用處已經很小。

總結一下,自從 HTTP/1.1 開始,Expires 逐漸被 Cache-control 取代。Cache-control 是一個相對時間,即使客戶端時間發生改變,相對時間也不會隨之改變,這樣可以保持伺服器和客戶端的時間一致性。而且 Cache-control 的可配置性比較強大。

Cache-control 的優先順序高於 Expires,為了相容 HTTP/1.0 和 HTTP/1.1,實際專案中兩個欄位我們都會設定。

對比快取 (也叫協商快取)

當強制快取失效(超過規定時間)時,就需要使用對比快取,由伺服器決定快取內容是否失效。

流程上說,瀏覽器先請求快取資料庫,返回一個快取標識。之後瀏覽器拿這個標識和伺服器通訊。如果快取未失效,則返回 HTTP 狀態碼 304 表示繼續使用,於是客戶端繼續使用快取;如果失效,則返回新的資料和快取規則,瀏覽器響應資料後,再把規則寫入到快取資料庫。

對比快取在請求數上和沒有快取是一致的,但如果是 304 的話,返回的僅僅是一個狀態碼而已,並沒有實際的檔案內容,因此 在響應體體積上的節省是它的優化點。它的優化覆蓋了文章開頭提到過的請求資料的三個步驟中的最後一個:“響應”。通過減少響應體體積,來縮短網路傳輸時間。所以和強制快取相比提升幅度較小,但總比沒有快取好。

對比快取是可以和強制快取一起使用的,作為在強制快取失效後的一種後備方案。實際專案中他們也的確經常一同出現。

對比快取有 2 組欄位(不是兩個):

Last-Modified & If-Modified-Since

  1. 伺服器通過 Last-Modified 欄位告知客戶端,資源最後一次被修改的時間,例如

    Last-Modified: Mon, 10 Nov 2018 09:10:11 GMT
    複製程式碼
  2. 瀏覽器將這個值和內容一起記錄在快取資料庫中。

  3. 下一次請求相同資源時時,瀏覽器從自己的快取中找出“不確定是否過期的”快取。因此在請求頭中將上次的 Last-Modified 的值寫入到請求頭的 If-Modified-Since 欄位

  4. 伺服器會將 If-Modified-Since 的值與 Last-Modified 欄位進行對比。如果相等,則表示未修改,響應 304;反之,則表示修改了,響應 200 狀態碼,並返回資料。

但是他還是有一定缺陷的:

  • 如果資源更新的速度是秒以下單位,那麼該快取是不能被使用的,因為它的時間單位最低是秒。
  • 如果檔案是通過伺服器動態生成的,那麼該方法的更新時間永遠是生成的時間,儘管檔案可能沒有變化,所以起不到快取的作用。

Etag & If-None-Match

為了解決上述問題,出現了一組新的欄位 EtagIf-None-Match

Etag 儲存的是檔案的特殊標識(一般都是 hash 生成的),伺服器儲存著檔案的 Etag 欄位。之後的流程和 Last-Modified 一致,只是 Last-Modified 欄位和它所表示的更新時間改變成了 Etag 欄位和它所表示的檔案 hash,把 If-Modified-Since 變成了 If-None-Match。伺服器同樣進行比較,命中返回 304, 不命中返回新資源和 200。

Etag 的優先順序高於 Last-Modified

快取小結

當瀏覽器要請求資源時

  1. 呼叫 Service Worker 的 fetch 事件響應
  2. 檢視 memory cache
  3. 檢視 disk cache。這裡又細分:
    1. 如果有強制快取且未失效,則使用強制快取,不請求伺服器。這時的狀態碼全部是 200
    2. 如果有強制快取但已失效,使用對比快取,比較後確定 304 還是 200
  4. 傳送網路請求,等待網路響應
  5. 把響應內容存入 disk cache (如果 HTTP 頭資訊配置可以存的話)
  6. 把響應內容 的引用 存入 memory cache (無視 HTTP 頭資訊的配置)
  7. 把響應內容存入 Service Worker 的 Cache Storage (如果 Service Worker 的指令碼呼叫了 cache.put())

一些案例

光看原理不免枯燥。我們編寫一些簡單的網頁,通過案例來深刻理解上面的那些原理。

1. memory cache & disk cache

我們寫一個簡單的 index.html,然後引用 3 種資源,分別是 index.js, index.cssmashroom.jpg

我們給這三種資源都設定上 Cache-control: max-age=86400,表示強制快取 24 小時。以下截圖全部使用 Chrome 的隱身模式。

  1. 首次請求

    First request

    毫無意外的全部走網路請求,因為什麼快取都還沒有。

  2. 再次請求 (F5)

    Refresh

    第二次請求,三個請求都來自 memory cache。因為我們沒有關閉 TAB,所以瀏覽器把快取的應用加到了 memory cache。(耗時 0ms,也就是 1ms 以內)

  3. 關閉 TAB,開啟新 TAB 並再次請求

    Reopen

    因為關閉了 TAB,memory cache 也隨之清空。但是 disk cache 是持久的,於是所有資源來自 disk cache。(大約耗時 3ms,因為檔案有點小)

    而且對比 2 和 3,很明顯看到 memory cache 還是比 disk cache 快得多的。

2. no-cache & no-store

我們在 index.html 裡面一些程式碼,完成兩個目標:

  • 每種資源都(同步)請求兩次
  • 增加指令碼非同步請求圖片
<!-- 把3種資源都改成請求兩次 -->
<link rel="stylesheet" href="/static/index.css">
<link rel="stylesheet" href="/static/index.css">
<script src="/static/index.js"></script>
<script src="/static/index.js"></script>
<img src="/static/mashroom.jpg">
<img src="/static/mashroom.jpg">

<!-- 非同步請求圖片 -->
<script>
    setTimeout(function () {
        let img = document.createElement('img')
        img.src = '/static/mashroom.jpg'
        document.body.appendChild(img)
    }, 1000)
</script>
複製程式碼
  1. 當把伺服器響應設定為 Cache-Control: no-cache 時,我們發現開啟頁面之後,三種資源都只被請求 1 次。

    只請求一次

    只請求一次

    這說明兩個問題:

    • 同步請求方面,瀏覽器會自動把當次 HTML 中的資源存入到快取 (memory cache),這樣碰到相同 src 的圖片就會自動讀取快取(但不會在 Network 中顯示出來)

    • 非同步請求方面,瀏覽器同樣是不發請求而直接讀取快取返回。但同樣不會在 Network 中顯示。

    總體來說,如上面原理所述,no-cache 從語義上表示下次請求不要直接使用快取而需要比對,並不對本次請求進行限制。因此瀏覽器在處理當前頁面時,可以放心使用快取。

  2. 當把伺服器響應設定為 Cache-Control: no-store 時,情況發生了變化,三種資源都被請求了 2 次。而圖片因為還多一次非同步請求,總計 3 次。(紅框中的都是那一次非同步請求)

    請求多次

    請求多次

    這同樣說明:

    • 如之前原理所述,雖然 memory cache 是無視 HTTP 頭資訊的,但是 no-store 是特別的。在這個設定下,memory cache 也不得不每次都請求資源。

    • 非同步請求和同步遵循相同的規則,在 no-store 情況下,依然是每次都傳送請求,不進行任何快取。

3. Service Worker & memory (disk) cache

我們嘗試把 Service Worker 也加入進去。我們編寫一個 serviceWorker.js,並編寫如下內容:(主要是預快取 3 個資源,並在實際請求時匹配快取並返回)

// serviceWorker.js
self.addEventListener('install', e => {
  // 當確定要訪問某些資源時,提前請求並新增到快取中。
  // 這個模式叫做“預快取”
  e.waitUntil(
    caches.open('service-worker-test-precache').then(cache => {
      return cache.addAll(['/static/index.js', '/static/index.css', '/static/mashroom.jpg'])
    })
  )
})

self.addEventListener('fetch', e => {
  // 快取中能找到就返回,找不到就網路請求,之後再寫入快取並返回。
  // 這個稱為 CacheFirst 的快取策略。
  return e.respondWith(
    caches.open('service-worker-test-precache').then(cache => {
      return cache.match(e.request).then(matchedResponse => {
        return matchedResponse || fetch(e.request).then(fetchedResponse => {
          cache.put(e.request, fetchedResponse.clone())
          return fetchedResponse
        })
      })
    })
  )
})
複製程式碼

註冊 SW 的程式碼這裡就不贅述了。此外我們還給伺服器設定 Cache-Control: max-age=86400 來開啟 disk cache。我們的目的是看看兩者的優先順序。

  1. 當我們首次訪問時,會看到常規請求之外,瀏覽器(確切地說是 Service Worker)額外發出了 3 個請求。這來自預快取的程式碼。

    預快取

  2. 第二次訪問(無論關閉 TAB 重新開啟,還是直接按 F5 重新整理)都能看到所有的請求標記為 from SerciceWorker

    快取命中

    from ServiceWorker 只表示請求通過了 Service Worker,至於到底是命中了快取,還是繼續 fetch() 方法光看這一條記錄其實無從知曉。因此我們還得配合後續的 Network 記錄來看。因為之後沒有額外的請求了,因此判定是命中了快取。

    Service Worker 伺服器

    從伺服器的日誌也能很明顯地看到,3 個資源都沒有被重新請求,即命中了 Service Worker 內部的快取。

  3. 如果修改 serviceWorker.jsfetch 事件監聽程式碼,改為如下:

    // 這個也叫做 NetworkOnly 的快取策略。
    self.addEventListener('fetch', e => {
      return e.respondWith(fetch(e.request))
    })
    複製程式碼

    可以發現在後續訪問時的效果和修改前是 完全一致的。(即 Network 僅有標記為 from ServiceWorker 的幾個請求,而伺服器也不列印 3 個資源的訪問日誌)

    很明顯 Service Worker 這層並沒有去讀取自己的快取,而是直接使用 fetch() 進行請求。所以此時其實是 Cache-Control: max-age=86400 的設定起了作用,也就是 memory/disk cache。但具體是 memory 還是 disk 這個只有瀏覽器自己知道了,因為它並沒有顯式的告訴我們。(個人猜測是 memory,因為不論從耗時 0ms 還是從不關閉 TAB 來看,都更像是 memory cache)

瀏覽器的行為

所謂瀏覽器的行為,指的就是使用者在瀏覽器如何操作時,會觸發怎樣的快取策略。主要有 3 種:

  • 開啟網頁,位址列輸入地址: 查詢 disk cache 中是否有匹配。如有則使用;如沒有則傳送網路請求。
  • 普通重新整理 (F5):因為 TAB 並沒有關閉,因此 memory cache 是可用的,會被優先使用(如果匹配的話)。其次才是 disk cache。
  • 強制重新整理 (Ctrl + F5):瀏覽器不使用快取,因此傳送的請求頭部均帶有 Cache-control: no-cache(為了相容,還帶了 Pragma: no-cache)。伺服器直接返回 200 和最新內容。

快取的應用模式

瞭解了快取的原理,我們可能更加關心如何在實際專案中使用它們,才能更好的讓使用者縮短載入時間,節約流量等。這裡有幾個常用的模式,供大家參考

模式 1:不常變化的資源

Cache-Control: max-age=31536000
複製程式碼

通常在處理這類資源資源時,給它們的 Cache-Control 配置一個很大的 max-age=31536000 (一年),這樣瀏覽器之後請求相同的 URL 會命中強制快取。而為了解決更新的問題,就需要在檔名(或者路徑)中新增 hash, 版本號等動態字元,之後更改動態字元,達到更改引用 URL 的目的,從而讓之前的強制快取失效 (其實並未立即失效,只是不再使用了而已)。

線上提供的類庫 (如 jquery-3.3.1.min.js, lodash.min.js 等) 均採用這個模式。如果配置中還增加 public 的話,CDN 也可以快取起來,效果拔群。

這個模式的一個變體是在引用 URL 後面新增引數 (例如 ?v=xxx 或者 ?_=xxx),這樣就不必在檔名或者路徑中包含動態引數,滿足某些完美主義者的喜好。在專案每次構建時,更新額外的引數 (例如設定為構建時的當前時間),則能保證每次構建後總能讓瀏覽器請求最新的內容。

特別注意: 在處理 Service Worker 時,對待 sw-register.js(註冊 Service Worker) 和 serviceWorker.js (Service Worker 本身) 需要格外的謹慎。如果這兩個檔案也使用這種模式,你必須多多考慮日後可能的更新及對策。

模式 2:經常變化的資源

Cache-Control: no-cache
複製程式碼

這裡的資源不單單指靜態資源,也可能是網頁資源,例如部落格文章。這類資源的特點是:URL 不能變化,但內容可以(且經常)變化。我們可以設定 Cache-Control: no-cache 來迫使瀏覽器每次請求都必須找伺服器驗證資源是否有效。

既然提到了驗證,就必須 ETag 或者 Last-Modified 出場。這些欄位都會由專門處理靜態資源的常用類庫(例如 koa-static)自動新增,無需開發者過多關心。

也正如上文中提到協商快取那樣,這種模式下,節省的並不是請求數,而是請求體的大小。所以它的優化效果不如模式 1 來的顯著。

模式 3:非常危險的模式 1 和 2 的結合 (反例)

Cache-Control: max-age=600, must-revalidate
複製程式碼

不知道是否有開發者從模式 1 和 2 獲得一些啟發:模式 2 中,設定了 no-cache,相當於 max-age=0, must-revalidate。我的應用時效性沒有那麼強,但又不想做過於長久的強制快取,我能不能配置例如 max-age=600, must-revalidate 這樣折中的設定呢?

表面上看這很美好:資源可以快取 10 分鐘,10 分鐘內讀取快取,10 分鐘後和伺服器進行一次驗證,集兩種模式之大成,但實際線上暗存風險。因為上面提過,瀏覽器的快取有自動清理機制,開發者並不能控制。

舉個例子:當我們有 3 種資源: index.html, index.js, index.css。我們對這 3 者進行上述配置之後,假設在某次訪問時,index.js 已經被快取清理而不存在,但 index.html, index.css 仍然存在於快取中。這時候瀏覽器會向伺服器請求新的 index.js,然後配上老的 index.html, index.css 展現給使用者。這其中的風險顯而易見:不同版本的資源組合在一起,報錯是極有可能的結局。

除了自動清理引發問題,不同資源的請求時間不同也能導致問題。例如 A 頁面請求的是 A.jsall.css,而 B 頁面是 B.jsall.css。如果我們以 A -> B 的順序訪問頁面,勢必導致 all.css 的快取時間早於 B.js。那麼以後訪問 B 頁面就同樣存在資源版本失配的隱患。


有開發者朋友(wd2010)在知乎的評論區提了一個很好的問題:

如果我不使用must-revalidate,只是Cache-Control: max-age=600,瀏覽器快取的自動清理機制就不會執行麼?如果瀏覽器快取的自動清理機制執行的話那後續的index.js被清掉的所引發的情況都是一樣的呀!

這個問題涉及幾個小點,我補充說明一下:

  1. 'max-age=600' 和 'max-age=600,must-revalidate' 有什麼區別?

    沒有區別。在列出 max-age 了之後,must-revalidate 是否列出效果相同,瀏覽器都會在超過 max-age 之後進行校驗,驗證快取是否可用。

    在 HTTP 的規範中,只闡述了 must-revalidate 的作用,卻沒有闡述不列出 must-revalidate 時,瀏覽器應該如何解決快取過期的問題,因此這其實是瀏覽器實現時的自主決策。(可能有少數瀏覽器選擇在源站點無法訪問時繼續使用過期快取,但這取決於瀏覽器自身)

  2. 那 'max-age=600' 是不是也會引發問題?

    是的。問題的出現和是否列出 'must-revalidate' 無關,依然會存在 JS CSS等檔案版本失配的問題。因此常規的網站在不同頁面需要使用不同的 JS CSS 檔案時,如果要使用 max-age 做強快取,不要設定一個太短的時間。

  3. 那這類比較短的 max-age 到底能用在哪裡呢?

    既然版本存在失配的問題,那麼要避開這個問題,就有兩種方法。

    1. 整站都使用相同的 JS 和 CSS,即合併後的檔案。這個比較適合小型站點,否則可能過於冗餘,影響效能。(不過可能還是會因為瀏覽器自身的清理策略被清理,依然有隱患)

    2. 資源是獨立使用的,並不需要和其他檔案配合生效。例如 RSS 就歸在此類。


後記

這篇文章真心有點長,但已經囊括了前端快取的絕大部分,包括 HTTP 協議中的快取,Service Worker,以及 Chrome 瀏覽器的一些優化 (Memory Cache)。希望開發者們善用快取,因為它往往是最容易想到,提升也最大的效能優化策略。

參考文章

A Tale of Four Caches (但這篇文章把 Service Worker 的優先順序排在 memory cache 和 disk cache 之間,跟我實驗效果並不相符。懷疑可能是 2 年來 chrome 策略的修改?)

Caching best practices & max-age gotchas

相關文章