在 TypeScript 中使用 Service Worker

在 TypeScript 中使用 Service Worker

本文章將說明如何在 TypeScript 中使用 Service Worker。

我們將說明如何在 TypeScript 中使用 Service Worker,並包含實用範例。

YouTube Video

在 TypeScript 中使用 Service Worker

Service Worker 是一種介於瀏覽器與網路之間的“請求代理”。它實現了請求攔截、快取管理、離線支援,以及後台處理(同步與推播)。使用 TypeScript 可以增加型別安全性並提升可維護性。

TypeScript 設定方法

tsconfig.json(啟用 WebWorker 型別)

我們來看看在 tsconfig.json 中啟用 WebWorker 型別的範例。

 1{
 2  "compilerOptions": {
 3    "target": "ES2020",
 4    "module": "ES2020",
 5    "lib": ["ES2020", "WebWorker"],
 6    "moduleResolution": "Bundler",
 7    "strict": true,
 8    "noEmitOnError": true,
 9    "outDir": "out",
10    "skipLibCheck": true
11  },
12  "include": ["sw.ts"]
13}
  • lib 陣列中加入 WebWorker,即可使用如 ServiceWorkerGlobalScope 這類型別。
  • DOMWebWorker 擁有不同的型別,因此通常會將瀏覽器(主應用)與 Service Workertsconfig.json 設定分開管理。
  • Service Worker 檔案最終會輸出到與範圍(scope)匹配的路徑(通常是網站根目錄 /sw.js)。
  • 出於安全考量,Service Worker 只能在 HTTPS(或 localhost)環境下運作。

瀏覽器端(主程式)註冊程式碼

register-sw.ts

1// register-sw.ts
2async function registerServiceWorker() {
3  if (!('serviceWorker' in navigator)) return;
4
5  try {
6    const registration = await navigator.serviceWorker.register(
7      '/sw.js', { scope: '/' }
8    );
  • 這個流程會註冊一個 Service Workerscope 指的是 Service Worker 可以控制的路徑範圍。例如,如果你將 /sw.js 放在網站根目錄並將 scope 設定為根目錄(/),那麼你可以控制整個網站的所有資源。另一方面,如果你指定一個特定的目錄,例如 /app/,那麼只有該目錄下的內容會被控制。
1    // If there's a waiting worker, notify the user.
2    if (registration.waiting) {
3      promptUserToUpdate(registration);
4    }
  • waiting 表示 新的 Service Worker 已安裝完畢,正在等待啟動 的狀態。在這個階段,現有的頁面仍然由舊的 Service Worker 控制,因此通常會提示用戶確認,獲得同意後再呼叫 skipWaiting() 來立即啟動新的 Service Worker。這樣可以讓最新的處理流程立即生效,而不必等待下次頁面重新載入。
 1    // When a new SW is installing, monitor its state changes
 2    registration.addEventListener('updatefound', () => {
 3      const newWorker = registration.installing;
 4      if (!newWorker) return;
 5      newWorker.addEventListener('statechange', () => {
 6        if (newWorker.state === 'installed' &&
 7            navigator.serviceWorker.controller) {
 8          // New content available, prompt the user
 9          promptUserToUpdate(registration);
10        }
11      });
12    });
  • updatefound 事件在新的 Service Worker 安裝開始時被觸發。當這個事件發生時,新的 worker 會被設置在 registration.installing,因此通過監聽它的 statechange,你可以檢測安裝什麼時候完成(installed)。此外,如果存在 navigator.serviceWorker.controller,則表示舊的 Service Worker 已經在控制該頁面,此時可以通知用戶有新版本存在。
1    // When the active worker changes (e.g., after skipWaiting), reload if desired
2    navigator.serviceWorker.addEventListener('controllerchange', () => {
3      // Optionally reload to let the new SW take over
4      window.location.reload();
5    });
6  } catch (err) {
7    console.error('Service Worker registration failed: ', err);
8  }
9}
  • controllerchange 事件會在新的 Service Worker 開始控制當前頁面時被觸發。此時重新載入頁面即可立即套用新的快取策略和處理流程。然而,自動重新載入可能會降低用戶體驗,因此 最好是在取得用戶同意後再重新載入
