Service Worker 進階:離線優先架構與背景同步策略

技术架构(更新於 2026年6月4日)

Service Worker:離線優先架構的核心

Service Worker 是瀏覽器中獨立於頁面的背景執行緒,能攔截網路請求、管理快取、處理推播:

特性 說明
執行環境 獨立 Worker 執行緒,無 DOM 存取
生命週期 install → activate → fetch
作用域 註冊路徑及其子路徑
通訊 postMessage / MessageChannel
持久化 事件驅動,不常駐記憶體

生命週期詳解

                  ┌──────────┐
                  │  parsed  │
                  └────┬─────┘
                       │ install()
                  ┌────▼─────┐
            ┌─────│installing│
            │     └────┬─────┘
            │          │ waitUntil()
            │     ┌────▼─────┐
            │     │ installed │ (waiting)
            │     └────┬─────┘
            │          │ activate()
            │     ┌────▼─────┐
            └────▶│activating│
                  └────┬─────┘
                       │
                  ┌────▼─────┐
                  │ activated │ ← 處理 fetch/push/sync
                  └──────────┘

註冊與更新

if ('serviceWorker' in navigator) {
  const registration = await navigator.serviceWorker.register('/sw.js', {
    scope: '/',
    updateViaCache: 'none'
  });

  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing;
    newWorker?.addEventListener('statechange', () => {
      if (newWorker.state === 'activated') {
        showToast('新版本已就緒,重新整理生效');
      }
    });
  });
}

Cache API:快取策略矩陣

五大經典策略

策略 網路優先 適用場景 離線行為
Network Only 非快取資料(支付/即時) 失敗
Network First ✅+降級 頻繁更新的 API 回傳快取
Cache First 不變資源(字型/圖片) 回傳快取
Cache Only 預快取的離線頁面 回傳快取
Stale While Revalidate 背景更新 HTML/非關鍵 API 回傳舊快取

策略實作

const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
  '/',
  '/styles/main.css',
  '/scripts/main.js',
  '/offline.html',
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(STATIC_ASSETS))
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
      )
    )
  );
});

Cache First 實作

async function cacheFirst(request: Request): Promise<Response> {
  const cached = await caches.match(request);
  if (cached) return cached;

  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone());
  }
  return response;
}

Network First 實作

async function networkFirst(request: Request): Promise<Response> {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    const cached = await caches.match(request);
    if (cached) return cached;
    return caches.match('/offline.html');
  }
}

Stale While Revalidate 實作

async function staleWhileRevalidate(request: Request): Promise<Response> {
  const cache = await caches.open(CACHE_NAME);
  const cached = await cache.match(request);

  const fetchPromise = fetch(request).then((response) => {
    if (response.ok) cache.put(request, response.clone());
    return response;
  });

  return cached || fetchPromise;
}

路由分發

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  if (request.method !== 'GET') return;

  if (url.pathname.startsWith('/api/')) {
    event.respondWith(staleWhileRevalidate(request));
  } else if (url.pathname.match(/\.(css|js|woff2)$/)) {
    event.respondWith(cacheFirst(request));
  } else if (url.pathname.match(/\.(png|jpg|webp|svg)$/)) {
    event.respondWith(cacheFirst(request));
  } else {
    event.respondWith(networkFirst(request));
  }
});

Background Sync:背景同步

註冊同步任務

async function registerSync() {
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('submit-pending-data');
}

async function saveOffline(data: object) {
  const db = await openDB('outbox', 1, {
    upgrade(db) {
      db.createObjectStore('pending', { keyPath: 'id', autoIncrement: true });
    }
  });
  await db.add('pending', data);
  await registerSync();
}

Service Worker 處理同步

self.addEventListener('sync', (event) => {
  if (event.tag === 'submit-pending-data') {
    event.waitUntil(submitPendingData());
  }
});

async function submitPendingData() {
  const db = await openDB('outbox', 1);
  const pending = await db.getAll('pending');

  for (const item of pending) {
    try {
      await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(item),
      });
      await db.delete('pending', item.id);
    } catch {
      throw new Error('同步失敗,將重試');
    }
  }
}

Push API:推播通知

訂閱推播

async function subscribePush(publicKey: string) {
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: publicKey
  });
  await fetch('/api/push/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription.toJSON())
  });
}

處理推播

self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? { title: '新訊息', body: '' };

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/notification.png',
      badge: '/icons/badge.png',
      vibrate: [200, 100, 200],
      data: { url: data.url },
      actions: [
        { action: 'open', title: '檢視' },
        { action: 'dismiss', title: '忽略' }
      ]
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const url = event.notification.data?.url || '/';

  if (event.action === 'dismiss') return;

  event.waitUntil(
    self.clients.matchAll({ type: 'window' }).then((clients) => {
      for (const client of clients) {
        if (client.url === url && 'focus' in client) {
          return client.focus();
        }
      }
      return self.clients.openWindow(url);
    })
  );
});

Workbox:Google 官方工具鏈

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

registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 3600 }),
      new CacheableResponsePlugin({ statuses: [0, 200] }),
    ],
  })
);

registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({
    cacheName: 'api',
    plugins: [
      new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 5 * 60 }),
    ],
  })
);

registerRoute(
  ({ request }) => request.mode === 'navigate',
  new NetworkFirst({
    cacheName: 'pages',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
    ],
  })
);

快取策略對比

策略 首次載入 後續載入 離線 資料新鮮度 複雜度
Network Only 網路 網路 最新
Network First 網路 網路/快取 較新
Cache First 網路 快取 可能舊
Stale While Revalidate 網路 快取 最終一致

瀏覽器相容性

API Chrome Firefox Safari Edge
Service Worker 40+ 44+ 11.1+ 17+
Cache API 40+ 41+ 11.1+ 17+
Background Sync 49+ 不支援 不支援 17+
Push API 50+ 44+ 16+ 17+
Navigation Preload 62+ 不支援 不支援 17+

最佳實踐

  1. 預快取關鍵資源:install 時快取 App Shell,保證離線可用
  2. 版本化快取名稱app-v1app-v2,activate 時清理舊快取
  3. 路由分發:不同資源類型使用不同策略
  4. 限制快取大小:ExpirationPlugin 防止快取無限成長
  5. 優雅降級:SW 註冊失敗時頁面仍正常工作

總結

Service Worker 是建構離線優先 Web 應用的核心,透過 Cache API 的策略矩陣實現網路與快取的智慧排程,Background Sync 保證離線操作的最終一致性,Push API 實現伺服器端主動通知。Workbox 工具鏈大幅降低了實作複雜度。

推薦使用 檔案壓縮 減少快取資源體積,PDF 合併 處理離線文件,圖片壓縮 最佳化快取圖片。

本站提供瀏覽器本地工具,免註冊即可試用 →

#Service Worker#离线优先#Cache API#后台同步#推送通知