• 說出瀏覽器裡有哪幾層快取,一個請求會依序經過哪些地方。

  • 讀懂 、、 等標頭,並能為 HTML、雜湊過的靜態資源、API 各自設計正確的快取策略。

  • 用 Resource Timing 與 DevTools 驗證一個資源是否真的命中快取。

  • 理解 bfcache(往返快取)如何讓「上一頁」瞬間完成,以及哪些寫法會讓頁面失去資格。

  • 使用 Speculation Rules API 預先抓取或預先渲染下一頁,並知道如何避免副作用。

快取就像便利商店的「預先備貨」:客人(頁面)要一瓶水(資源),店員先看櫃檯(memory cache),再看店內貨架(HTTP disk cache),再打電話問附近倉庫(CDN 邊緣節點),最後才向遠方工廠(origin)下單。每往外一層,等待時間就多一個數量級。效能優化最有效的一招,就是讓需求盡量在最靠近客人的那一層被滿足——最快的請求,是根本沒有發出去的請求。

瀏覽器的快取層級

提到,一次新連線要花好幾個 RTT 才能拿到第一個位元組。若資源能在本地快取命中,這些 RTT 全部歸零,而且不佔用頻寬。圖 1.1 是一個請求離開頁面後,可能被「攔下」的各個層級。

max width=

一個請求可能在哪些層級被滿足。越靠左,成本越低。

L3cm L5.4cm L5.4cm 層級 & 誰控制 & 特性
Memory cache & 瀏覽器自動 & 只活在目前這個文件的生命週期內;同一資源在同頁被第二次請求時直接命中;preload scanner 抓下的資源也放這裡。DevTools Size 欄顯示「(memory cache)」。
Service Worker Cache Storage & 你的 SW 程式碼 & 可程式化,離線可用,不受 HTTP 標頭限制;但每個請求都要先喚醒 SW(有啟動成本)。詳見。
HTTP cache(disk cache) & HTTP 回應標頭 & 標準、跨分頁、跨工作階段;本章主角。DevTools 顯示「(disk cache)」。自 Chrome 86 起依「頂層網站」分區(partitioned),A 站快取的 CDN 檔案不能給 B 站用。
bfcache & 瀏覽器(依頁面資格) & 快取的不是資源而是「整個頁面的執行狀態」,讓上一頁 / 下一頁瞬間完成。見 §1.5。
Prefetch / prerender cache & Speculation Rules & 提前抓取或渲染「使用者接下來可能去的頁面」。見 §1.6。

HTTP 快取:新鮮度與驗證

HTTP 快取的規範是 RFC 9111(2022 年,取代舊的 RFC 7234)。它的核心只有兩個概念:

  • 新鮮(fresh):快取中的回應還在有效期限內,瀏覽器可以直接使用,不發任何網路請求。

  • 過期(stale):有效期限已過,但不代表內容一定變了。瀏覽器會發出條件式請求(conditional request),帶上驗證器(validator)問伺服器「我這份還能用嗎?」。伺服器若回 (沒有 body),瀏覽器就繼續用舊的;若回 ,就換成新內容。

控制新鮮度:Cache-Control

L4.1cm L9.4cm 指令 & 意義
& 從回應產生起 N 秒內視為新鮮。注意是以伺服器的 起算,再扣掉中間快取回報的 。
& 只對共享快取(CDN、代理)生效的 max-age,優先於 。讓你「CDN 快取 10 分鐘、瀏覽器不快取」。
& 可以儲存,但每次使用前必須先向伺服器驗證。等同 。名字容易誤導。
& 完全不儲存(記憶體以外)。用於含個資、一次性的回應。會讓頁面在部分情況失去 bfcache 資格(§1.5)。
/ & :只有瀏覽器可存,CDN 不可(含個人化內容的回應)。:即使帶有 也允許共享快取儲存。
& 過期後絕不能在「連不到伺服器」時擅自使用舊內容(預設其實允許)。
& 告訴瀏覽器「這份內容在有效期內絕不會變」,使用者按重新整理時也不要驗證。Firefox、Safari 支援;Chrome 自 54 起本來就不在一般重新整理時驗證子資源,所以效果等價。
& 過期後 N 秒內可以先用舊的回應,同時在背景發請求更新(RFC 5861)。Chrome、Firefox 支援。
& 若重新驗證時伺服器錯誤(5xx)或連不上,過期後 N 秒內允許用舊內容頂著。