1function promptUserToUpdate(reg: ServiceWorkerRegistration) {
2  // Show UI to user. If user accepts:
3  if (reg.waiting) {
4    reg.waiting.postMessage({ type: 'SKIP_WAITING' });
5  }
6}
7
8registerServiceWorker();
  • 通過讓 Service Worker 從客戶端接收到 postMessage({ type: 'SKIP_WAITING' }) 並呼叫 self.skipWaiting(),你可以促使更新立即執行。

sw.ts 中的範圍宣告

接下來,讓我們看看典型的 Service Worker 實現 app shell 快取的範例。

在 TypeScript 中使用 Service Worker 時,將正確型別賦予 self 是很有用的。

1// sw.ts
2export default null;
3declare const self: ServiceWorkerGlobalScope;
  • 在 TypeScript 中,self 預設被視為 any,因此若沒有額外註明型別,您將無法獲得如 skipWaiting()clients 這類 Service Worker 專有 API 的型別提示與檢查。
  • 指定 ServiceWorkerGlobalScope 型別可啟用自動完成功能,防範錯誤使用,使開發更安全並與常規 DOM 腳本區隔。

基本 Service Worker(安裝/啟用/攔截請求)

本範例包含快取版本管理、安裝時預先快取、啟用時刪除舊快取,以及各種攔截請求的快取策略(如靜態檔案採 cache-first,API 採 network-first)。

sw.ts(最精簡設置與快取骨架)

 1const CACHE_NAME = 'app-shell-v1';
 2const STATIC_ASSETS = [
 3  '/',
 4  '/index.html',
 5  '/styles.css',
 6  '/main.js',
 7  '/fallback.png'
 8];
 9
10self.addEventListener('install', (event: ExtendableEvent) => {
11  // Pre-cache application shell
12  event.waitUntil(
13    caches.open(CACHE_NAME)
14      .then(cache => cache.addAll(STATIC_ASSETS))
15      // Activate immediately (optional: coordinate with client)
16      .then(() => self.skipWaiting())
17  );
18});
  • install 事件期間,應用程式的 靜態資源(App Shell) 會被預先快取。透過呼叫 self.skipWaiting(),新的 Service Worker 會立即啟動,讓最新的快取無需等到下一次存取時就能生效。
 1self.addEventListener('activate', (event: ExtendableEvent) => {
 2  // Clean up old caches and take control of clients immediately
 3  event.waitUntil(
 4    caches.keys().then(keys =>
 5      Promise.all(keys
 6        .filter(key => key !== CACHE_NAME)
 7        .map(key => caches.delete(key)))
 8    ).then(() => self.clients.claim())
 9  );
10});
  • activate 事件中,舊版本的快取會被刪除,並且可確保 Service Worker 保持最新狀態。此外,透過呼叫 self.clients.claim(),新的 Service Worker 能在不用重新載入頁面的情況下控制所有用戶端。
 1self.addEventListener('fetch', (event: FetchEvent) => {
 2  const request = event.request;
 3  const url = new URL(request.url);
 4
 5  // Navigation requests (SPA) -> network-first with fallback to cached index.html
 6  if (request.mode === 'navigate') {
 7    event.respondWith(
 8      fetch(request).catch(() => caches.match('/index.html') as Promise<Response>)
 9    );
10    return;
11  }
12
13  // Simple API routing: network-first for /api/
14  if (url.pathname.startsWith('/api/')) {
15    event.respondWith(networkFirst(request));
16    return;
17  }
18
19  // Static assets: cache-first
20  event.respondWith(cacheFirst(request));
21});
  • fetch 階段,你可以 攔截請求並控制回應內容。你可以實作如 cache-first 或 network-first 之類的策略,幫助提升離線支援與效能。
