EP.33Web 開發系列

Progressive Web App(PWA)
完整指南

Service Worker、Web App Manifest、Push Notification、Offline First — 讓網頁擁有原生 App 的體驗

Joseph Chen 2026 18 min read PWA · Service Worker · Manifest · Offline

Section 1:什麼是 PWA,為什麼要學?

Progressive Web App(PWA)是一套讓網頁具備原生 App 體驗的技術標準,由 Google 在 2016 年提出。它不是一個框架,而是一系列 Web 標準的組合:Service Worker、Web App Manifest、Cache API、Push API 等。核心概念是「漸進增強」——在支援的瀏覽器中提供完整體驗,在不支援的環境中仍能正常運作。

🛡️

Reliable(可靠)

離線可用。透過 Service Worker + Cache API,即使在網路不穩或完全離線的情況下,核心功能仍能運作。用戶不會看到瀏覽器的「無法連線」恐龍頁面。

Fast(快速)

快速載入。預先快取關鍵資源(CSS、字體、核心 JS),讓回訪者幾乎瞬間看到頁面。即使在 3G 網路下,體驗也遠優於傳統網頁。

📲

Engaging(吸引人)

可安裝到主畫面 + Push Notification。用戶可以像使用 App 一樣使用你的網站,沒有瀏覽器的網址列干擾,還能接收即時推播通知。

Web vs Native App 的差距正在快速縮小

2016

PWA 概念提出,Service Worker 規範在 Chrome 首先實作

2018

iOS 11.3 開始支援 Service Worker,PWA 終於可以跨平台

2021

Web Share API、File System Access API 讓網頁能做更多 Native 才能做的事

2023

Web Push API 全面支援(包含 iOS 16.4+),通知功能補齊最後一塊拼圖

2024

Project Fugu — Google 持續推進 Web 能力:藍牙、NFC、USB、攝影機控制等

PWA 的真實商業效益

  • • Twitter Lite(PWA):工作階段時間增加 65%、推文增加 75%、跳出率降低 20%
  • • Flipkart:App 安裝轉換率提升 70%,工作階段時長增加 3 倍
  • • Starbucks PWA:用戶訂單數量是 Native App 的 2 倍

Section 2:Web App Manifest — 讓網頁可安裝

Web App Manifest 是一個 JSON 檔案,告訴瀏覽器你的網頁應該如何以「App 模式」呈現——包含名稱、圖示、啟動畫面顏色、顯示模式等。有了這個檔案,用戶才能將你的網站「加入主畫面」,讓它看起來和行為都像一個真正的 App。

完整的 manifest.json

json
{
  "name": "My Awesome App",
  "short_name": "AwesomeApp",
  "description": "A Progressive Web App",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#2563eb",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/desktop.png",
      "sizes": "1280x720",
      "type": "image/png",
      "form_factor": "wide"
    }
  ]
}

display 選項說明

  • standalone — 全螢幕,無瀏覽器 UI(最像 Native App)
  • fullscreen — 完全全螢幕,連狀態列都隱藏
  • minimal-ui — 保留最少的瀏覽器 UI(返回鍵)
  • browser — 普通瀏覽器模式(等同沒有 PWA)

purpose(icon) 選項說明

  • any — 一般用途圖示
  • maskable — Android 可適應性圖示(會被裁切成圓形)
  • monochrome — 單色圖示,用於特殊情境
  • 建議:提供 "maskable any" 以兼顧所有場景

在 Next.js 中設定 Manifest

Next.js 13+ 支援透過 app/manifest.ts 以程式碼方式產生 manifest,或者使用 next-pwa 套件自動處理整個 PWA 配置流程。

typescript
// app/manifest.ts(Next.js 13+ 原生支援)
import type { MetadataRoute } from 'next';

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'My Awesome App',
    short_name: 'AwesomeApp',
    description: 'A Progressive Web App',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#2563eb',
    icons: [
      {
        src: '/icons/icon-192x192.png',
        sizes: '192x192',
        type: 'image/png',
        purpose: 'maskable',
      },
      {
        src: '/icons/icon-512x512.png',
        sizes: '512x512',
        type: 'image/png',
        purpose: 'any',
      },
    ],
  };
}

使用 next-pwa 套件(自動化方案)

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

module.exports = withPWA({
  // your next.js config
});

開發環境(development)中建議停用 PWA(disable: true),否則 Service Worker 的快取行為會干擾開發時的即時更新,造成改了程式碼卻看不到變化的困惑。

Section 3:Service Worker 核心原理

Service Worker 是 PWA 的核心技術,本質上是一個在瀏覽器背景執行的 JavaScript Worker,獨立於主執行緒之外。它充當瀏覽器與網路之間的代理人(Proxy),能夠攔截所有網路請求、管理快取、接收推播通知,甚至在沒有網路連線時繼續運作。

Service Worker 的角色

User Request[Service Worker]← 可以攔截所有網路請求
Cache Storage/Network

Install 階段