若回應沒有 也沒有 ,但有 ,瀏覽器會使用啟發式新鮮度(heuristic freshness):通常取「 減 」的 10% 當作有效期。一個三年沒改的檔案會被快取 100 多天;一個剛部署的檔案則幾乎不快取。這種不確定性正是「我明明改了檔案,使用者卻看到舊版」的常見來源。每個回應都應明確給出 Cache-Control。

驗證器:ETag 與 Last-Modified

  • :內容的識別碼(通常是雜湊)。瀏覽器重新驗證時帶 ;相同就回 304。前綴 表示「弱驗證器」,語意相同即可(例如壓縮前後)。

  • :最後修改時間,對應 。精度只有秒,且多台伺服器的檔案時間可能不同。

Apache 預設把檔案的 inode 算進 ETag,導致同一檔案在不同機器上有不同 ETag,負載平衡後永遠驗證失敗、永遠回 200。請設定成只用大小與時間(),或在建置階段用內容雜湊。另外,某些 nginx 設定在啟用 gzip 時會把強 ETag 降為弱 ETag,這是正常的。

瀏覽器的決策流程

max width=

瀏覽器對一個子資源請求的快取決策(簡化)。

幾個實務上必須知道的行為:

  • 一般重新整理(F5):Chrome 只重新驗證主文件,子資源若仍新鮮就直接用(Chrome 54 起)。強制重新整理(Shift+F5)則全部繞過快取。

  • DevTools 的 Disable cache:只在 DevTools 開著時生效。做效能測試時,記得決定你要測的是「冷快取」還是「熱快取」,並在報告中註明。

  • Vary: 表示不同的 Accept-Encoding 是不同的快取條目。 或 幾乎等於讓快取失效,尤其對 CDN。

  • 快取分區(cache partitioning):Chrome 86 起,HTTP cache 以「頂層網站 + 資源來源」為鍵。過去「大家都用同一個 CDN 的 jQuery,所以會命中快取」的說法已不成立;自行託管(self-host)第三方資源反而更划算。

為不同資源設計策略

快取策略的核心矛盾是:快取越久越快,但越難更新。解法是把資源分成兩類,用「URL 版本化」切開這個矛盾。

L2.9cm L5.4cm L5.4cm 資源 & 建議標頭 & 理由
帶內容雜湊的靜態資源
、 & & 內容變了 URL 就變,永遠不需要驗證。一年(31536000 秒)是規範建議的上限。
HTML 文件 &
或
搭配 & HTML 是所有雜湊 URL 的「入口」,必須每次驗證才能拿到新版本清單;但 304 讓驗證成本很低。
可由 CDN 快取的 HTML(內容型網站) & & 瀏覽器每次驗證,CDN 快取 10 分鐘且過期後先給舊版再背景更新,TTFB 穩定在邊緣節點的水準()。
無版本化的圖片、字型 & & 通常不常變;允許一天內直接用,之後一週內先用再更新。字型若能雜湊命名則直接走第一列。
個人化 API 回應 &
或 & 不可被 CDN 存;若資料很短暫可用 減少重複請求。
公開、可預期更新的 API(匯率、排行) & & 讓 CDN 吸收尖峰流量。

# 1. 雜湊命名的 JS:一年、不驗證
HTTP/2 200
content-type: application/javascript
cache-control: public, max-age=31536000, immutable
etag: "3f2a1b"

# 2. HTML:每次驗證,靠 304 省流量
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: no-cache
etag: W/"7d9-1a2b3c"
last-modified: Fri, 11 Sep 2026 08:00:00 GMT

# 3. 第二次請求 HTML 時瀏覽器送出的條件式請求與回應
GET / HTTP/2
if-none-match: W/"7d9-1a2b3c"

