說明 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=
SW 能做的事很多(推播、背景同步、離線),但本章只談效能:它讓你可以用 JavaScript 決定每個請求的快取策略,不再受限於伺服器的 HTTP 標頭,並且能在離線或網路很差時仍立即回應。
生命週期
max width=
註冊:頁面呼叫 。第一次載入的頁面不受控制(不會經過 fetch 事件),除非 SW 在 activate 時呼叫 。
install:只在 SW 第一次安裝或內容變更時觸發。這裡做預快取(precache):把 app shell、關鍵 JS / CSS 放進 Cache Storage。用 包住 Promise,全部成功才算安裝完成。
waiting:若有舊版 SW 仍控制著任何分頁,新版會等待,避免同一時刻兩個版本共存。呼叫 可強制接手(但要處理頁面與新 SW 的版本不一致)。
activate:適合刪除舊版本的快取。
更新檢查:使用者導覽到作用範圍內的頁面、觸發功能事件、或呼叫 時,瀏覽器會重新下載 ,逐位元組比較,有差異就啟動新版生命週期。無論 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=
回訪與導覽:app shell、字型、雜湊資源命中 Cache Storage,可以做到「離線也秒開」;這是 SW 最強的場景。
網路很差時:Network First 加逾時、或 SWR,讓頁面不因單一慢請求而卡住。
HTTP 標頭不可控時:第三方或舊系統的資源沒有好的 Cache-Control,SW 可以自己決定。
反例:首次載入不會受益(SW 尚未安裝);每個請求都經過 SW 的 JS,簡單的 Cache First 對雜湊資源反而不如 HTTP cache 直接命中(HTTP cache 不用喚醒任何東西)。
Navigation preload
讓瀏覽器在啟動 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 狀態,實作「有新版本,點此更新」的提示並驗證 後頁面重新載入。
為什麼 SW 第一次安裝後,當前頁面的請求不會經過它?要怎麼改變這個行為,有什麼風險?
對帶雜湊檔名的 ,HTTP cache 一年 immutable 與 SW Cache First,哪個回應更快?為什麼?
描述 Stale-While-Revalidate 在「第一次」「第二次」「第三次」請求同一張圖時各回傳什麼。
一個電商網站把 用 Cache First 快取,會發生什麼問題?應該用哪種策略?
Navigation preload 解決的是哪一段時間?請對照 Navigation Timing 的屬性說明。
你發現使用者回報「網站永遠停在舊版」。列出三個可能的原因與對應的修法。
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 與自毀腳本。