1self.addEventListener('message', (event: ExtendableMessageEvent) => {
2  const data = (event as any).data;
3  if (!data) return;
4
5  if (data.type === 'SKIP_WAITING') {
6    // Force the waiting service worker to become active
7    self.skipWaiting();
8  }
9});
  • 收到 SKIP_WAITING 時,呼叫 self.skipWaiting() 可以立即啟動等待中的 Service Worker。因此,新的版本會在下次請求時自動套用,而無需重新載入頁面。

實用快取策略總覽

cache-first

Cache-first(快取優先) 會先查詢快取,若有則直接回應。若沒有則從網路取得,並存進快取。適合靜態檔案。

 1async function cacheFirst(request: Request): Promise<Response> {
 2  const cache = await caches.open(CACHE_NAME);
 3  const cached = await cache.match(request);
 4  if (cached) {
 5    return cached;
 6  }
 7  const response = await fetch(request);
 8  if (response && response.ok) {
 9    cache.put(request, response.clone());
10  }
11  return response;
12}
  • 本段程式碼示範快取優先的實作方法。若有快取則直接回應,否則從網路取得並存入快取。適合不常變動的靜態資源,如圖片或 CSS。

network-first

Network-first(網路優先) 則優先從網路取得資料,失敗時才從快取讀取。適合需要最新資料的 API。

 1async function networkFirst(request: Request): Promise<Response> {
 2  const cache = await caches.open(CACHE_NAME);
 3  try {
 4    const response = await fetch(request);
 5    if (response && response.ok) {
 6      cache.put(request, response.clone());
 7    }
 8    return response;
 9  } catch (err) {
10    const cached = await cache.match(request);
11    if (cached) return cached;
12    return new Response(JSON.stringify({ error: 'offline' }), {
13      status: 503,
14      headers: { 'Content-Type': 'application/json' }
15    });
16  }
17}
  • 本段程式碼示範網路優先的實作方式。若取得網路回應則存入快取,若失敗則回傳快取內容。適用於需要及時資料的內容,如新聞文章或 API 回應。

stale-while-revalidate

stale-while-revalidate 會先回傳快取內容,然後在背景同時從網路更新快取。可兼顧回應速度和資料新鮮度。

 1async function staleWhileRevalidate(request: Request, cacheName = CACHE_NAME): Promise<Response> {
 2  const cache = await caches.open(cacheName);
 3  const cachedResponse = await cache.match(request);
 4  const networkFetch = fetch(request).then(networkResponse => {
 5    if (networkResponse && networkResponse.ok) {
 6      cache.put(request, networkResponse.clone());
 7    }
 8    return networkResponse;
 9  }).catch(() => undefined);
10
11  // Return cached immediately if exists, otherwise wait network
12  return cachedResponse || (await networkFetch) || new Response('offline', { status: 503 });
13}
  • 本範例會在有快取時立即回應,同時背景從網路取回新資料以更新快取。可快速回應使用者,並於下次使用最新內容,適合 UI 資源或輕量級資料傳遞。

優化更新流程(通知與安全重新載入)

Service Worker 的更新並非立即生效;新的版本會一直處於等待狀態直到現有的分頁被關閉。

這裡我們實作一套機制,讓新版本準備就緒時能通知用戶端,並可依使用者操作安全重新載入頁面。

當新版本已準備好時,請從 Service Worker 端通知客戶端

 1// In sw.ts: after 'activate' or when new version is ready, broadcast a message
 2async function notifyClientsUpdated() {
 3  const all = await self.clients.matchAll({ type: 'window' });
 4  for (const client of all) {
 5    client.postMessage({ type: 'SW_UPDATED' });
 6  }
 7}
 8
 9// e.g., call this at the end of 'activate'
