pwa-development

pwa-development

熱門

漸進式網頁應用程式 - Service Worker、快取策略、離線支援、Workbox

704星標
56分支
更新於 2026/7/14
SKILL.md
唯讀
名稱
pwa-development
描述

漸進式網頁應用程式 - Service Worker、快取策略、離線支援、Workbox

PWA 開發技能

目的: 打造可離線運作、像原生應用程式般安裝,並在所有裝置上提供快速、可靠體驗的漸進式網頁應用程式。


核心 PWA 需求

┌─────────────────────────────────────────────────────────────────┐
│  PWA 三大支柱                                                   │
│  ─────────────────────────────────────────────────────────────  │
│                                                                 │
│  1. HTTPS                                                       │
│     需要 Service Worker 與安全性。                               │
│     開發時可使用 localhost。                                     │
│                                                                 │
│  2. SERVICE WORKER                                              │
│     在背景執行的 JavaScript。                                    │
│     支援離線、快取、推播通知。                                   │
│                                                                 │
│  3. WEB APP MANIFEST                                            │
│     描述應用程式後設資料的 JSON 檔案。                           │
│     支援安裝與類應用程式體驗。                                   │
├─────────────────────────────────────────────────────────────────┤
│  可安裝性條件 (Chrome)                                          │
│  ─────────────────────────────────────────────────────────────  │
│  • HTTPS(或 localhost)                                        │
│  • 具有 fetch 處理常式的 Service Worker                         │
│  • Web App Manifest 包含:name、icons (192px + 512px)、         │
│    start_url、display: standalone/fullscreen/minimal-ui         │
└─────────────────────────────────────────────────────────────────┘

Web App Manifest

必要欄位

{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "A description of what the app does",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512-maskable.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

進階 Manifest(完整功能)

{
  "name": "My Progressive Web App",
  "short_name": "MyPWA",
  "description": "A full-featured PWA",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "orientation": "portrait-primary",
  "background_color": "#ffffff",
  "theme_color": "#3367D6",
  "dir": "ltr",
  "lang": "en",
  "categories": ["productivity", "utilities"],

  "icons": [
    { "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" },
    { "src": "/icons/icon-96.png", "sizes": "96x96", "type": "image/png" },
    { "src": "/icons/icon-128.png", "sizes": "128x128", "type": "image/png" },
    { "src": "/icons/icon-144.png", "sizes": "144x144", "type": "image/png" },
    { "src": "/icons/icon-152.png", "sizes": "152x152", "type": "image/png" },
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-384.png", "sizes": "384x384", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],

  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    },
    {
      "src": "/screenshots/mobile.png",
      "sizes": "750x1334",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ],

  "shortcuts": [
    {
      "name": "New Item",
      "short_name": "New",
      "description": "Create a new item",
      "url": "/new?source=shortcut",
      "icons": [{ "src": "/icons/shortcut-new.png", "sizes": "192x192" }]
    }
  ],

  "share_target": {
    "action": "/share",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url",
      "files": [{ "name": "files", "accept": ["image/*"] }]
    }
  },

  "protocol_handlers": [
    {
      "protocol": "web+myapp",
      "url": "/handle?url=%s"
    }
  ],

  "file_handlers": [
    {
      "action": "/open-file",
      "accept": {
        "text/plain": [".txt"]
      }
    }
  ]
}

Manifest 檢查清單

  • [ ] 已定義 nameshort_name
  • [ ] 已設定 start_url(使用查詢參數進行分析)
  • [ ] display 設為 standalonefullscreen
  • [ ] 圖示:至少 192x192 和 512x512
  • [ ] 包含可遮罩圖示以支援 Android 適應性圖示
  • [ ] theme_color 符合應用程式設計
  • [ ] background_color 用於啟動畫面
  • [ ] 螢幕截圖以提供更豐富的安裝介面(選用)
  • [ ] 捷徑以快速執行操作(選用)

Service Worker 模式

基本 Service Worker

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

// 安裝:快取靜態資源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(STATIC_ASSETS))
      .then(() => self.skipWaiting())
  );
});

// 啟動:清除舊快取
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys()
      .then((keys) => Promise.all(
        keys
          .filter((key) => key !== CACHE_NAME)
          .map((key) => caches.delete(key))
      ))
      .then(() => self.clients.claim())
  );
});

// 擷取:從快取提供,若無則回退到網路
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((cached) => cached || fetch(event.request))
      .catch(() => caches.match('/offline.html'))
  );
});

註冊