第一次載入時觸發,預先快取靜態資源(CSS、字體、離線頁面)

Activate 階段

舊版 Service Worker 被新版取代時觸發,用來清除舊快取

Fetch 階段

每次網路請求都會觸發,這裡決定要用快取還是去網路取資料

Service Worker 完整生命週期實作

javascript
// public/sw.js

const CACHE_NAME = 'v1';
const STATIC_ASSETS = [
  '/',
  '/offline.html',
  '/styles/main.css',
  '/fonts/inter.woff2',
];

// 1. Install 階段:預快取靜態資源
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      console.log('SW: Caching static assets');
      return cache.addAll(STATIC_ASSETS);
    })
  );
  self.skipWaiting(); // 立即激活新 SW
});

// 2. Activate 階段:清除舊快取
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) =>
      Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      )
    )
  );
  self.clients.claim(); // 立即控制所有 clients
});

// 3. Fetch 階段:攔截請求
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) {
        // Cache First 策略
        return cached;
      }

      return fetch(event.request).then((response) => {
        // 動態快取
        if (response.ok) {
          const responseClone = response.clone();
          caches.open(CACHE_NAME).then((cache) => {
            cache.put(event.request, responseClone);
          });
        }
        return response;
      }).catch(() => {
        // 網路失敗,回傳 offline 頁面
        return caches.match('/offline.html');
      });
    })
  );
});

為什麼要 response.clone()?

Response 是一個只能讀取一次的 Stream。如果你直接把 response 存入快取再回傳,回傳的就是空的。response.clone() 複製一份給快取存,原本的 response 繼續回傳給瀏覽器。

Section 4:快取策略選擇

Service Worker 的靈活之處在於你能根據不同的資源類型選擇最適合的快取策略。沒有萬能的策略——靜態資源、API 請求、動態內容各有最佳解。選錯策略可能讓用戶看到過期資料,或是失去離線能力。

Cache First適合:字體、圖片、靜態 CSS/JS

先查快取,有就直接回傳,沒有才去網路取。適合幾乎不會更新的資源,速度最快,但要注意快取失效策略,否則用戶可能永遠看到舊版資源。

javascript
// 先查快取,沒有才去網路
async function cacheFirst(request) {
  const cached = await caches.match(request);
  return cached ?? fetch(request);
}
Network First適合:API 請求、即時資料

先去網路,成功則更新快取並回傳最新資料;失敗(離線)才用快取。確保資料即時性,但在網路不穩時可能較慢。

javascript
// 先去網路,失敗才用快取
async function networkFirst(request, cacheName) {
  try {
    const response = await fetch(request);
    const cache = await caches.open(cacheName);
    cache.put(request, response.clone());
    return response;
  } catch {
    return caches.match(request);
  }
}
Stale While Revalidate適合:新聞、部落格、頻繁更新但允許稍舊

立刻回傳快取(速度快),同時在背景更新快取。下次請求就能看到最新版本。這是「速度優先,允許一次稍舊」的最佳折衷方案。

javascript
// 先用快取(快),同時後台更新
async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);

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

  return cached ?? fetchPromise;
}
Network Only適合:支付流程、表單送出、敏感操作

完全不快取,每次都向網路請求。用於不能使用舊資料的場景:支付、轉帳、刪除資料等。如果沒有網路就讓請求失敗,配合 UI 提示用戶。

Section 5:Push Notification 實作

Web Push Notification 讓你在用戶沒有打開你的網站時,仍能傳送通知給他們——就像 Native App 一樣。這需要三個角色配合:用戶的瀏覽器、你的後端伺服器、以及瀏覽器廠商的 Push Service(如 Firebase Cloud Messaging)。

Push Notification 流程

1. 用戶在瀏覽器授權通知 → 取得 PushSubscription

2. 前端將 PushSubscription 傳給你的後端儲存

3. 後端透過 web-push 程式庫,呼叫瀏覽器廠商的 Push Server

4. Push Server 將通知推送到用戶的瀏覽器

5. 瀏覽器的 Service Worker 接收到 push 事件,顯示通知

前端:請求權限與訂閱

javascript
// 1. 請求通知權限
async function requestNotificationPermission() {
  if (!('Notification' in window)) {
    console.log('Browser does not support notifications');
    return false;
  }

  const permission = await Notification.requestPermission();
  return permission === 'granted';
}

// 2. 訂閱 Push Service
async function subscribeToPush() {
  const registration = await navigator.serviceWorker.ready;

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true, // 必須要有可見通知(iOS 要求)
    applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY),
  });

  // 3. 傳送 subscription 到後端儲存
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  return subscription;
}

// 4. Service Worker 接收推播
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};

  event.waitUntil(
    self.registration.showNotification(data.title ?? 'New Message', {
      body: data.body,
      icon: '/icons/icon-192x192.png',
      badge: '/icons/badge-72x72.png',
      vibrate: [100, 50, 100],
      data: { url: data.url },
      actions: [
        { action: 'open', title: '查看' },
        { action: 'dismiss', title: '關閉' },
      ],
    })
  );
});

