為什麼需要快取#

現代網頁早就不是一份靜態 HTML,從產品列表、會員資訊到一張縮圖,幾乎都得靠 Request 與 Response 動態取得。當頁面同時要載入幾十個資源、後端又要打資料庫時,每個請求都得花上一段等待時間。流量一大,效能很容易就被拖垮。

解決這個問題最直覺的想法其實有點搞笑:那就「不要發請求」就好了。這正是快取的核心思路。在請求與來源之間放一層儲存空間,先看看裡面有沒有想要的資料,有就直接用、沒有再去拿。一份資料如果同時具備「常被使用」與「不常變動」這兩個特性,就特別適合放進快取。

快取的好處不只是回應快,還可以同時節省頻寬、減輕 origin server 的壓力。

Cache Hit 與 Cache Miss#

談快取時最常聽到的兩個詞:

  • Cache Hit:發出請求時在快取就找到資源。
  • Cache Miss:快取沒有資源,必須回到 origin server 拿。

效能優化希望盡量提高 Hit Ratio,但這不等於越多越好。例如電商商品的價格已經更新,使用者卻拿到舊版的快取圖片,那就會變成商業問題。所以「讓現有快取適時失效」也是一個必須面對的設計題。

快取的種類#

以 Web 開發的角度,快取大致可分為三層:

  • Client Cache:例如瀏覽器自己管的快取。
  • Server Cache(Application Cache):例如後端應用的記憶體快取。
  • Networking Cache:例如 CDN、proxy 上的快取。

依照存取範圍,又可以再切成兩類:

  • 公有快取(Public Cache):放在中介伺服器上,可以同時服務多個使用者,例如 proxy server 上的快取。
  • 私有快取(Private Cache):只服務單一使用者,例如瀏覽器的快取。

本章會聚焦在前端最容易掌控的部分:HTTP 快取(HTTP Caching)、Service Worker 的快取,以及之後會提到的 CDN。

HTTP Caching 怎麼運作#

HTTP 快取(HTTP Caching),有時也被稱為 Browser Caching,是瀏覽器與伺服器之間最基本的一層快取。它的運作方式是雙方透過 HTTP Header 進行協商:伺服器告訴瀏覽器「這個資源可以快取多久」「過期後該怎麼驗證」,瀏覽器則照規則決定要不要發出請求。

打開任一網站重新整理後查看 DevTool 的 Network tab,常會看到資源旁標記 from disk cache,代表這份資源直接從本地快取讀取,沒有真的發請求出去。

依「過期前」與「過期後」可以將機制再分成兩類:

  • 強快取(Strong Cache):在效期內直接用快取,不發請求。對應的 Header 是 ExpiresCache-Control
  • 協商快取(Conditional Cache):快取過期後,向伺服器確認資源是否真的有更新,再決定要不要重新下載。對應的 Header 有 Last-Modified / If-Modified-Since、以及 ETag / If-None-Match

Expires 與 Cache-Control#

Expires 是早期的做法,伺服器在 Response 直接給一個絕對時間:

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

瀏覽器收到後會把資源存進快取,後續請求時拿使用者本地時間與這個時間比對。問題在於它依賴使用者的系統時鐘,只要把時間調得很未來,所有快取就都被視為過期。

HTTP/1.1 之後推出了 Cache-Control,給的是相對時間:

Cache-Control: max-age=60

意思是收到 Response 後 60 秒內,相同資源都會直接走快取。如果 Response 同時有 ExpiresCache-Control,瀏覽器會以 Cache-Control 為準。

Cache-Control 還有不少常見指令:

  • no-store:完全不要快取,每次都回伺服器拿。常用在含使用者敏感資訊或變動極頻繁的內容。
  • no-cache:名字容易讓人誤會,但它不是「不要快取」的意思,而是「可以存,但每次使用前都要先去伺服器確認」。真正不要存的是 no-store
  • private:只允許瀏覽器這種私有快取保存。
  • public:允許任何快取保存,包含 reverse proxy 等共用快取。
  • s-maxage:只對共用快取生效,會覆蓋 max-ageExpires,瀏覽器會忽略它。

