Skip to content

瀏覽器依照資源新鮮度直接使用快取,或向伺服器重新驗證並更新快取的流程

瀏覽器快取機制有哪些? ​

上一篇提到,Vary: Accept-Encoding 會讓 cache 依 request 的 Accept-Encoding 區分不同壓縮版本。那麼瀏覽器到底會把 response 保存多久?過期後又怎麼知道內容有沒有更新?這篇就接著把 HTTP Cache 的判斷流程拆開來看。

先講一下,快取大致可以依照所在位置分成 Client Cache、Network Cache 與 Server Cache。Client Cache 位於使用者端,除了瀏覽器的 HTTP Cache,也包含由 Service Worker 使用的 Cache Storage;Network Cache 常見的是 CDN Cache 等 shared cache;Server Cache 則可以 Redis 為例。

這篇先從 Client Cache 裡的 HTTP Caching 開始看,理解瀏覽器如何判斷快取是否仍然新鮮,以及快取過期後怎麼確認資源有沒有更新。

這裡也先把幾個容易混在一起的名稱分開:Memory Cache 與 Disk Cache 是瀏覽器實作 HTTP Cache 時可能採用的儲存位置;Cache Storage 是由網站透過 Cache API 管理的 request/response storage,常與 Service Worker 搭配;BFCache 則是為上一頁、下一頁 Navigation 保留整個文件狀態。它們的生命週期與使用目的都不同,不能只因為名稱裡都有 Cache,就當成同一套機制。

快取有效時間 ​

假如每次開啟頁面都重新下載所有 CSS、JS 和圖片,當然可以拿到最新版本,但 request 數量與傳輸量也會一起增加。只要 response 還在 freshness lifetime 內,瀏覽器就可以直接沿用 cache,這就是快取能幫忙的地方。

不過 Expires 和 Cache-Control 不是同一時間出現的。Expires 是 HTTP/1.0 時代用來描述快取到期時間的 header;到了 HTTP/1.1,為了解決 Expires 對絕對時間、時間格式與系統時鐘的依賴,也希望能描述更多快取規則,才引入 Cache-Control。所以這裡會先看 Expires,再看後來的 Cache-Control。

只要 response 有設定快取期限,瀏覽器就能在資源仍然 fresh 的期間直接重用,減少重複發送 request。假如同一個 response 同時帶有 Cache-Control: max-age 和 Expires,一般會優先使用 max-age;對 shared cache 來說,如果有設定 s-maxage,則會優先依照 s-maxage 判斷。

Expires ​

Expires 的做法很直覺:伺服器回傳一個固定日期,告訴瀏覽器「這份 response 到這個時間以前都可以使用」。開啟 DevTools 的 Network 面板後,如果 response 有設定 Expires,呈現方式會像下面這樣:

http
Expires: Wed, 21 Oct 2015 07:28:00 GMT

這個做法的問題在於,瀏覽器要拿固定日期跟現在時間比較。假如使用者電腦的系統時間被調整,或實作對 HTTP-date 的解析不一致,就可能提早或延後判斷過期。另外,日期格式比單純的秒數複雜,處理起來也比較容易遇到相容性問題。Expires 不是不能用,只是控制方式比較單一,也比較依賴時間點。

Cache-Control ​

承接前面的問題,Cache-Control 通常會搭配 max-age 使用,改用經過的秒數描述 freshness lifetime。假如設定為 60,可以先簡單理解為這個 response 在 60 秒內是 fresh;只要 request 沒有額外要求重新驗證,cache 就可以直接重用。超過之後,才會進入 revalidation 或重新下載資源。

http
Cache-Control: max-age=60

而且 Cache-Control 不只處理快取有效時間,還能透過不同 directive 控制快取行為。常用的有 no-store、no-cache、private、public 與 s-maxage。這裡要特別注意,no-cache 不是「不要儲存」,而是重用前必須先重新驗證;真的要禁止儲存才是使用 no-store。

簡單來說,Expires 只是在回答「到什麼時間以前可以使用」,Cache-Control 則能一起交代「可以用多久、誰可以使用,以及使用前要不要重新驗證」。

快取過期後重新取得資料 ​

當 cache 還是 fresh 時,瀏覽器通常不需要重新發送 request;但 freshness lifetime 到期後,就不能直接假設原本的 response 還是最新版本。最簡單的做法是重新 GET 一份完整資源,只是如果內容根本沒有變,這樣就會多傳一份完全相同的資料。

所以這時可以使用 conditional request。簡單來說,就是先拿著手上的 validator 問伺服器:「我這份還能不能繼續用?」如果資源沒變,伺服器回傳 304 Not Modified,瀏覽器就沿用本地的 cache;如果有變,才回傳 200 OK 和新內容。常見的 validator 就是 Last-Modified 和 ETag。

Last-Modified & If-Modified-Since ​