// 5. 點擊通知
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'open' || !event.action) {
    event.waitUntil(
      clients.openWindow(event.notification.data.url)
    );
  }
});

後端發送 Push(Next.js API Route)

typescript
// app/api/push/send/route.ts
import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:admin@example.com',
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
);

export async function POST(request: Request) {
  const { subscription, title, body, url } = await request.json();

  await webpush.sendNotification(
    subscription,
    JSON.stringify({ title, body, url })
  );

  return Response.json({ success: true });
}

VAPID 金鑰生成

VAPID(Voluntary Application Server Identification)是 Web Push 的身份驗證機制。執行 npx web-push generate-vapid-keys 一次性生成公私鑰對,公鑰放前端,私鑰只放後端環境變數。

Section 6:Offline-First 使用者體驗設計

Offline-First 不只是技術實作,更是一種設計哲學:假設網路隨時可能中斷,在設計每個功能時就考慮離線狀態下的使用者體驗。優秀的 PWA 在離線時不只顯示錯誤訊息,而是繼續讓用戶完成工作,等到上線再自動同步。

在 React 中偵測離線狀態

tsx
// hooks/useOnlineStatus.ts
import { useState, useEffect } from 'react';

export function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(
    typeof navigator !== 'undefined' ? navigator.onLine : true
  );

  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return isOnline;
}

// components/OfflineBanner.tsx
export function OfflineBanner() {
  const isOnline = useOnlineStatus();

  if (isOnline) return null;

  return (
    <div className="fixed top-0 left-0 right-0 z-50 bg-yellow-500 text-black text-center py-2 text-sm font-medium">
      目前處於離線模式,部分功能可能無法使用
    </div>
  );
}

Background Sync — 離線儲存,上線後自動同步

Background Sync API 讓你能在網路中斷時,將用戶的操作(例如送出表單)先暫存在 IndexedDB,等到裝置重新連線後,Service Worker 會自動在背景完成這些操作——即使用戶已經關閉了頁面。

javascript
// 在 fetch 失敗時,透過 Background Sync 排程
async function submitFormWithSync(formData) {
  try {
    return await fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(formData),
    });
  } catch {
    // 儲存到 IndexedDB
    await saveToIndexedDB('pendingForms', formData);

    // 註冊 Background Sync
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-forms');
  }
}

// Service Worker 中處理 sync
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-forms') {
    event.waitUntil(syncPendingForms());
  }
});

離線 UX 原則

  • 讓用戶知道目前處於離線狀態(顯示 Banner)
  • 已載入的內容繼續可以瀏覽
  • 用戶的操作不丟失(暫存 + 同步)
  • 上線後給予成功/失敗的反饋

常見錯誤

  • 離線時只顯示空白頁面或報錯
  • 用戶不知道是否操作成功
  • 快取了敏感的個人資料在裝置上
  • 沒有提供「清除本機資料」的選項

Section 7:在 Next.js 中實作 PWA — 完整 Checklist

使用 next-pwa 搭配 Workbox 是目前在 Next.js 中實作 PWA 最常見的方案。Workbox 是 Google 出的 Service Worker 工具庫,封裝了各種快取策略,讓你不需要手寫複雜的 Service Worker 邏輯。

完整 next-pwa + Workbox 配置

javascript
// next.config.mjs
import withPWA from 'next-pwa';

const config = withPWA({
  dest: 'public',
  register: true,
  skipWaiting: true,
  runtimeCaching: [
    {
      urlPattern: /^https://fonts.googleapis.com/,
      handler: 'CacheFirst',
      options: {
        cacheName: 'google-fonts-stylesheets',
        expiration: { maxAgeSeconds: 60 * 60 * 24 * 365 },
      },
    },
    {
      urlPattern: //api//,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'api-cache',
        expiration: { maxEntries: 50, maxAgeSeconds: 60 * 5 },
      },
    },
  ],
});

export default config({
  // your Next.js config
});

PWA 上線 Checklist

manifest.json 正確填寫(name、short_name、icons、display)

HTTPS 環境(Service Worker 只在安全環境運作,localhost 除外)

提供所有必要的 icon 尺寸(192×192、512×512)

maskable icon 準備完畢(Android 適應性圖示,確保圓形裁切後不會裁到重要區域)

offline.html fallback 頁面(網路完全中斷時顯示)

Lighthouse PWA 稽核分數 ≥ 90(Chrome DevTools → Lighthouse → PWA)

Service Worker 在 Chrome DevTools → Application → Service Workers 中正確顯示

測試「Airplane Mode」下的離線體驗

iOS Safari 的特殊設定(apple-touch-icon、apple-mobile-web-app-capable)

iOS 特殊配置(在 app/layout.tsx 的 head 中加入)

html
<!-- iOS 主畫面圖示 -->
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />

<!-- iOS Splash Screen(啟動畫面) -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="My App" />
PWA
Service Worker
Manifest
Offline First
Push Notification
Cache API
Background Sync