不要任何快取的是 no-store,不是 no-cache。這是非常容易踩雷的誤解。

過期之後:Last-Modified 與 ETag#

快取過期不代表一定要重新下載。例如公司 logo 一年沒換,重抓一次完整檔案其實很浪費。協商快取就是用來處理這個情境的。

Last-Modified 搭配 If-Modified-Since 是 HTTP/1.0 就提供的方案:

  • Last-Modified:出現在 Response Header,告訴瀏覽器這個檔案上次更新的時間。
  • If-Modified-Since:出現在 Request Header,瀏覽器拿之前記下的時間問伺服器:「在這之後有改過嗎?」

伺服器若認為沒有改過,就回 304 Not Modified,瀏覽器繼續用舊快取;若已被更新,就回新的內容。

Last-Modified: Sat, 11 Sep 2021 07:28:00 GMT
Cache-Control: max-age=31536000
GET /ironman-logo.png
If-Modified-Since: Sat, 11 Sep 2021 07:28:00 GMT

但這個機制有個盲點:它判斷的是「修改時間」,檔案被開啟另存後修改時間就會變動,即便內容完全相同。

ETagIf-None-Match 換了一個角度:用內容雜湊產生唯一識別碼,內容變一個字元,ETag 就會跟著變。

Cache-Control: max-age=86400
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

伺服器比對 ETag,相同就回 304;不同就回傳新內容。

ETag 也可以拿來避免表單在多人同時編輯時互相覆蓋(Mid-Air Collisions),用法是在送出修改時帶上 If-Match,伺服器發現 hash 不一致時回 412 Precondition Failed。

協商快取雖然比直接走快取慢,但回傳的封包通常很小,比真的下載完整檔案還是快很多;只是仍比強快取耗資源,因此選擇合適的 cache policy 才是重點。

Cache Busting#

如果檔案改了、但瀏覽器快取還沒到期,使用者可能一直看到舊版。常見做法是讓「檔名本身就是版本識別」:

<link rel="stylesheet" href="./public/css/style-dasd2134das.css" />
<script src="./public/js/app-ajskdj1213.js"></script>

只要內容改了,bundler(例如 webpack)會在打包時更新檔名 hash,並同步改寫 HTML 中的引用:

<link rel="stylesheet" href="./public/css/style-eawe124as.css" />
<script src="./public/js/app-ajskdj1213.js"></script>

再把 HTML 設成 Cache-Control: no-cache,瀏覽器每次都會檢查 HTML,看到引用檔名變了就會重新抓對應的 CSS / JS。整體效果就是強迫瀏覽器跟上最新版本。

Memory Cache 與 Disk Cache#

Chrome 在 DevTool 偶爾會顯示 from memory cache。這是 Chrome 自己實作的一層,把資源放在記憶體裡,存取比 disk 還快,但具有揮發性,瀏覽器一關就清空。

幾個值得記住的點:

  • 哪些資源會放進 memory cache 沒有公開的明確規則,但透過 preloadprefetch 載入的資源比較容易進去。
  • memory cache 不會看 Cache-Control,識別資源時也不是用 URL,而是看 Content-Type、CORS 等屬性。
  • 三種快取被查詢的優先順序為:memory cache ➡️ service worker cache ➡️ disk cache。

本章重點#

  • 快取的本質是:把可能再次需要的資料先放好,請求時先問快取再決定要不要發網路請求。
  • HTTP 快取(HTTP Caching)分成「強快取」與「協商快取」兩階段。
  • 強快取靠 Cache-Control(與較舊的 Expires),決定多久之內不發請求。
  • 協商快取靠 Last-Modified / ETag 在過期後跟伺服器確認資源是否真的有變。
  • 不要任何快取請用 no-storeno-cache 仍會存,只是使用前要驗證。
  • Cache Busting 透過更換檔名強迫瀏覽器抓新檔,是搭配 SPA 與 bundler 常見的做法。

原文出處#