HTTP/2 304
etag: W/"7d9-1a2b3c"
cache-control: no-cache
server {
  listen 443 ssl;
  http2 on;
  etag on;

  # 帶雜湊的建置產物(Vite / webpack 的 assets 目錄)
  location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    try_files $uri =404;
  }

  # 圖片、字型:一天 + SWR 一週
  location ~* \.(png|jpe?g|webp|avif|woff2)$ {
    add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800";
  }

  # HTML:每次驗證
  location / {
    add_header Cache-Control "no-cache";
    try_files $uri $uri/ /index.html;
  }
}

現代打包工具(Vite、webpack、Next.js、Astro)預設就會為 JS / CSS 產出內容雜湊檔名,並在 HTML 中引用。你要做的只是(1)確認伺服器 / CDN 對這些路徑給出一年的 標頭,(2)確認 HTML 本身是 。不要用 的查詢字串做版本化:部分 CDN 預設忽略查詢字串,而且部分代理不快取帶查詢字串的 URL。

驗證快取是否真的命中

介紹過 Resource Timing 的大小欄位,表 [tab:cache-verify] 整理了判讀規則:

L4.2cm L4.6cm L4.4cm 情況 & Resource Timing & DevTools Network
新鮮命中(不發請求) & ,,網路階段全為 0 & Size 欄「(disk cache)」或「(memory cache)」,Time 極短
304 重新驗證 & 只有標頭大小(約 200–500 B), & Status 304,Size 欄很小
完整下載 & \(\approx\) + 標頭 & Status 200,Size 為實際傳輸量
Service Worker 供應 & ; 依 SW 是否上網而定 & Size 欄「(ServiceWorker)」

const entries = performance.getEntriesByType('resource');
const stats = { fresh: 0, revalidated: 0, network: 0, sw: 0 };

for (const e of entries) {
  if (e.workerStart > 0) stats.sw++;
  else if (e.transferSize === 0 && e.decodedBodySize > 0) stats.fresh++;
  else if (e.transferSize > 0 && e.transferSize < 600 && e.encodedBodySize > e.transferSize) stats.revalidated++;
  else stats.network++;
}
console.table(stats);
// 想看哪些檔沒命中:
console.table(
  entries.filter(e => e.transferSize > 600)
         .map(e => ({ name: e.name.split('/').pop(), kB: (e.transferSize / 1024).toFixed(1) }))
);

沒有 的跨來源資源, 等大小欄位一律為 0,會被誤判為「命中」。先用 排除這類條目(兩者都為 0 表示被遮蔽,而不是命中)。

bfcache:讓「上一頁」瞬間完成

當使用者離開頁面時,瀏覽器不銷毀它,而是把整個頁面的狀態(DOM、JS heap、捲動位置、正在執行的計時器)凍結在記憶體中。使用者按「上一頁」或「下一頁」時直接解凍,不重新載入、不重新執行 JS。Chrome 的資料顯示,行動裝置上約每五次導覽就有一次是往返導覽,bfcache 讓這些導覽的 LCP、CLS、INP 幾乎為零。

被 bfcache 還原的頁面不會觸發 、,而是觸發 ();離開時觸發 ( 表示「正在進入 bfcache」)。頁面在快取中處於凍結狀態:計時器暫停、JS 不執行、網路請求被中止。

哪些情況會失去資格

L4.6cm L8.9cm 阻擋原因 & 說明與解法
註冊了 監聽器 & 歷史上最常見的原因(第三方分析腳本尤其愛用)。改用 或 。Chrome 已從 117 起分階段棄用 ,可用 主動禁止。
(主文件) & 傳統上直接阻擋。Chrome 自 2024 年起分階段放寬:若頁面在快取期間沒有 cookie 變動、也不依賴 HttpOnly cookie 等敏感狀態,仍可還原。最好的做法是不要對 HTML 用 ,改 。
開著的連線 & 未關閉的 WebSocket、WebRTC、進行中的 IndexedDB 交易、BroadcastChannel。在 時關閉,在 時重連。
關聯 & 用 開新視窗(現代瀏覽器對 預設如此)。
瀏覽器內部限制 & 頁面太大、播放中的媒體擷取、部分 API 使用中、進入快取超過一段時間(Chrome 約 10 分鐘)後被逐出。