10self.addEventListener('activate', (event) => {
11  event.waitUntil((async () => {
12    if ('navigationPreload' in self.registration) {
13      await self.registration.navigationPreload.enable();
14    }
15    // cache cleanup
16    const cacheNames = await caches.keys();
17    await Promise.all(
18      cacheNames.map((name) => {
19        if (name !== CACHE_NAME) {
20          return caches.delete(name);
21        }
22      })
23    );
24
25    await self.clients.claim();
26    await notifyClientsUpdated();
27  })());
28});
  • 在此範例中,activate 事件結束時會呼叫 notifyClientsUpdated 通知所有已連線客戶新版本已就緒。clients.claim() 是一個可以立即讓目前開啟中的頁面(clients)由新啟動的 Service Worker 控制的方法。通常 Service Worker 只會在下次載入頁面時開始控制,但透過使用 clients.claim(),可以讓頁面立即被控制,無須重新載入。

在用戶端顯示更新 UI,並依使用者操作重新載入

1// in app startup
2navigator.serviceWorker.addEventListener('message', (e) => {
3  if (e.data?.type === 'SW_UPDATED') {
4    // Show a non-intrusive toast or banner: "New version available"
5    // When user clicks "Reload", call:
6    window.location.reload();
7  }
8});
  • 客戶端透過 message 事件收到 SW_UPDATED 並在 UI 顯示更新通知。當用戶選擇重新載入時,就會執行 window.location.reload(),將頁面上的舊版 HTML、CSS 及其他資源更新到最新版本。這可確保透過 clients.claim() 切換控制權後的快取與 Service Worker 能反映到整個頁面中。

離線備援(Fallback)

準備 /offline.html 以供重要頁面導覽,並提供即使沒有圖片或字型也能傳達訊息的簡易 UI。若 API 請求失敗,盡可能顯示 最後一次快取狀態,並於背景重試,以提升用戶體驗。

實作範例

 1// sw.ts
 2const CACHE_NAME = 'app-cache-v1';
 3
 4// Cache offline.html during install
 5self.addEventListener('install', (event) => {
 6  event.waitUntil((async () => {
 7    const cache = await caches.open(CACHE_NAME);
 8    await cache.addAll(['/offline.html']);
 9  })());
10});
11
12// Handle fetch requests
13self.addEventListener('fetch', (event) => {
14  const request = event.request;
15
16  // Navigation requests (e.g., page transitions)
17  if (request.mode === 'navigate') {
18    event.respondWith((async () => {
19      try {
20        // Try to fetch from the network as usual
21        return await fetch(request);
22      } catch (err) {
23        // On failure, return offline fallback page
24        const cache = await caches.open(CACHE_NAME);
25        return await cache.match('/offline.html') as Response;
26      }
27    })());
28  }
29});
  • install 階段預先快取 /offline.html,確保網路無法連線時可顯示簡易畫面。
  • fetch 事件中,您可以使用 request.mode === 'navigate' 來監控導覽請求,並針對頁面切換進行特殊處理。
  • 若網路請求失敗時就回退至 /offline.html,即使離線也能顯示。

客戶端與 Service Worker 之間的通訊

由於 Service Worker 獨立於頁面生命週期運作,進行狀態通知與指令傳達的 雙向訊息溝通 變得非常重要。為訊息定義型別有助於避免錯誤的訊息發送,提供自動補全,也讓您的實作更加健全。

程式碼範例

  1. 訊息型別定義