// main.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/'
      });
      console.log('SW registered:', registration.scope);
    } catch (error) {
      console.error('SW registration failed:', error);
    }
  });
}

快取策略

策略選擇指南

策略 使用情境 說明
Cache First 靜態資源(CSS、JS、圖片) 檢查快取,若無則回退到網路
Network First API 回應、動態內容 嘗試網路,若失敗則回退到快取
Stale While Revalidate 半靜態內容(頭像、文章) 立即提供快取,背景更新
Network Only 不可快取的請求(分析) 一律使用網路
Cache Only 僅離線資源 僅從快取提供

Cache First(離線優先)

// 最適合:很少變動的靜態資源
self.addEventListener('fetch', (event) => {
  if (event.request.destination === 'image' ||
      event.request.destination === 'style' ||
      event.request.destination === 'script') {
    event.respondWith(
      caches.match(event.request)
        .then((cached) => {
          if (cached) return cached;
          return fetch(event.request).then((response) => {
            const clone = response.clone();
            caches.open(CACHE_NAME).then((cache) => {
              cache.put(event.request, clone);
            });
            return response;
          });
        })
    );
  }
});

Network First(新鮮優先)

// 最適合:API 資料、頻繁更新的內容
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          const clone = response.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, clone);
          });
          return response;
        })
        .catch(() => caches.match(event.request))
    );
  }
});

Stale While Revalidate

// 最適合:可以接受稍微過時的內容
self.addEventListener('fetch', (event) => {
  if (event.request.url.includes('/articles/')) {
    event.respondWith(
      caches.open(CACHE_NAME).then((cache) => {
        return cache.match(event.request).then((cached) => {
          const fetchPromise = fetch(event.request).then((response) => {
            cache.put(event.request, response.clone());
            return response;
          });
          return cached || fetchPromise;
        });
      })
    );
  }
});

Workbox(建議使用)

為什麼使用 Workbox?

  • 經過實戰考驗的快取策略
  • 帶版本管理的預先快取
  • 離線表單的背景同步
  • 自動快取清理
  • TypeScript 支援

安裝

npm install workbox-webpack-plugin  # Webpack
npm install @vite-pwa/vite-plugin   # Vite

Workbox 搭配 Vite

// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';

export default {
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
      manifest: {
        name: 'My App',
        short_name: 'App',
        theme_color: '#ffffff',
        icons: [
          { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
          { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' }
        ]
      },
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: {
                maxEntries: 100,
                maxAgeSeconds: 60 * 60 * 24 // 24 小時
              }
            }
          },
          {
            urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
            handler: 'CacheFirst',
            options: {
              cacheName: 'image-cache',
              expiration: {
                maxEntries: 50,
                maxAgeSeconds: 60 * 60 * 24 * 30 // 30 天
              }
            }
          }
        ]
      }
    })
  ]
};

Workbox 手動 Service Worker

// sw.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

// 預先快取靜態資源(由建置工具產生)
precacheAndRoute(self.__WB_MANIFEST);

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

// 快取 API 回應
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-responses',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] }),
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 24 * 60 * 60 // 24 小時
      })
    ]
  })
);

// 快取頁面導航
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new NetworkFirst({
    cacheName: 'pages',
    plugins: [
      new CacheableResponsePlugin({ statuses: [0, 200] })
    ]
  })
);

離線體驗

離線頁面

<!-- offline.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>離線 - 應用程式名稱</title>
  <style>
    body {
      font-family: system-ui, sans-serif;
      display: flex;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      margin: 0;
      background: #f5f5f5;
    }
    .offline-content {
      text-align: center;
      padding: 2rem;
    }
    .offline-icon { font-size: 4rem; }
    h1 { color: #333; }
    p { color: #666; }
    button {
      background: #3367D6;
      color: white;
      border: none;
      padding: 0.75rem 1.5rem;
      border-radius: 4px;
      cursor: pointer;
      font-size: 1rem;
    }
  </style>
</head>
<body>
  <div class="offline-content">
    <div class="offline-icon">📡</div>
    <h1>您已離線</h1>
    <p>請檢查連線後再試一次。</p>
    <button onclick="location.reload()">重試</button>
  </div>
</body>
</html>

離線偵測

// 線上/離線狀態處理
function updateOnlineStatus() {
  const status = navigator.onLine ? 'online' : 'offline';
  document.body.dataset.connectionStatus = status;

  if (!navigator.onLine) {
    showNotification('您已離線,部分功能可能無法使用。');
  }
}

window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus();

背景同步(佇列離線操作)

// sw.js 搭配 Workbox
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';

const bgSyncPlugin = new BackgroundSyncPlugin('formQueue', {
  maxRetentionTime: 24 * 60 // 重試 24 小時
});

registerRoute(
  ({ url }) => url.pathname === '/api/submit',
  new NetworkOnly({
    plugins: [bgSyncPlugin]
  }),
  'POST'
);
// main.js - 佇列表單提交
async function submitForm(data) {
  try {
    const response = await fetch('/api/submit', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    });
    return response.json();
  } catch (error) {
    // 連線後將由背景同步重試
    showNotification('已離線儲存,連線後將自動同步。');
  }
}

類應用程式功能

安裝提示

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferredPrompt = e;
  showInstallButton();
});