測試與監測

  • DevTools:Application \(\rightarrow\) Back/forward cache \(\rightarrow\) 「Test back/forward cache」。它會自動導覽離開再返回,列出所有阻擋原因。

  • Lighthouse:有一項「Page prevented back/forward cache restoration」稽核。

  • 現場監測:(Chrome 123+)。當往返導覽沒有從 bfcache 還原時,這個物件列出原因(含 iframe 內的原因)。

// 1. 不要用 unload;離開時用 pagehide 收尾
window.addEventListener('pagehide', (event) => {
  if (event.persisted) {
    // 正在進入 bfcache:關閉會阻擋資格的連線
    socket?.close();
  }
  // 送出分析資料(sendBeacon 不會阻擋 bfcache)
  navigator.sendBeacon('/analytics', JSON.stringify(collect()));
});

// 2. 被還原時,更新可能過時的資料
window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    console.log('從 bfcache 還原');
    refreshCartBadge();   // 使用者可能在另一頁改了購物車
    socket = connect();   // 重新建立連線
  }
});

// 3. 回報「為什麼沒有被還原」
const nav = performance.getEntriesByType('navigation')[0];
if (nav.type === 'back_forward' && nav.notRestoredReasons) {
  const reasons = nav.notRestoredReasons.reasons?.map(r => r.reason);
  navigator.sendBeacon('/analytics', JSON.stringify({ bfcacheBlocked: reasons }));
}

函式庫會把 bfcache 還原視為一次新的頁面瀏覽並重新回報 LCP、CLS、INP(值通常非常小),這是刻意的:使用者確實「看到了一頁」。若你自己寫量測程式,記得在 中重置狀態,否則 CLS 會把前後兩次瀏覽累加在一起。

Speculation Rules:預先抓取與預先渲染下一頁

前面的快取都是「這一頁用過的東西下次快一點」;Speculation Rules API 則是「猜使用者接下來要去哪一頁,先做起來放」。

  • prefetch:先下載目標頁的 HTML 主文件(只有 HTML,不含子資源、不執行)。導覽時省下整段網路時間,TTFB 接近 0。

  • prerender:在隱藏的背景分頁中完整載入並渲染目標頁,包含子資源與 JS。導覽時直接「啟用(activate)」,LCP 可接近 0 ms。成本也高得多(CPU、記憶體、流量),且會執行目標頁的 JS。

Chrome 109 起支援(同網站 prerender;跨網站 prefetch 不帶 cookie)。其他瀏覽器目前會忽略這些規則,所以它是「漸進式增強」。

<script type="speculationrules">
{
  "prerender": [{
    "where": {
      "and": [
        { "href_matches": "/products/*" },
        { "not": { "selector_matches": ".no-prerender" } }
      ]
    },
    "eagerness": "moderate"
  }],
  "prefetch": [{
    "where": { "href_matches": "/*" },
    "eagerness": "conservative"
  }]
}
</script>

L2.4cm L6.2cm L4.9cm eagerness & 觸發時機 & 適用
& 規則一出現就執行(舊名 ) & 使用者幾乎一定會去的頁(結帳下一步)。
& 目前行為與 immediate 相近,未來可能以更早的訊號觸發 & 少數高機率連結。
& 滑鼠懸停約 200 ms,或觸控裝置上 pointerdown & 一般連結清單的預設選擇。
& / (點下去到放開之間) & 想省資源,仍可搶到約 100 ms。

在目標頁面偵測與配合

被 prerender 的頁面會真的執行 JS,因此分析、廣告曝光、A/B 分派等有副作用的程式必須延到啟用之後:

function onActivated(fn) {
  if (document.prerendering) {
    document.addEventListener('prerenderingchange', fn, { once: true });
  } else {
    fn();
  }
}

onActivated(() => {
  sendPageView();          // 只在使用者真的看到頁面時計一次
  startVideoAutoplay();
});

// 量測時要扣掉 activationStart,否則 LCP 會出現「負數」或極小值
const nav = performance.getEntriesByType('navigation')[0];
const activationStart = nav.activationStart || 0;
new PerformanceObserver((list) => {
  const last = list.getEntries().at(-1);
  console.log('LCP(相對於啟用)', Math.max(last.startTime - activationStart, 0));
}).observe({ type: 'largest-contentful-paint', buffered: true });

