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 檢查清單
- [ ] 已定義
name和short_name - [ ] 已設定
start_url(使用查詢參數進行分析) - [ ]
display設為standalone或fullscreen - [ ] 圖示:至少 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 憑證 |
| 快取大小過大 | 設定 maxEntries 和 maxAgeSeconds |
| 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. 擷取
↓ ↓ ↓ ↓
載入應用程式 快取資源 清除舊快取 從快取/網路
提供請求




