pwa-development
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePWA Development Skill
PWA开发技能
Load with: base.md
Purpose: Build Progressive Web Apps that work offline, install like native apps, and deliver fast, reliable experiences across all devices.
加载基础文件:base.md
用途:构建可离线运行、像原生应用一样安装,并能在所有设备上提供快速、可靠体验的渐进式Web应用(PWA)。
Core PWA Requirements
PWA核心要求
┌─────────────────────────────────────────────────────────────────┐
│ THE THREE PILLARS OF PWA │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 1. HTTPS │
│ Required for service workers and security. │
│ localhost allowed for development. │
│ │
│ 2. SERVICE WORKER │
│ JavaScript that runs in background. │
│ Enables offline, caching, push notifications. │
│ │
│ 3. WEB APP MANIFEST │
│ JSON file describing app metadata. │
│ Enables installation and app-like experience. │
├─────────────────────────────────────────────────────────────────┤
│ INSTALLABILITY CRITERIA (Chrome) │
│ ───────────────────────────────────────────────────────────── │
│ • HTTPS (or localhost) │
│ • Service worker with fetch handler │
│ • Web app manifest with: name, icons (192px + 512px), │
│ start_url, display: standalone/fullscreen/minimal-ui │
└─────────────────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────────────────┐
│ PWA的三大支柱 │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 1. HTTPS │
│ Service Worker和安全功能的必备条件。 │
│ 开发环境下允许使用localhost。 │
│ │
│ 2. SERVICE WORKER │
│ 在后台运行的JavaScript脚本。 │
│ 支持离线功能、缓存、推送通知。 │
│ │
│ 3. WEB APP MANIFEST │
│ 描述应用元数据的JSON文件。 │
│ 支持应用安装类原生体验。 │
├─────────────────────────────────────────────────────────────────┤
│ Chrome浏览器的可安装性标准 │
│ ───────────────────────────────────────────────────────────── │
│ • HTTPS(或localhost) │
│ • 带有fetch处理器的Service Worker │
│ • Web应用清单包含:name、图标(192px + 512px)、 │
│ start_url、display: standalone/fullscreen/minimal-ui │
└─────────────────────────────────────────────────────────────────┘Web App Manifest
Web应用清单(Web App Manifest)
Required Fields
必填字段
json
{
"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"
}
]
}json
{
"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"
}
]
}Enhanced Manifest (Full Features)
增强版清单(完整功能)
json
{
"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"]
}
}
]
}json
{
"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 Checklist
清单检查清单
- and
namedefinedshort_name - set (use query param for analytics)
start_url - set to
displayorstandalonefullscreen - Icons: 192x192 and 512x512 minimum
- Maskable icon included for Android adaptive icons
- matches app design
theme_color - for splash screen
background_color - Screenshots for richer install UI (optional)
- Shortcuts for quick actions (optional)
- 已定义和
nameshort_name - 已设置(可添加查询参数用于统计)
start_url - 已将设置为
display或standalonefullscreen - 图标:至少包含192x192和512x512尺寸
- 已添加适用于Android自适应图标的maskable图标
- 与应用设计匹配
theme_color - 已设置启动屏的
background_color - 已添加截图以优化安装界面(可选)
- 已添加快捷方式以支持快速操作(可选)
Service Worker Patterns
Service Worker模式
Basic Service Worker
基础Service Worker
javascript
// sw.js
const CACHE_NAME = 'app-cache-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/offline.html'
];
// Install: Cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate: Clean old caches
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())
);
});
// Fetch: Serve from cache, fall back to network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((cached) => cached || fetch(event.request))
.catch(() => caches.match('/offline.html'))
);
});javascript
// 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'))
);
});Registration
注册Service Worker
javascript
// 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);
}
});
}javascript
// 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);
}
});
}Caching Strategies
缓存策略
Strategy Selection Guide
策略选择指南
| Strategy | Use Case | Description |
|---|---|---|
| Cache First | Static assets (CSS, JS, images) | Check cache, fall back to network |
| Network First | API responses, dynamic content | Try network, fall back to cache |
| Stale While Revalidate | Semi-static content (avatars, articles) | Serve cache immediately, update in background |
| Network Only | Non-cacheable requests (analytics) | Always use network |
| Cache Only | Offline-only assets | Only serve from cache |
| 策略 | 使用场景 | 描述 |
|---|---|---|
| Cache First(优先缓存) | 静态资源(CSS、JS、图片) | 先检查缓存,缓存无则请求网络 |
| Network First(优先网络) | API响应、动态内容 | 先尝试网络请求,网络失败则回退到缓存 |
| Stale While Revalidate(缓存优先并后台更新) | 半静态内容(头像、文章) | 立即返回缓存内容,后台异步更新缓存 |
| Network Only(仅网络) | 不可缓存的请求(统计数据) | 始终使用网络请求 |
| Cache Only(仅缓存) | 仅离线可用的资源 | 仅从缓存返回内容 |
Cache First (Offline First)
Cache First(优先离线)
javascript
// Best for: Static assets that rarely change
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;
});
})
);
}
});javascript
// 最佳适用场景:很少变更的静态资源
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 (Fresh First)
Network First(优先新鲜内容)
javascript
// Best for: API data, frequently updated content
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))
);
}
});javascript
// 最佳适用场景: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
Stale While Revalidate(缓存优先后台更新)
javascript
// Best for: Content that's okay to be slightly outdated
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;
});
})
);
}
});javascript
// 最佳适用场景:允许轻微过时的内容
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 (Recommended)
Workbox(推荐使用)
Why Workbox?
为什么选择Workbox?
- Battle-tested caching strategies
- Precaching with revision management
- Background sync for offline forms
- Automatic cache cleanup
- TypeScript support
- 经过实战检验的缓存策略
- 带版本管理的预缓存功能
- 支持离线表单的后台同步
- 自动缓存清理
- TypeScript支持
Installation
安装
bash
npm install workbox-webpack-plugin # Webpack
npm install @vite-pwa/vite-plugin # Vitebash
npm install workbox-webpack-plugin # Webpack环境
npm install @vite-pwa/vite-plugin # Vite环境Workbox with Vite
Vite集成Workbox
javascript
// 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 hours
}
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
}
}
}
]
}
})
]
};javascript
// 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 Manual Service Worker
手动编写Workbox Service Worker
javascript
// 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';
// Precache static assets (generated by build tool)
precacheAndRoute(self.__WB_MANIFEST);
// Cache images
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 days
})
]
})
);
// Cache API responses
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 hours
})
]
})
);
// Cache page navigations
registerRoute(
({ request }) => request.mode === 'navigate',
new NetworkFirst({
cacheName: 'pages',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] })
]
})
);javascript
// 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 Experience
离线体验
Offline Page
离线页面
html
<!-- offline.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - App Name</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>You're offline</h1>
<p>Check your connection and try again.</p>
<button onclick="location.reload()">Retry</button>
</div>
</body>
</html>html
<!-- 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>Offline Detection
离线状态检测
javascript
// Online/offline status handling
function updateOnlineStatus() {
const status = navigator.onLine ? 'online' : 'offline';
document.body.dataset.connectionStatus = status;
if (!navigator.onLine) {
showNotification('You are offline. Some features may be unavailable.');
}
}
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus();javascript
// 在线/离线状态处理
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();Background Sync (Queue Offline Actions)
后台同步(离线操作队列)
javascript
// sw.js with 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 // Retry for 24 hours
});
registerRoute(
({ url }) => url.pathname === '/api/submit',
new NetworkOnly({
plugins: [bgSyncPlugin]
}),
'POST'
);javascript
// main.js - Queue form submission
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) {
// Will be retried by background sync when online
showNotification('Saved offline. Will sync when connected.');
}
}javascript
// 集成Workbox的Service Worker
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'
);javascript
// 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('已保存至离线状态,恢复连接后将自动同步。');
}
}App-Like Features
类原生应用功能
Install Prompt
安装提示
javascript
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;
});javascript
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(`用户${outcome === 'accepted' ? '接受' : '拒绝'}了安装请求`);
deferredPrompt = null;
hideInstallButton();
}
window.addEventListener('appinstalled', () => {
console.log('应用已安装');
deferredPrompt = null;
});Detecting Standalone Mode
检测独立运行模式
javascript
// Check if running as installed PWA
function isInstalledPWA() {
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true; // iOS
}
// Listen for display mode changes
window.matchMedia('(display-mode: standalone)')
.addEventListener('change', (e) => {
console.log('Display mode:', e.matches ? 'standalone' : 'browser');
});javascript
// 检查是否以已安装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('显示模式:', e.matches ? '独立模式' : '浏览器模式');
});Push Notifications
推送通知
javascript
// Request permission
async function requestNotificationPermission() {
const permission = await Notification.requestPermission();
if (permission === 'granted') {
await subscribeToPush();
}
return permission;
}
// Subscribe to push
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
// Send subscription to server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
}
// sw.js - Handle push events
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 }
})
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});javascript
// 请求通知权限
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)
);
});Share Target
分享目标
javascript
// sw.js - Handle share target
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');
// Store or process shared content
// Redirect to app with shared data
return Response.redirect(`/?shared=true&title=${encodeURIComponent(title)}`);
})());
}
});javascript
// 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)}`);
})());
}
});Performance Optimization
性能优化
Critical Rendering Path
关键渲染路径
html
<!-- Inline critical CSS -->
<style>
/* Critical above-the-fold styles */
</style>
<!-- Preload important resources -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/scripts/app.js" as="script">
<!-- Defer non-critical CSS -->
<link rel="stylesheet" href="/styles/main.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>html
<!-- 内联关键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>Image Optimization
图片优化
html
<!-- Responsive images -->
<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"
>
<!-- Modern formats with fallback -->
<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>html
<!-- 响应式图片 -->
<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="首页横幅"
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="首页横幅" loading="lazy">
</picture>Code Splitting
代码分割
javascript
// Dynamic imports for route-based splitting
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;
}
}javascript
// 基于路由的动态导入分割
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;
}
}Testing PWA
PWA测试
Lighthouse Audit
Lighthouse审计
bash
undefinedbash
undefinedRun Lighthouse from CLI
从命令行运行Lighthouse
npx lighthouse https://your-app.com --view
npx lighthouse https://your-app.com --view
Key metrics to check:
需要检查的关键指标:
- PWA badge (installable, offline-ready)
- PWA徽章(可安装、离线可用)
- Performance score
- 性能得分
- Best practices
- 最佳实践
- Accessibility
- 可访问性
undefinedundefinedManual Testing Checklist
手动测试清单
-
Installability
- Install prompt appears on desktop Chrome
- Can be added to home screen on mobile
- App opens in standalone mode after install
-
Offline Support
- App loads when offline (airplane mode)
- Cached pages display correctly
- Offline fallback page shows for uncached routes
- Background sync works when coming back online
-
Performance
- First Contentful Paint < 1.8s
- Largest Contentful Paint < 2.5s
- Time to Interactive < 3.8s
- Cumulative Layout Shift < 0.1
-
Service Worker
- SW registers successfully
- Static assets cached on install
- SW updates correctly (new version)
- No stale cache issues
-
Manifest
- All required fields present
- Icons display correctly
- Theme color applied
- Splash screen shows on launch
-
可安装性
- 桌面Chrome浏览器中出现安装提示
- 移动端可添加至主屏幕
- 安装后应用以独立模式打开
-
离线支持
- 离线状态下(飞行模式)应用可正常加载
- 缓存页面显示正常
- 未缓存路由显示离线回退页面
- 恢复在线后后台同步功能正常
-
性能
- 首次内容绘制(FCP)< 1.8秒
- 最大内容绘制(LCP)< 2.5秒
- 可交互时间(TTI)< 3.8秒
- 累积布局偏移(CLS)< 0.1
-
Service Worker
- Service Worker注册成功
- 安装阶段静态资源已缓存
- Service Worker更新正常(新版本)
- 无过期缓存问题
-
应用清单
- 所有必填字段齐全
- 图标显示正常
- 主题色已应用
- 启动时显示启动屏
Testing Service Worker Updates
测试Service Worker更新
javascript
// Force update check
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.update();
});
}
// Listen for updates
navigator.serviceWorker.addEventListener('controllerchange', () => {
// New service worker activated
window.location.reload();
});javascript
// 强制检查更新
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.update();
});
}
// 监听更新事件
navigator.serviceWorker.addEventListener('controllerchange', () => {
// 新Service Worker已激活
window.location.reload();
});Project Structure
项目结构
project/
├── public/
│ ├── manifest.json # Web app manifest
│ ├── sw.js # Service worker (if not bundled)
│ ├── offline.html # Offline fallback page
│ ├── 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 # For adaptive icons
│ ├── apple-touch-icon.png
│ └── favicon.ico
├── src/
│ ├── sw.js # Service worker source (if bundled)
│ ├── pwa/
│ │ ├── install.js # Install prompt handling
│ │ ├── offline.js # Offline detection
│ │ └── push.js # Push notification handling
│ └── ...
└── tests/
└── pwa/
├── manifest.test.js
├── sw.test.js
└── offline.test.jsproject/
├── public/
│ ├── manifest.json # Web应用清单
│ ├── 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.jsCommon Mistakes
常见错误
| Mistake | Fix |
|---|---|
| Missing maskable icon | Add icon with |
| No offline fallback | Create |
| Cache never expires | Use |
| SW caches too aggressively | Use appropriate strategies per resource type |
| No update mechanism | Implement |
| Broken install prompt | Ensure manifest meets all criteria |
| No HTTPS in production | Configure SSL certificate |
| Large cache size | Set |
| Stale API responses | Use |
| Missing start_url tracking | Add query param: |
| 错误 | 修复方案 |
|---|---|
| 缺少maskable图标 | 添加带有 |
| 无离线回退页面 | 创建 |
| 缓存永不过期 | 使用Workbox的 |
| Service Worker缓存过于激进 | 根据资源类型选择合适的缓存策略 |
| 无更新机制 | 实现 |
| 安装提示失效 | 确保应用清单满足所有要求 |
| 生产环境未配置HTTPS | 配置SSL证书 |
| 缓存体积过大 | 设置 |
| API响应过期 | 对动态数据使用 |
| 缺少start_url统计 | 添加查询参数: |
PWA Development Checklist
PWA开发清单
Before Launch
上线前
- HTTPS configured (production)
- Manifest complete with all required fields
- Icons in all required sizes (192, 512, maskable)
- Service worker registered and working
- Offline page created and cached
- Cache strategies defined for all resource types
- Install prompt handling implemented
- Lighthouse PWA audit passes
- 生产环境已配置HTTPS
- 应用清单完整包含所有必填字段
- 已准备所有必要尺寸的图标(192、512、maskable)
- Service Worker已注册并正常工作
- 已创建并缓存离线页面
- 已为所有资源类型定义缓存策略
- 已实现安装提示处理
- Lighthouse PWA审计通过
After Launch
上线后
- Monitor cache sizes
- Test SW updates don't break app
- Track PWA installs via analytics
- Test on multiple devices/browsers
- Monitor Core Web Vitals
- Set up push notification flow (if needed)
- 监控缓存体积
- 测试Service Worker更新不会导致应用崩溃
- 通过统计工具追踪PWA安装量
- 在多设备/浏览器上测试
- 监控核心Web指标
- 配置推送通知流程(如需)
Framework-Specific Guides
框架特定指南
Next.js
Next.js
bash
npm install next-pwajavascript
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development'
});
module.exports = withPWA({
// Your Next.js config
});bash
npm install next-pwajavascript
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development'
});
module.exports = withPWA({
// 你的Next.js配置
});Create React App
Create React App
bash
undefinedbash
undefinedCRA 4+ has PWA support built-in
CRA 4+ 内置PWA支持
npx create-react-app my-pwa --template cra-template-pwa
undefinednpx create-react-app my-pwa --template cra-template-pwa
undefinedVite (Any Framework)
Vite(任意框架)
bash
npm install vite-plugin-pwa -DSee Workbox with Vite section above for configuration.
bash
npm install vite-plugin-pwa -D配置方式可参考上方"Vite集成Workbox"章节。
Quick Reference
速查手册
Caching Strategy Cheat Sheet
缓存策略速查表
Static Assets (CSS, JS, images) → Cache First
API Responses → Network First
User-generated content → Stale While Revalidate
Analytics, non-cacheable → Network Only
Offline-only assets → Cache Only静态资源(CSS、JS、图片) → Cache First
API响应 → Network First
用户生成内容 → Stale While Revalidate
统计数据、不可缓存内容 → Network Only
仅离线可用资源 → Cache OnlyManifest Minimum Requirements
应用清单最低要求
json
{
"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" }
]
}json
{
"name": "应用名称",
"short_name": "简称",
"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 Lifecycle
Service Worker生命周期
1. Register → 2. Install → 3. Activate → 4. Fetch
↓ ↓ ↓ ↓
Load app Cache assets Clean old Serve requests
caches from cache/network1. 注册 → 2. 安装 → 3. 激活 → 4. 抓取请求
↓ ↓ ↓ ↓
加载应用 缓存静态资源 清理旧缓存 从缓存/网络返回请求