伺服器端可用請求標頭 或 辨認推測性請求,例如不計入瀏覽統計、或回 拒絕(瀏覽器之後會正常導覽)。

  • Chrome 對數量有上限(immediate / eager 的 prerender 約 10 個、moderate / conservative 約 2 個,先進先出),且在省電模式、低記憶體、行動數據「省流量」模式下會停用。

  • 目標頁若彈出對話框、要求權限、播放音效,這些會被延後到啟用時才發生,但仍要避免。

  • 不要對「會改變伺服器狀態」的連結(登出、加入購物車)做 prefetch / prerender。用 GET 做這類操作本身就是錯的。

  • 舊的 早已降級成 NoState Prefetch(只抓資源不執行),不是真正的預渲染; 仍可用於「下一頁需要的子資源」。

  • DevTools:Application \(\rightarrow\) Speculative loads 可看到每條規則的狀態與失敗原因。

  • /:這一頁需要、但瀏覽器發現得太晚的資源()。

  • :下一頁需要的子資源(JS chunk、圖片),低優先權下載進 HTTP cache。

  • Speculation Rules:下一頁本身。prefetch 抓 HTML;prerender 整頁做完。

課堂實作

挑一個網站,打開 DevTools Network,勾選 Disable cache 後重新載入,觀察 Response Headers 中每類資源的 與 。依表 [tab:cache-patterns] 判斷:(1)JS / CSS 是否雜湊命名並給一年?(2)HTML 是否 或短 ?(3)有哪些資源缺少 Cache-Control(會落入啟發式)?寫下三條你會改的地方。可用 加速盤點。

取消 Disable cache,第二次載入同一頁。在 Console 執行程式碼 [lst:cachestats] 的統計(本章「驗證快取是否真的命中」小節),並對照 Network 的 Size 欄。哪些檔每次都完整下載?看它們的標頭找原因。再用 Performance 面板比較冷、熱兩次載入的 LCP。

在 DevTools Application \(\rightarrow\) Back/forward cache 按下 Test。若不合格,找出原因(常見:某個第三方腳本註冊了 unload)。在自己的專案中,用 或移除監聽器修正,再測一次。最後寫一段 處理,在還原時更新頁面上會過時的區塊。

在一個有「列表 → 詳情」結構的示範專案中加入 規則:詳情頁 prerender(moderate)、其他連結 prefetch(conservative)。打開 Application \(\rightarrow\) Speculative loads 觀察狀態,滑過連結後點擊,用 Performance 面板比較 LCP;並確認詳情頁的分析事件只在啟用後送出一次。

  1. 與 的差別是什麼?哪一個會影響 bfcache?

  2. 一個 的 (沒有雜湊檔名)上線後發現有 bug,你有哪些補救辦法?為什麼「版本化 URL」能從根本上避免這個問題?

  3. 某資源的 Resource Timing 顯示 、、。它是命中快取了嗎?

  4. 為什麼 會讓 CDN 幾乎無法快取?

  5. 新聞網站首頁要讓 CDN 快取、但讀者每次進站都要看到 5 分鐘內的最新內容,且來源伺服器掛掉時仍能顯示舊版。請寫出一行 。

  6. prerender 一個「登出」連結會發生什麼事?怎麼避免?

  • 快取的層級由近到遠:memory cache → Service Worker → HTTP cache → CDN → origin。越近越快,目標是讓請求在最近的一層被滿足。

  • HTTP 快取只有兩個概念:新鮮(直接用)與驗證(條件式請求換 304)。 控制新鮮度, / 負責驗證。永遠明確給出 Cache-Control,避免啟發式快取。

  • 標準配方:雜湊命名的靜態資源 ;HTML ;CDN 可快取的 HTML 用 + ;個人化內容 。

  • 用 Resource Timing 的 與 DevTools 的 Size 欄驗證命中;注意跨來源遮蔽。

  • bfcache 讓往返導覽瞬間完成:不要用 、HTML 不要 、離開時關連線;用 還原後更新資料,用 監測。

  • Speculation Rules 把「下一頁」提前做好:prefetch 省網路時間、prerender 省全部時間;用 eagerness 控制成本,目標頁要延後副作用並扣掉 。