• 說明 Service Worker(SW)在頁面與網路之間扮演什麼角色,以及它的生命週期。

  • 用 Cache Storage 實作 Cache First、Network First、Stale-While-Revalidate 等策略,並知道各自適用的資源。

  • 理解 SW 的效能成本(啟動時間、每個請求都經過 JS),並用 navigation preload 與 Static Routing API 降低成本。

  • 用 Workbox 產生可維護的 SW,處理版本更新與快取清理。

  • 用 Resource Timing 的 與 DevTools 觀察 SW 對每個請求的影響。

如果 HTTP cache 是「照店家規定備貨的便利商店」,Service Worker 就是你自己雇的「私人採購」:所有出門買東西的需求先經過他,他可以決定從自家倉庫(Cache Storage)拿、出門買、或先給舊的再去補貨。他很靈活,但每次被叫醒都要花點時間穿鞋(啟動成本),而且如果什麼都要經過他,簡單的事也會變慢。

Service Worker 是什麼

一個在瀏覽器背景執行、與頁面分離的 JavaScript 執行緒,註冊後會成為其作用範圍(scope)內所有頁面的網路代理:頁面發出的每個請求(HTML、JS、圖片、)都會先觸發 SW 的 事件,由 SW 決定如何回應。它沒有 DOM 存取權、事件驅動、閒置時會被瀏覽器終止,並且只能在 HTTPS(或 localhost)下運作。

max width=

Service Worker 位於頁面與網路之間,可自行決定回應來源。

SW 能做的事很多(推播、背景同步、離線),但本章只談效能:它讓你可以用 JavaScript 決定每個請求的快取策略,不再受限於伺服器的 HTTP 標頭,並且能在離線或網路很差時仍立即回應。

生命週期

max width=

Service Worker 的生命週期。
  1. 註冊:頁面呼叫 。第一次載入的頁面不受控制(不會經過 fetch 事件),除非 SW 在 activate 時呼叫 。

  2. install:只在 SW 第一次安裝或內容變更時觸發。這裡做預快取(precache):把 app shell、關鍵 JS / CSS 放進 Cache Storage。用 包住 Promise,全部成功才算安裝完成。

  3. waiting:若有舊版 SW 仍控制著任何分頁,新版會等待,避免同一時刻兩個版本共存。呼叫 可強制接手(但要處理頁面與新 SW 的版本不一致)。

  4. activate:適合刪除舊版本的快取。

  5. 更新檢查:使用者導覽到作用範圍內的頁面、觸發功能事件、或呼叫 時,瀏覽器會重新下載 ,逐位元組比較,有差異就啟動新版生命週期。無論 HTTP 標頭怎麼寫,瀏覽器最多把 快取 24 小時;仍建議對它設 。

if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    // 等 load 之後再註冊,避免和首屏資源搶頻寬與 CPU
    const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });

    reg.addEventListener('updatefound', () => {
      const newWorker = reg.installing;
      newWorker.addEventListener('statechange', () => {
        if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
          // 新版已就緒但在等待:提示使用者重新整理
          showToast('有新版本', { action: () => newWorker.postMessage({ type: 'SKIP_WAITING' }) });
        }
      });
    });

    // 新 SW 接手後重新載入,確保頁面與 SW 版本一致
    navigator.serviceWorker.addEventListener('controllerchange', () => location.reload());
  });
}

Cache Storage 與快取策略

Cache Storage 是 SW(與頁面)都能用的鍵值儲存:鍵是 ,值是 。常用 API:、、、、、。

L3.3cm L5.2cm L5cm 策略 & 行為 & 適用
Cache First(cache, falling back to network) & 先查快取,命中就回;未命中才上網並存入。 & 帶雜湊檔名的 JS / CSS、字型、不會變的圖片。
Network First(network, falling back to cache) & 先上網(可設逾時),失敗才用快取。 & HTML 導覽、必須最新的 API;離線時仍有內容。
Stale-While-Revalidate & 立刻回快取(若有),同時在背景上網更新快取,下次才用新的。 & 頭像、商品圖、非關鍵 API:要快又能接受晚一步更新。
Network Only & 不經快取。 & 付款、寫入操作、非 GET 請求。
Cache Only & 只用快取(預快取階段已放入)。 & App shell 在特定情境下。

const VERSION = 'v2026-09-12';
const SHELL_CACHE = `shell-${VERSION}`;
const RUNTIME_CACHE = `runtime-${VERSION}`;
const SHELL = ['/', '/offline.html', '/assets/app.3f2a1b.js', '/assets/main.9c0d.css'];

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(SHELL_CACHE).then((c) => c.addAll(SHELL)));
});

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    // 刪掉不是這個版本的快取
    for (const key of await caches.keys()) {
      if (!key.endsWith(VERSION)) await caches.delete(key);
    }
    await self.clients.claim();
  })());
});

self.addEventListener('message', (e) => {
  if (e.data?.type === 'SKIP_WAITING') self.skipWaiting();
});

