PWA — Progressive Web App
1. 核心概念
三大支柱
| 支柱 |
作用 |
必要条件 |
| Service Worker |
网络代理、离线缓存、后台同步 |
HTTPS(或 localhost) |
| Web App Manifest |
应用元数据、安装能力 |
JSON 配置文件 |
| HTTPS |
安全传输、SW 注册前提 |
生产环境必须 TLS |
PWA 能力层级
基础层 ─── 离线缓存(Cache API) + 可安装(Manifest)
↓
进阶层 ─── 推送通知(Push API) + 后台同步(Background Sync)
↓
高级层 ─── 文件系统(File System Access)、Web Bluetooth、Web USB、WebAssembly
浏览器兼容性现状
| 能力 |
Chrome/Edge |
Safari/iOS Safari |
Firefox |
| Service Worker |
完整支持 |
支持(有限调试) |
支持 |
| Manifest + 安装 |
完整支持 |
display: standalone 受限 |
部分支持 |
| Push Notification |
完整支持 |
不支持 |
支持 |
| Background Sync |
支持 |
不支持 |
不支持 |
| Periodic Sync |
支持(需 flags) |
不支持 |
不支持 |
| Cache API |
完整支持 |
支持(配额严苛) |
支持 |
| IndexedDB |
完整支持 |
支持(50MB 限额提示) |
支持 |
2. Service Worker(重点)
生命周期
register ──→ install ──→ waiting ──→ activate ──→ fetch/idle ──→ redundant
│ │
pre-cache 清理旧缓存
(waitUntil) (clients.claim)
事件详解
install — 预缓存
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('my-app-v1').then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
]);
})
);
});
waitUntil 保证缓存完成前 SW 不会进入 activated
- 失败则本次安装作废,下次更新继续
activate — 清理旧缓存 + 立即接管
self.addEventListener('activate', (event) => {
const cacheWhitelist = ['my-app-v2'];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((name) => {
if (!cacheWhitelist.includes(name)) return caches.delete(name);
})
);
}).then(() => clients.claim()) // 立即控制所有客户端
);
});
clients.claim() 让新 SW 立即接管所有页面,无需等待页面刷新
- activate 中清理旧缓存是最佳实践
fetch — 拦截请求 + 缓存策略
self.addEventListener('fetch', (event) => {
event.respondWith(
// 各种缓存策略见第 3 节
);
});
push — 推送通知
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? { title: '新消息', body: '' };
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon-192.png',
badge: '/badge-72.png',
data: { url: data.url },
})
);
});
sync — 后台同步
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-messages') {
event.waitUntil(syncPendingMessages());
}
});
Scope 与注册
// scope 默认为 SW 文件所在目录(或子目录)
navigator.serviceWorker.register('/sw.js', { scope: '/' });
// 若 SW 在 /scripts/sw.js,默认 scope = /scripts/
// 要扩大 scope,需要服务端响应头:
// Service-Worker-Allowed: /
| 路径 |
注册 scope |
可控制的页面 |
/sw.js |
/ |
/, /about/, /blog/ |
/scripts/sw.js |
/scripts/ |
仅 /scripts/** |
/scripts/sw.js + Service-Worker-Allowed: / |
/ |
全部 |
更新机制
// 每次导航自动检查更新,最多 24h 一次
// 手动触发:
navigator.serviceWorker.register('/sw.js').then((reg) => {
reg.update(); // 强制检查更新
});
// 检测到新版本,立即激活
self.addEventListener('install', () => {
self.skipWaiting(); // 跳过 waiting,直接进入 activate
});
- 浏览器每 24h 自动检查一次 SW 更新(字节对比)
- 新 SW 默认进入
waiting 状态,等老页面全部关闭后接管
skipWaiting() + clients.claim() 组合拳实现无缝升级
调试工具
| 工具 |
用途 |
| Chrome DevTools > Application > Service Workers |
手动更新 / unregister / skipWaiting / 模拟离线 / 查看缓存 |
chrome://serviceworker-internals |
查看所有 SW 状态 |
chrome://inspect/#service-workers |
远程调试 SW 上下文 |
3. 缓存策略
六大策略
1. Cache Only
event.respondWith(caches.match(event.request));
- 时机:从不请求网络
- 场景:静态资源(已预缓存的内容)
2. Network Only
event.respondWith(fetch(event.request));
3. Cache First(离线优先)
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request).then((res) => {
return caches.open('dynamic-v1').then((cache) => {
cache.put(event.request, res.clone());
return res;
});
});
})
);
- 时机:优先命中缓存,缓存未命中则回退网络并写入缓存
- 场景:不变的静态资源(字体、CSS、图片)
4. Network First(网络优先)
event.respondWith(
fetch(event.request).then((res) => {
return caches.open('dynamic-v1').then((cache) => {
cache.put(event.request, res.clone());
return res;
});
}).catch(() => caches.match(event.request))
);
- 时机:优先请求网络,失败回退缓存
- 场景:需要最新数据的 API、新闻页
5. Stale-While-Revalidate
event.respondWith(
caches.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request).then((res) => {
return caches.open('dynamic-v1').then((cache) => {
cache.put(event.request, res.clone());
return res;
});
});
return cached || fetchPromise;
})
);
- 时机:立即展示缓存,后台静默更新
- 场景:头像、UI 资源、用户资料(可接受短暂陈旧)
6. Cache & Network Race
event.respondWith(
new Promise((resolve) => {
let resolved = false;
const settle = (response) => {
if (!resolved) { resolved = true; resolve(response); }
};
caches.match(event.request).then(settle);
fetch(event.request).then((res) => {
caches.open('dynamic-v1').then((cache) => cache.put(event.request, res.clone()));
settle(res);
}).catch(() => {});
})
);
- 时机:缓存和网络同时抢答,取先返回的
- 场景:低延迟优先的场景(竞速)
预缓存 vs 运行时缓存
|
预缓存(precaching) |
运行时缓存(runtime caching) |
| 时机 |
install 阶段 |
fetch 阶段 |
| 内容 |
App Shell(HTML/CSS/JS/Logo) |
API 响应、图片 |
| 目的 |
保证离线基础可用 |
提高重复访问性能 |
| 管理 |
版本化 + 批量更新 |
动态 + LRU 淘汰 |
缓存版本管理
const CACHE_NAME = 'my-app-v2';
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((n) => n !== CACHE_NAME)
.map((n) => caches.delete(n))
)
)
);
});
- 每次修改 SW 逻辑或资源列表,更新
CACHE_NAME 版本号
- activate 中遍历所有缓存,删除非当前版本
缓存空间限制
| 浏览器 |
配额规则 |
| Chrome |
~60% 磁盘可用空间(单 origin,全局共享) |
| Safari |
~1GB(iOS 更严,7 天不交互可能清空) |
| Firefox |
~50% 磁盘可用空间 |
navigator.storage.estimate().then(({ usage, quota }) => {
console.log(`已用: ${(usage / 1024 / 1024).toFixed(1)}MB / 配额: ${(quota / 1024 / 1024).toFixed(1)}MB`);
});
4. Workbox(Google 官方工具)
为什么用 Workbox
- 封装 SW 生命周期,避免手写
install / activate / fetch 模板代码
- 预缓存清单自动生成,配合构建工具实现哈希指纹
- 内置 6 种缓存策略和路由匹配
- 处理了边界情况(重定向、opaque response、跨域请求)
核心模块
| 模块 |
功能 |
workbox-precaching |
预缓存管理,自动生成 manifest |
workbox-routing |
路由注册,URL 匹配规则 |
workbox-strategies |
6 种缓存策略(StaleWhileRevalidate / NetworkFirst / CacheFirst / NetworkOnly / CacheOnly) |
workbox-expiration |
缓存条目数 / 时间限制 |
workbox-cacheable-response |
基于状态码 / header 限制哪些响应可缓存 |
workbox-background-sync |
离线请求队列,恢复网络自动重放 |
workbox-google-analytics |
离线 GA 事件回放 |
示例配置
// sw.js — 手动模式
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
workbox.routing.registerRoute(
/\.(?:png|jpg|jpeg|svg|gif|webp)$/,
new workbox.strategies.CacheFirst({
cacheName: 'images',
plugins: [
new workbox.expiration.ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 }),
new workbox.cacheableResponse.CacheableResponsePlugin({ statuses: [0, 200] }),
],
})
);
workbox.routing.registerRoute(
/\/api\/.*/,
new workbox.strategies.NetworkFirst({ cacheName: 'api-cache' })
);
workbox.routing.registerRoute(
({ request }) => request.destination === 'document',
new workbox.strategies.StaleWhileRevalidate({ cacheName: 'documents' })
);
构建集成
| 构建工具 |
集成方式 |
| Webpack |
workbox-webpack-plugin 的 GenerateSW(自动生成 SW)/ InjectManifest(注入 manifest,自定义 SW) |
| Vite |
vite-plugin-pwa(基于 Workbox,零配置可用) |
| CLI |
workbox-cli 生成 SW + 构建配置 |
Vite 集成
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico'],
manifest: { /* ... */ },
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [
{
urlPattern: /\/api\//,
handler: 'NetworkFirst',
options: { cacheName: 'api-cache' },
},
],
},
}),
],
});
Webpack 集成(GenerateSW)
// webpack.config.js
const { GenerateSW } = require('workbox-webpack-plugin');
module.exports = {
plugins: [
new GenerateSW({
clientsClaim: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /\.(?:png|jpg|jpeg|svg)$/,
handler: 'CacheFirst',
options: { cacheName: 'images', expiration: { maxEntries: 60 } },
},
],
}),
],
};
Recipes
SPA 缓存方案
// App Shell (HTML) — NetworkFirst 或 CacheFirst + 网络回退
workbox.routing.registerRoute(
({ request }) => request.mode === 'navigate',
new workbox.strategies.NetworkFirst({
cacheName: 'pages',
plugins: [
new workbox.expiration.ExpirationPlugin({ maxAgeSeconds: 5 * 60 }), // 5 分钟
],
})
);
// API — NetworkFirst
workbox.routing.registerRoute(
/\/api\/.*/,
new workbox.strategies.NetworkFirst({ cacheName: 'api' })
);
Google Fonts 缓存
workbox.routing.registerRoute(
/^https:\/\/fonts\.googleapis\.com\/.*/i,
new workbox.strategies.StaleWhileRevalidate({ cacheName: 'google-fonts' })
);
workbox.routing.registerRoute(
/^https:\/\/fonts\.gstatic\.com\/.*/i,
new workbox.strategies.CacheFirst({
cacheName: 'google-fonts',
plugins: [
new workbox.expiration.ExpirationPlugin({ maxAgeSeconds: 365 * 24 * 60 * 60 }),
new workbox.cacheableResponse.CacheableResponsePlugin({ statuses: [0, 200] }),
],
})
);
5. Web App Manifest
核心字段
{
"name": "我的应用",
"short_name": "我的应用",
"description": "这是一个 PWA 示例",
"start_url": "/?source=pwa",
"display": "standalone",
"orientation": "portrait-primary",
"theme_color": "#4285f4",
"background_color": "#ffffff",
"scope": "/",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"categories": ["productivity"],
"lang": "zh-CN"
}
| 字段 |
说明 |
必填 |
name |
应用全名,安装提示显示 |
推荐 |
short_name |
桌面图标下方名称,12 字符以内 |
推荐 |
icons |
至少包含 192x192 和 512x512 |
是 |
start_url |
启动时打开的 URL |
推荐 |
display |
standalone / fullscreen / minimal-ui / browser |
是 |
theme_color |
地址栏 / 任务切换器颜色 |
推荐 |
background_color |
Splash Screen 背景色 |
推荐 |
scope |
限制哪些 URL 属于应用范围 |
推荐 |
orientation |
portrait-primary / landscape |
选填 |
install 安装提示 (beforeinstallprompt)
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
// 阻止浏览器默认安装栏
e.preventDefault();
deferredPrompt = e;
// 显示自定义安装按钮
installBtn.style.display = 'block';
installBtn.addEventListener('click', async () => {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log(outcome); // 'accepted' | 'dismissed'
deferredPrompt = null;
installBtn.style.display = 'none';
});
});
// 监听安装完成
window.addEventListener('appinstalled', () => {
console.log('PWA 已安装');
});
iOS Safari 特殊适配
<!-- iOS 专用 meta 标签 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/icon-192.png">
<link rel="apple-touch-startup-image" href="/splash.png">
PWA 安装条件(Chrome)
- 页面通过 HTTPS 提供
- 存在有效的 Web App Manifest(含
name / icons / start_url / display)
- 已注册 Service Worker(含有效的
fetch handler)
- 用户有足够的交互(Chrome 自动判断,非自定义)
6. 推送通知
架构
浏览器 ←→ Push Service(Chrome → FCM, Firefox → Autopush, Edge → WNS)
↑
Service Worker ←→ 后端推送
订阅流程
// 请求通知权限
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
// 订阅推送
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),
});
subscription 返回对象结构:
{
"endpoint": "https://fcm.googleapis.com/...",
"keys": {
"p256dh": "BOr...",
"auth": "aBc..."
}
}
显示通知
self.registration.showNotification(title, {
body: '通知正文',
icon: '/icon-192.png',
badge: '/badge-72.png',
image: '/notification-image.png', // 大图
vibrate: [200, 100, 200], // 振动模式
tag: 'message-group', // 相同 tag 自动折叠
renotify: true, // 同 tag 重复通知是否提醒
data: { url: '/messages' }, // 携带数据
actions: [ // 通知按钮
{ action: 'reply', title: '回复', icon: '/reply.png' },
{ action: 'dismiss', title: '忽略' },
],
silent: false,
requireInteraction: true, // 不自动消失
});
通知交互
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url ?? '/';
if (event.action === 'reply') {
// 打开特定回复界面
clients.openWindow('/messages/reply');
return;
}
// 检查是否已有窗口打开,有则 focus,否则新开
event.waitUntil(
clients.matchAll({ type: 'window' }).then((windowClients) => {
const target = windowClients.find((c) => c.url === url);
if (target) return target.focus();
return clients.openWindow(url);
})
);
});
权限管理
// 检测权限状态
const state = Notification.permission;
// 'granted' | 'denied' | 'default'
// 用户拒绝后的处理策略
if (state === 'denied') {
showPermissionGuide(); // 提示用户去设置中手动开启
}
// 再次请求(仅当状态为 default 时可触发弹窗)
if (state === 'default') {
Notification.requestPermission();
}
7. 离线与后台能力
Background Sync
// 页面注册同步任务
document.getElementById('sendBtn').addEventListener('click', async () => {
await saveToIndexedDB({ id: Date.now(), text: '待发送消息' });
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-messages');
});
// SW 处理同步
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-messages') {
event.waitUntil(syncPendingToServer());
}
});
| 特性 |
说明 |
| 触发条件 |
网络恢复后立即执行 |
| 事件保留 |
未处理的 sync 事件不会丢失(下次上网再试) |
| 限制 |
无定时/定频保证;iOS 不支持 |
| 场景 |
表单提交、消息发送、评论系统 |
Periodic Background Sync(定时同步)
// 注册
const registration = await navigator.serviceWorker.ready;
await registration.periodicSync.register('content-sync', {
minInterval: 12 * 60 * 60 * 1000, // 最小间隔 12h
});
// SW 处理
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'content-sync') {
event.waitUntil(fetchAndUpdateContent());
}
});
- 需要 Chrome(Flag 开启)+ 站点满足 engagement 条件(用户安装 + 有交互)
minInterval 仅为建议值,浏览器可延长
- iOS / Safari 不支持
IndexedDB 离线存储
| 存储方式 |
适合 |
不适合 |
| Cache API |
HTTP 请求/响应对 |
结构化业务数据 |
| IndexedDB |
结构化数据(JSON、Blob) |
简单键值对 |
| localStorage |
小量配置(同步 API,5MB 上限) |
大量离线数据 |
// 简单封装 IndexedDB 操作
const db = await idb.openDB('my-app', 1, {
upgrade(db) {
db.createObjectStore('messages', { keyPath: 'id', autoIncrement: true });
},
});
await db.add('messages', { text: 'hello', timestamp: Date.now() });
const all = await db.getAll('messages');
离线 UX 设计
| 方案 |
实现 |
| 离线提示 Toast |
监听 window.online + window.offline 显示浮层 |
| 优雅降级 |
缓存内容优先展示,网络错误显示重试按钮 + 错误页面 |
| 骨架屏 |
SW 先返回缓存的 App Shell + 骨架屏,数据到达后替换 |
window.addEventListener('online', () => showToast('网络已恢复'));
window.addEventListener('offline', () => showToast('当前离线,展示缓存内容'));
8. 实践与优化
App Shell 架构
┌─────────────────────────┐
│ Header (缓存) │ ← App Shell
├─────────────────────────┤
│ Sidebar (缓存) │ ← 静态 UI 框架
├─────────────────────────┤
│ Content (API 数据) │ ← 运行时缓存/Network First
└─────────────────────────┘
- 壳:HTML / CSS / JS / Logo 在 install 阶段预缓存
- 数据:API 响应走 Network First 或 StaleWhileRevalidate
- 首屏秒开(无需网络即可展示完整 UI 框架)
PWA 检测与引导安装
// 检测是否已作为 PWA 运行
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone; // iOS
// 检测是否支持 PWA
const supportsPWA = 'serviceWorker' in navigator;
// 自定义安装提示按钮
let installPrompt = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
installPrompt = e;
document.getElementById('install-btn').hidden = false;
});
document.getElementById('install-btn').addEventListener('click', async () => {
installPrompt?.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'dismissed') {
// 用户拒绝,降低安装提示频率
}
installPrompt = null;
});
iOS PWA 特殊处理
| 问题 |
解决 |
| 独立状态栏 |
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> |
| 存储限制 |
iOS 约 1GB,超过 7 天无交互可能清空 SW 缓存 |
| Safari 无 beforeinstallprompt |
自行检测 + 提示用户通过"分享 → 添加到主屏幕" |
navigator.standalone |
用此检测 iOS PWA 环境 |
| Service Worker 调试困难 |
Safari > 开发 > 连接设备后查看 |
| localStorage 不持久 |
推荐使用 IndexedDB(iOS 13+ 完善) |
<!-- iOS 必备 -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" href="/icon-192.png">
性能指标 / Lighthouse PWA 评分项
| 检查项 |
要求 |
| HTTPS |
重定向到 HTTPS,证书有效 |
| 可安装 |
有效 manifest + SW + HTTPS |
| 离线可用 |
200 响应来自 Cache(SW 返回缓存) |
| Splash Screen |
name + background_color + icons 512x512 配置 |
| 注册 SW |
页面注册了 SW |
请求预缓存(start_url) |
SW 预缓存了 start_url 的响应 |
| 页面加载速度 |
首屏内容(LCP)< 3s(硬件影响) |
| 视口配置 |
有 width=device-width |
| robots 不禁止 |
X-Robots-Tag 无 noindex |
Lighthouse PWA 评分 ≈ (可安装 + 离线 + Splash + HTTPS + 速度) / 总分
Chrome 的 PWA 评分标准已从独立 Lighthouse 分类合并到 Best Practices / SEO / Performance
参考