1type SwToClient =
2  | { type: 'SW_READY' }
3  | { type: 'SW_UPDATED' }
4  | { type: 'CACHE_CLEARED' }
5  | { type: 'PING'; ts: number };
6
7type ClientToSw =
8  | { type: 'CLEAR_CACHE' }
9  | { type: 'PING'; ts: number };
  • SwToClient 為 Service Worker 傳送給客戶端的訊息型別。
  • ClientToSw 是客戶端發送給 Service Worker 的訊息型別。
  • 這可以讓你明確雙向通訊中可以交換的事件類型。
  1. Service Worker 端的處理
 1self.addEventListener('message', (event) => {
 2  const data = event.data as ClientToSw;
 3  if (data?.type === 'CLEAR_CACHE') {
 4    event.waitUntil((async () => {
 5      const keys = await caches.keys();
 6      await Promise.all(keys.map((k) => caches.delete(k)));
 7      await broadcast({ type: 'CACHE_CLEARED' });
 8    })());
 9  } else if (data?.type === 'PING') {
10    event.source?.postMessage({ type: 'PING', ts: data.ts } as SwToClient);
11  }
12});
  • Service Worker 接收客戶端訊息,依型別分流處理。
  • 對於 CLEAR_CACHE,將刪除快取,並以 CACHE_CLEARED 通知所有客戶端。
  • 對於 PING,會回覆發送端一個包含時間戳記的 PING 訊息。
  1. 由 Service Worker 通知所有客戶端
1async function broadcast(msg: SwToClient) {
2  const clients = await self.clients.matchAll({ includeUncontrolled: true });
3  for (const c of clients) c.postMessage(msg);
4}
  • 利用 clients.matchAll 取得所有視窗分頁。
  • 針對每個視窗用 postMessage 發送訊息,即可達到廣播。
  • 可用於發送更新通知(如 SW_UPDATED)及錯誤通知等。
  1. 客戶端的處理
1navigator.serviceWorker.controller?.postMessage({
2  type: 'PING',
3  ts: Date.now()
4} as ClientToSw);
  • 透過從客戶端發送 PING 並接收來自 Service Worker 的回應,您可以驗證雙向通訊是否正常運作。這樣使得測試連線狀態及訊息處理變得更容易。
 1navigator.serviceWorker.addEventListener('message', (e) => {
 2  const msg = e.data as SwToClient;
 3  switch (msg.type) {
 4    case 'SW_READY':
 5      console.log('Service Worker is ready');
 6      // Example: hide loading spinner or enable offline UI
 7      break;
 8    case 'SW_UPDATED':
 9      console.log('A new version of the Service Worker is available');
10      // Example: show update notification or reload prompt
11      const shouldReload = confirm('A new version is available. Reload now?');
12      if (shouldReload) {
13        window.location.reload();
14      }
15      break;
16    case 'CACHE_CLEARED':
17      console.log('Cache cleared');
18      // Example: show confirmation message to user
19      alert('Cache has been successfully cleared.');
20      break;
21    case 'PING':
22      console.log(`Received PING response, ts=${msg.ts}`);
23      break;
24  }
25});
  • {^ i18n_speak クライアント側では Service Worker から送信されるメッセージを受信し、種類に応じて処理を分岐します。SW_READY は初期化完了、SW_UPDATED は新バージョン検出、CACHE_CLEARED はキャッシュ削除完了、PING は通信確認を示します。各メッセージに応じて、UI の更新やリロード、通知表示などを行います。^}

型別化訊息傳遞的優點

  • 使用型別訊息可以明確界定可收發哪些訊息,且有自動完成與型別檢查提升安全性。
  • postMessage 實現 一對一 通訊,broadcast 則實現 一對多 廣播。
  • 可輕鬆實現諸如更新通知(SW_UPDATED)、快取管理(CACHE_CLEARED)、健康檢查(PING)等功能。

總結

  • TypeScript 在 Service Worker API 呼叫與訊息傳遞上提供型別安全,大幅提升開發效率與可維護性。
  • 理解 installactivatefetch 等生命週期事件,並根據情境選用適合的快取策略(如 cache-firstnetwork-first),能提升使用者體驗。
  • 在操作上,熟悉快取版本管理及更新流程(updatefoundwaitingSKIP_WAITING 等)是不可或缺的。
  • 透過對客戶端與 Service Worker 通訊採用嚴格型別的訊息,你可以避免錯誤實作,並建立一個易於長期擴充和維護的系統。

您可以在我們的 YouTube 頻道上使用 Visual Studio Code 來跟隨上述文章一起學習。 請也查看我們的 YouTube 頻道。

YouTube Video