self.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET') return;           // 非 GET 一律不經手(Network Only)
  const url = new URL(request.url);

  if (request.mode === 'navigate') {
    event.respondWith(networkFirst(request));     // HTML:Network First + 離線頁
  } else if (url.pathname.startsWith('/assets/')) {
    event.respondWith(cacheFirst(request));       // 雜湊資源:Cache First
  } else if (request.destination === 'image') {
    event.respondWith(staleWhileRevalidate(request));
  }
  // 其他請求不呼叫 respondWith → 瀏覽器照常處理
});

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(RUNTIME_CACHE);
    cache.put(request, response.clone());         // body 只能讀一次,要 clone
  }
  return response;
}

async function networkFirst(request, timeoutMs = 3000) {
  const cache = await caches.open(RUNTIME_CACHE);
  try {
    const response = await Promise.race([
      fetch(request),
      new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)),
    ]);
    if (response.ok) cache.put(request, response.clone());
    return response;
  } catch {
    return (await cache.match(request)) || (await caches.match('/offline.html'));
  }
}

async function staleWhileRevalidate(request) {
  const cache = await caches.open(RUNTIME_CACHE);
  const cached = await cache.match(request);
  const fetching = fetch(request).then((response) => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  }).catch(() => cached);
  return cached || fetching;
}
  • 只快取成功的回應:檢查 ,否則 404 / 500 會被永久快取。

  • opaque 回應:跨來源且沒有 CORS 的請求()回傳 status 0 的 opaque 回應,看不到內容也無法判斷成功;Chrome 計算配額時會把它們「膨脹」到約 7 MB 一筆。第三方資源最好加上 屬性走 CORS。

  • 配額:Cache Storage 與 IndexedDB 共用來源配額(Chrome 約為可用磁碟的 60%),用 查詢;超過會被逐出(eviction),SW 程式碼要能承受快取消失。

  • 查詢字串: 可忽略 。

  • 版本化命名:每次部署改 ,在 activate 清舊快取;否則使用者的磁碟會被不再使用的舊檔佈滿。

SW 的效能成本與對策

SW 不是免費的。從的導覽流程可以看到,導覽請求在檢查 HTTP cache 之前,要先啟動 SW( 到 之間)。

max width=

SW 啟動成本的示意。命中時 SW 大幅領先;未命中時反而多付一次啟動時間,navigation preload 可把它藏起來。(數字為示意)
  • 回訪與導覽:app shell、字型、雜湊資源命中 Cache Storage,可以做到「離線也秒開」;這是 SW 最強的場景。

  • 網路很差時:Network First 加逾時、或 SWR,讓頁面不因單一慢請求而卡住。

  • HTTP 標頭不可控時:第三方或舊系統的資源沒有好的 Cache-Control,SW 可以自己決定。

  • 反例:首次載入不會受益(SW 尚未安裝);每個請求都經過 SW 的 JS,簡單的 Cache First 對雜湊資源反而不如 HTTP cache 直接命中(HTTP cache 不用喚醒任何東西)。

讓瀏覽器在啟動 SW 的同時就把導覽請求送出去,SW 啟動後從 取得結果,而不是等 SW 起來才開始上網。

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    if (self.registration.navigationPreload) {
      await self.registration.navigationPreload.enable();
      // 伺服器可透過 Service-Worker-Navigation-Preload 標頭辨認這種請求
      await self.registration.navigationPreload.setHeaderValue('shell-v2');
    }
  })());
});

self.addEventListener('fetch', (event) => {
  if (event.request.mode !== 'navigate') return;
  event.respondWith((async () => {
    const preload = await event.preloadResponse;   // 可能是 undefined
    if (preload) return preload;
    return fetch(event.request);
  })());
});

Static Routing API:讓部分請求完全不經過 SW

Chrome 123 起支援 :在 install 階段宣告「哪些請求直接走網路 / 直接查快取」,瀏覽器就不必為這些請求啟動 SW 或執行 fetch handler。

self.addEventListener('install', (event) => {
  event.addRoutes([
    // 影片與大型下載:一律直接走網路,不喚醒 SW
    { condition: { requestDestination: 'video' }, source: 'network' },
    { condition: { urlPattern: '/api/*' }, source: 'network' },
    // 雜湊資源:直接查 Cache Storage 中名為 shell-v2026 的快取,查不到才進 fetch handler
    { condition: { urlPattern: '/assets/*' }, source: { cacheName: 'shell-v2026-09-12' } },
    // SW 正在啟動時,導覽請求同時走網路與 fetch handler,誰快用誰
    { condition: { requestMode: 'navigate', runningStatus: 'not-running' },
      source: 'race-network-and-fetch-handler' },
  ]);
});

只為了「讓網站可安裝(PWA)」而寫一個什麼都不做的 監聽器,會讓每次導覽多付一次 SW 啟動時間卻毫無收益。Chrome 從 115 起會偵測「no-op fetch handler」並跳過啟動,DevTools 也會警告;正確做法是不要註冊用不到的 fetch 事件。

用 Workbox 少寫、少錯

Workbox 是 Google 維護的 SW 函式庫,把上面手寫的策略、預快取清單、過期清理都封裝好,並在建置時自動產生「檔名 → revision」的清單,解決版本化的痛點。

