SKILL.md
readonly只读
name
pwa-development
description
Progressive Web Apps - service workers, caching strategies, offline, Workbox
PWA 开发技能
目的: 构建能够离线工作、像原生应用一样安装,并在所有设备上提供快速、可靠体验的渐进式Web应用。
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
- [ ] 包含 Maskable 图标用于 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'
];
// Install: 缓存静态资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate: 清理旧缓存
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: 优先从缓存响应,回退到网络
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 hours
}
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
}
}
}
]
}
})
]
};
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 days
})
]
})
);
// 缓存 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 hours
})
]
})
);
// 缓存页面导航
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>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>
离线检测
// 在线/离线状态处理
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();
后台同步(离线操作队列)
// 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'
);
// 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('Saved offline. Will sync when connected.');
}
}
类应用功能
安装提示
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.8s
- [ ] 最大内容绘制 < 2.5s
- [ ] 可交互时间 < 3.8s
- [ ] 累积布局偏移 < 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
常见错误
| 错误 | 修复 |
|---|---|
| 缺少 maskable 图标 | 添加带有 "purpose": "maskable" 的图标 |
| 无离线回退 | 创建 offline.html 并缓存它 |
| 缓存永不过期 | 使用 Workbox 的 ExpirationPlugin |
| SW 缓存过于激进 | 根据资源类型使用适当的策略 |
| 无更新机制 | 实现 skipWaiting() + 重新加载提示 |
| 安装提示失效 | 确保 manifest 满足所有标准 |
| 生产环境无 HTTPS | 配置 SSL 证书 |
| 缓存过大 | 设置 maxEntries 和 maxAgeSeconds |
| API 响应过时 | 对动态数据使用 NetworkFirst |
| 缺少 start_url 跟踪 | 添加查询参数:/?source=pwa |
PWA 开发检查清单
发布前
- [ ] 配置 HTTPS(生产环境)
- [ ] Manifest 完整,包含所有必填字段
- [ ] 所有必需尺寸的图标(192、512、maskable)
- [ ] Service Worker 已注册并正常工作
- [ ] 离线页面已创建并缓存
- [ ] 为所有资源类型定义了缓存策略
- [ ] 实现了安装提示处理
- [ ] Lighthouse PWA 审计通过
发布后
- [ ] 监控缓存大小
- [ ] 测试 SW 更新不会破坏应用
- [ ] 通过分析跟踪 PWA 安装
- [ ] 在多个设备/浏览器上测试
- [ ] 监控核心 Web 指标
- [ ] 设置推送通知流程(如果需要)
框架特定指南
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({
// Your Next.js config
});
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. 请求
↓ ↓ ↓ ↓
加载应用 缓存资源 清理旧缓存 从缓存/网络
缓存 提供请求