async function installApp() {
  if (!deferredPrompt) return;

  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;

  console.log(`User ${outcome === 'accepted' ? 'accepted' : 'dismissed'} install`);
  deferredPrompt = null;
  hideInstallButton();
}

window.addEventListener('appinstalled', () => {
  console.log('App installed');
  deferredPrompt = null;
});

偵測獨立模式

// 檢查是否以已安裝 PWA 執行
function isInstalledPWA() {
  return window.matchMedia('(display-mode: standalone)').matches ||
         window.navigator.standalone === true; // iOS
}

// 監聽顯示模式變更
window.matchMedia('(display-mode: standalone)')
  .addEventListener('change', (e) => {
    console.log('Display mode:', e.matches ? 'standalone' : 'browser');
  });

推播通知

// 請求權限
async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();
  if (permission === 'granted') {
    await subscribeToPush();
  }
  return permission;
}

// 訂閱推播
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
  });

  // 將訂閱資訊傳送給伺服器
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });
}

// sw.js - 處理推播事件
self.addEventListener('push', (event) => {
  const data = event.data.json();
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url }
    })
  );
});

// 處理通知點擊
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    clients.openWindow(event.notification.data.url)
  );
});

分享目標

// sw.js - 處理分享目標
self.addEventListener('fetch', (event) => {
  if (event.request.url.endsWith('/share') &&
      event.request.method === 'POST') {
    event.respondWith((async () => {
      const formData = await event.request.formData();
      const title = formData.get('title');
      const text = formData.get('text');
      const url = formData.get('url');

      // 儲存或處理分享的內容
      // 將分享資料重新導向至應用程式
      return Response.redirect(`/?shared=true&title=${encodeURIComponent(title)}`);
    })());
  }
});

效能最佳化

關鍵渲染路徑

<!-- 內嵌關鍵 CSS -->
<style>
  /* 關鍵的頁面頂端樣式 */
</style>

<!-- 預載重要資源 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/scripts/app.js" as="script">

<!-- 延遲非關鍵 CSS -->
<link rel="stylesheet" href="/styles/main.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>

圖片最佳化

<!-- 響應式圖片 -->
<img
  src="/images/hero-800.webp"
  srcset="
    /images/hero-400.webp 400w,
    /images/hero-800.webp 800w,
    /images/hero-1200.webp 1200w
  "
  sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
  alt="Hero image"
  loading="lazy"
  decoding="async"
>

<!-- 現代格式搭配備援 -->
<picture>
  <source srcset="/images/hero.avif" type="image/avif">
  <source srcset="/images/hero.webp" type="image/webp">
  <img src="/images/hero.jpg" alt="Hero image" loading="lazy">
</picture>

程式碼分割

// 基於路由的動態匯入
const routes = {
  '/': () => import('./pages/Home.js'),
  '/about': () => import('./pages/About.js'),
  '/settings': () => import('./pages/Settings.js')
};

async function loadPage(path) {
  const loader = routes[path];
  if (loader) {
    const module = await loader();
    return module.default;
  }
}

測試 PWA

Lighthouse 稽核

# 從命令列執行 Lighthouse
npx lighthouse https://your-app.com --view

# 要檢查的關鍵指標:
# - PWA 徽章(可安裝、離線就緒)
# - 效能分數
# - 最佳實務
# - 無障礙性