import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// 建置工具會把 [{url, revision}] 清單注入 self.__WB_MANIFEST
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();

// HTML 導覽:Network First,3 秒逾時
registerRoute(new NavigationRoute(new NetworkFirst({ cacheName: 'pages', networkTimeoutSeconds: 3 })));

// 圖片:Cache First,最多 60 張、30 天
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 }),
    ],
  })
);

// 非關鍵 API:SWR
registerRoute(({ url }) => url.pathname.startsWith('/api/feed'), new StaleWhileRevalidate({ cacheName: 'api' }));
  • generateSW:完全由設定檔產生 sw.js,適合簡單需求;injectManifest:你寫 sw.js,Workbox 只注入預快取清單,彈性最大。

  • 框架整合:、Next.js 的 、Angular 的 都以 Workbox 或類似機制為基礎。

  • 提供頁面端的更新提示流程( 事件、)。

觀察與除錯

L4.2cm L9.3cm 工具 & 用途
Application → Service Workers & 狀態、版本、Update on reload(每次重新整理都裝新版)、Bypass for network(暫時跳過 SW)、Unregister、手動觸發 push / sync。
Application → Cache storage & 檢視每個快取的內容與大小,手動刪除。
Network 面板 & Size 欄「(ServiceWorker)」表示由 SW 回應;同一請求會出現兩列(頁面→SW、SW→網路,後者帶齒輪圖示)。
Performance 面板 & 在 Main 之外會看到 Service Worker 執行緒的軌道,可量測 fetch handler 花了多久。
Resource Timing & 表示經過 SW; 是 SW 啟動 + handler 前置時間。
& 所有已註冊 SW 的內部狀態、啟動 / 停止。

const nav = performance.getEntriesByType('navigation')[0];
if (nav.workerStart > 0) {
  console.log('SW 啟動 + 攔截前置(ms):', (nav.fetchStart - nav.workerStart).toFixed(1));
}

const viaSW = performance.getEntriesByType('resource').filter(e => e.workerStart > 0);
console.table(viaSW.map(e => ({
  name: e.name.split('/').pop(),
  swOverhead: (e.fetchStart - e.workerStart).toFixed(1),
  total: e.duration.toFixed(1),
  fromNetwork: e.transferSize > 0,
})));

SW 一旦出錯,使用者可能一直看到壞掉的舊版。上線前準備好:(1) 必須 ;(2)保留一支「自毀 SW」——內容只有在 activate 時 並刪除所有快取,緊急時部署它取代原檔;(3)不要用 SW 快取第三方腳本的 HTML 回應或含個資的 API;(4)確保 與 SW 策略不衝突(SW 內 仍會用 HTTP cache,雜湊資源的一年快取在 SW 未命中時依然有效)。

課堂實作

在一個 Vite 專案中手寫 :install 預快取 、雜湊後的 JS / CSS 與 ;fetch 對導覽用 Network First(3 秒逾時),對 用 Cache First。用 DevTools Network 的 Offline 模式驗證離線可開,再用 Resource Timing 程式碼觀察 。

同一頁分別在(a)無 SW、(b)SW 且 Cache Storage 命中、(c)SW 但清空 Cache Storage 三種情況下,用 Performance 面板(Slow 4G、CPU 4x)各錄三次,記錄 LCP 與導覽的 。討論(c)為什麼比(a)慢,然後加上 navigation preload 再測一次。

用 (injectManifest)改寫 7-1,加上 。修改一行程式後重新部署,觀察 Application 面板的 waiting 狀態,實作「有新版本,點此更新」的提示並驗證 後頁面重新載入。

  1. 為什麼 SW 第一次安裝後,當前頁面的請求不會經過它?要怎麼改變這個行為,有什麼風險?

  2. 對帶雜湊檔名的 ,HTTP cache 一年 immutable 與 SW Cache First,哪個回應更快?為什麼?

  3. 描述 Stale-While-Revalidate 在「第一次」「第二次」「第三次」請求同一張圖時各回傳什麼。

  4. 一個電商網站把 用 Cache First 快取,會發生什麼問題?應該用哪種策略?

  5. Navigation preload 解決的是哪一段時間?請對照 Navigation Timing 的屬性說明。

  6. 你發現使用者回報「網站永遠停在舊版」。列出三個可能的原因與對應的修法。

  • SW 是頁面與網路之間的可程式化代理,生命週期為 install(預快取)→ waiting → activate(清舊快取)→ fetch。第一次載入不受控制,更新靠逐位元組比較 。

  • 五種策略:Cache First(雜湊資源)、Network First(HTML、必須最新)、SWR(要快又可稍舊)、Network Only(寫入)、Cache Only。只快取 的回應,記得 。

  • SW 有啟動成本:命中時遠快於網路,未命中時反而更慢。用 navigation preload 平行化、用 Static Routing API 讓不需要的請求繞過 SW、不要寫空的 fetch handler。

  • Workbox 解決版本化與清理的繁瑣工作;injectManifest 模式最有彈性。

  • 用 Application 面板、Network 的 (ServiceWorker) 標示、Resource Timing 的 觀察成本;上線前準備 的 sw.js 與自毀腳本。