為什麼需要快取#
現代網頁早就不是一份靜態 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 是
Expires與Cache-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 同時有 Expires 與 Cache-Control,瀏覽器會以 Cache-Control 為準。
Cache-Control 還有不少常見指令:
no-store:完全不要快取,每次都回伺服器拿。常用在含使用者敏感資訊或變動極頻繁的內容。no-cache:名字容易讓人誤會,但它不是「不要快取」的意思,而是「可以存,但每次使用前都要先去伺服器確認」。真正不要存的是no-store。private:只允許瀏覽器這種私有快取保存。public:允許任何快取保存,包含 reverse proxy 等共用快取。s-maxage:只對共用快取生效,會覆蓋max-age與Expires,瀏覽器會忽略它。
不要任何快取的是
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=31536000GET /ironman-logo.png
If-Modified-Since: Sat, 11 Sep 2021 07:28:00 GMT但這個機制有個盲點:它判斷的是「修改時間」,檔案被開啟另存後修改時間就會變動,即便內容完全相同。
ETag 與 If-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 沒有公開的明確規則,但透過
preload、prefetch載入的資源比較容易進去。 - 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-store;no-cache仍會存,只是使用前要驗證。 - Cache Busting 透過更換檔名強迫瀏覽器抓新檔,是搭配 SPA 與 bundler 常見的做法。