手動測試檢查清單

  • [ ] 可安裝性

    • [ ] 桌面版 Chrome 出現安裝提示
    • [ ] 行動裝置可加入主畫面
    • [ ] 安裝後應用程式以獨立模式開啟
  • [ ] 離線支援

    • [ ] 離線時應用程式可載入(飛航模式)
    • [ ] 快取的頁面正確顯示
    • [ ] 未快取的路由顯示離線備援頁面
    • [ ] 恢復連線時背景同步正常運作
  • [ ] 效能

    • [ ] 首次內容繪製 < 1.8 秒
    • [ ] 最大內容繪製 < 2.5 秒
    • [ ] 可互動時間 < 3.8 秒
    • [ ] 累計版面配置位移 < 0.1
  • [ ] Service Worker

    • [ ] SW 成功註冊
    • [ ] 安裝時快取靜態資源
    • [ ] SW 正確更新(新版本)
    • [ ] 無過時快取問題
  • [ ] Manifest

    • [ ] 所有必要欄位皆存在
    • [ ] 圖示正確顯示
    • [ ] 主題顏色已套用
    • [ ] 啟動時顯示啟動畫面

測試 Service Worker 更新

// 強制檢查更新
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.ready.then((registration) => {
    registration.update();
  });
}

// 監聽更新
navigator.serviceWorker.addEventListener('controllerchange', () => {
  // 新的 Service Worker 已啟動
  window.location.reload();
});

專案結構

project/
├── public/
│   ├── manifest.json           # Web App Manifest
│   ├── sw.js                   # Service Worker(若未打包)
│   ├── offline.html            # 離線備援頁面
│   ├── robots.txt
│   └── icons/
│       ├── icon-72.png
│       ├── icon-96.png
│       ├── icon-128.png
│       ├── icon-144.png
│       ├── icon-152.png
│       ├── icon-192.png
│       ├── icon-384.png
│       ├── icon-512.png
│       ├── icon-maskable.png   # 適應性圖示用
│       ├── apple-touch-icon.png
│       └── favicon.ico
├── src/
│   ├── sw.js                   # Service Worker 原始碼(若打包)
│   ├── pwa/
│   │   ├── install.js          # 安裝提示處理
│   │   ├── offline.js          # 離線偵測
│   │   └── push.js             # 推播通知處理
│   └── ...
└── tests/
    └── pwa/
        ├── manifest.test.js
        ├── sw.test.js
        └── offline.test.js

常見錯誤

錯誤 修正
缺少可遮罩圖示 加入 "purpose": "maskable" 的圖示
無離線備援 建立 offline.html 並快取
快取永不過期 使用 Workbox 的 ExpirationPlugin
SW 快取過於積極 根據資源類型使用適當策略
無更新機制 實作 skipWaiting() + 重新載入提示
安裝提示失效 確保 manifest 符合所有條件
正式環境無 HTTPS 設定 SSL 憑證
快取大小過大 設定 maxEntriesmaxAgeSeconds
API 回應過時 動態資料使用 NetworkFirst
缺少 start_url 追蹤 加入查詢參數:/?source=pwa

PWA 開發檢查清單

上線前

  • [ ] 已設定 HTTPS(正式環境)
  • [ ] Manifest 完整包含所有必要欄位
  • [ ] 所有必要尺寸的圖示(192、512、可遮罩)
  • [ ] Service Worker 已註冊且正常運作
  • [ ] 已建立並快取離線頁面
  • [ ] 已為所有資源類型定義快取策略
  • [ ] 已實作安裝提示處理
  • [ ] Lighthouse PWA 稽核通過

上線後

  • [ ] 監控快取大小
  • [ ] 測試 SW 更新不會破壞應用程式
  • [ ] 透過分析追蹤 PWA 安裝
  • [ ] 在多個裝置/瀏覽器上測試
  • [ ] 監控 Core Web Vitals
  • [ ] 設定推播通知流程(如有需要)

框架特定指南

Next.js

npm install next-pwa
// next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development'
});

module.exports = withPWA({
  // 您的 Next.js 設定
});

Create React App

# CRA 4+ 內建 PWA 支援
npx create-react-app my-pwa --template cra-template-pwa

Vite(任何框架)

npm install vite-plugin-pwa -D

設定請參閱上方 Workbox 搭配 Vite 章節。


快速參考

快取策略速查表

靜態資源(CSS、JS、圖片)     → Cache First
API 回應                        → Network First
使用者產生的內容              → Stale While Revalidate
分析、不可快取               → Network Only
僅離線資源                     → Cache Only

Manifest 最低需求

{
  "name": "App Name",
  "short_name": "App",
  "start_url": "/",
  "display": "standalone",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

Service Worker 生命週期

1. 註冊 → 2. 安裝 → 3. 啟動 → 4. 擷取
     ↓              ↓            ↓           ↓
  載入應用程式   快取資源   清除舊快取   從快取/網路
                                        提供請求