先看比較直覺的 Last-Modified。伺服器會在 response header 告訴瀏覽器資源最後修改的時間;瀏覽器之後在 conditional request 帶上 If-Modified-Since,請伺服器確認「這份資源在這個時間之後有沒有變過」。

http
HTTP/1.1 200 OK
Last-Modified: Sat, 11 Sep 2021 07:28:00 GMT
Cache-Control: max-age=31536000

31536000 秒大約是一年。當快取的 freshness lifetime 到期後,瀏覽器可以帶上 If-Modified-Since 發出 conditional request:

http
GET /ironman-logo.png HTTP/1.1
Host: example.com
If-Modified-Since: Sat, 11 Sep 2021 07:28:00 GMT

如果資源有更新,伺服器會回傳 200 OK 與新的內容;如果沒有更新,則會回傳 304 Not Modified。304 不包含 response body,瀏覽器收到後就能繼續沿用原本儲存的 cache response。

這種方式的限制是,它比對的是時間戳記,不是檔案內容。假如伺服器是依照檔案系統的修改時間產生這個欄位,我只是把檔案打開後另存新檔,即使內容沒有實質變化,修改時間仍可能改變,最後還是會讓伺服器重新回傳一份完整資料。

ETag & If-None-Match ​

如果不想只依賴時間,也可以使用 ETag。它是伺服器為某個資源版本產生的 identifier,產生方式由伺服器決定,可以是內容的 hash、版本號或其他值,不一定直接來自檔案內容。假如伺服器使用內容 hash,只要空白或標點符號改變,通常就會產生不同的 ETag;但這是伺服器實作的選擇,不是 HTTP 規格強制的做法。

ETag 會放在伺服器的 response header 中,讓 cache 將它和資源一起儲存起來。你可以把它想成資源版本的 fingerprint:

http
HTTP/1.1 200 OK
Cache-Control: max-age=86400
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

等到 max-age 到期後,瀏覽器會把原本的 ETag 值放進 If-None-Match,再交給伺服器確認:

http
GET /ironman-logo.png HTTP/1.1
Host: example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

如果伺服器判斷目前資源的 ETag 相同,就會回傳 304 Not Modified;如果不同,則會回傳 200 OK 與最新版本。實務上 response 也常會同時提供 ETag 與 Last-Modified;如果 request 同時帶上 If-None-Match 和 If-Modified-Since,通常會優先使用 If-None-Match。

不過這裡要注意,ETag 和 Last-Modified 都不是免 request。revalidation 仍然會有網路往返與伺服器處理的成本,只是 304 不需要重新傳送 response body,傳輸量會比完整回傳資源小很多。

所以 revalidation 比較像是「先問伺服器,再決定要不要沿用本地版本」:它可以省下完整資源的傳輸量,但不代表完全沒有等待時間。

下圖把前面提到的判斷流程整理在一起:

HTTP 快取依照 Cache-Control 或 Expires 判斷是否新鮮,過期後再以 ETag 或 If-Modified-Since 驗證的流程圖

Cache Busting ​

前面提到的快取策略,可能會讓 CSS 或 JS 檔案更新後,瀏覽器仍然沿用同一個 URL 的舊版本。這時可以使用 Cache Busting,在檔案內容更新時同步改變資源的 URL,讓瀏覽器把它當成新的資源重新下載。

以 Vite 為例,經由 build 處理的 assets 通常會產生帶有 hash 的檔名。只要內容變更,檔名就會跟著變,瀏覽器也會把它視為新的 URL,重新下載新的資源。若檔案是放在 public 目錄,則不會套用這個自動命名流程,仍需要自行處理版本化或快取策略。

總結 ​

簡單來說,快取就是讓瀏覽器在資源還沒過期、可以直接沿用時,不用每次都重新下載。Expires 是比較早期的做法,透過固定時間判斷是否到期;後來 HTTP/1.1 引入 Cache-Control,用 max-age 和其他 directive 提供更彈性的控制,也改善 Expires 太依賴絕對時間的問題。

當快取過期後,也不代表每次都要把整份檔案重新抓回來。瀏覽器可以透過 Last-Modified 與 If-Modified-Since,或 ETag 與 If-None-Match 發出 revalidation;如果資源沒有變更,伺服器回傳 304 Not Modified,瀏覽器就能繼續使用本地 cache 中的 response。只是 304 仍然需要 request 和網路往返,省下的是 response body 的傳輸量,不是所有等待時間。

如果是 CSS、JS 這類會隨著版本更新的 assets,則可以搭配 Cache Busting,透過改變 URL 讓瀏覽器知道這是新的資源。以 Vite 的 build assets 來說,檔名通常會帶有 hash;所以實務上常見的做法就是讓舊版本長時間快取,內容更新時再用新的檔名載入,而不是為了更新內容把所有 cache 都設定成不能使用。

參考資料 ​

按 Enter 或空白鍵放大圖片,按 Escape 關閉放大檢視。