PWA 的目标不是「替代原生应用」,而是「在 Web 的能力边界内提供尽可能接近原生的体验」。 一个合格的 PWA 可以在弱网甚至离线环境下工作、支持推送通知、能被用户添加到主屏幕——这些曾经只属于原生应用的能力,现在浏览器已经免费开放。
一、PWA 核心组成
PWA = HTTPS + Service Worker + Web App Manifest
├── HTTPS — 安全上下文(SW 要求)
├── Service Worker — 代理网络请求、缓存、后台任务
└── Web App Manifest — 应用元数据(名称、图标、主题色)
扩展能力:
├── Push API — 服务器推送通知
├── Background Sync — 离线操作,恢复网络后自动同步
├── Notifications API — 本地通知
└── Add to Home Screen — 安装到主屏幕
二、Web App Manifest
2.1 manifest.json
{
"name": "My Awesome App",
"short_name": "MyApp",
"description": "A progressive web app built with Vue",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"orientation": "portrait",
"scope": "/",
"icons": [
{ "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" },
{ "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" }
],
"screenshots": [
{ "src": "/screenshots/narrow.png", "sizes": "750x1334", "type": "image/png", "form_factor": "narrow" },
{ "src": "/screenshots/wide.png", "sizes": "1920x1080", "type": "image/png", "form_factor": "wide" }
],
"categories": ["productivity", "utilities"],
"lang": "zh-CN"
}
2.2 display 模式
| 值 | 行为 | 适用 |
|---|---|---|
standalone | 无浏览器 UI,像原生应用 | ✅ 推荐 |
fullscreen | 全屏,无系统 UI | 游戏、沉浸式 |
minimal-ui | 最小浏览器控件 | 阅读器 |
browser | 普通浏览器标签 | 无特殊处理 |
三、Service Worker 生命周期
3.1 三个核心事件
// sw.js
const CACHE_NAME = 'my-app-v2';
const STATIC_ASSETS = [
'/',
'/index.html',
'/app.js',
'/app.css',
'/icons/icon-192x192.png'
];
// 1. Install:首次注册时触发,预缓存核心资源
self.addEventListener('install', (event) => {
console.log('[SW] Install');
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting()) // 立即激活新 SW
);
});
// 2. Activate:新 SW 接管页面,清理旧缓存
self.addEventListener('activate', (event) => {
console.log('[SW] Activate');
event.waitUntil(
caches.keys().then((cacheNames) =>
Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name))
)
).then(() => self.clients.claim()) // 立即控制所有页面
);
});
// 3. Fetch:拦截网络请求
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// 命中缓存则返回,否则走网络
return response || fetch(event.request);
})
);
});
3.2 注册 Service Worker
// main.ts / main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('SW registered:', registration.scope);
// 监听更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 新 SW 已安装但等待激活 → 提示用户刷新
showUpdateNotification();
}
});
});
})
.catch((err) => console.error('SW registration failed:', err));
});
}
function showUpdateNotification() {
const confirmed = confirm('新版本可用,是否刷新?');
if (confirmed) {
navigator.serviceWorker.controller?.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
3.3 SW 更新机制
Service Worker 更新流程:
1. 浏览器每 24h 自动检查 sw.js 是否有变化(字节对比)
2. 检测到变化 → 下载新 SW → 触发 install
3. 新 SW 进入 "waiting" 状态(旧 SW 仍在控制页面)
4. 用户关闭所有相关标签页 → 新 SW activate
5. 或使用 skipWaiting() 强制立即激活
⚠️ 注意:
- SW 安装后持久存在,不是每次加载都重新安装
- 更新时旧缓存不会自动删除,需在 activate 中清理
- 建议通过版本号管理缓存(CACHE_NAME = 'app-v3')
四、缓存策略
4.1 五种策略
策略对比:
┌─────────────────────────────────────────────────────────────┐
│ Cache-First │
│ 先读缓存 → 命中返回 / 未命中 fetch → 写入缓存 │
│ 适合:静态资源(JS/CSS/图片)、不常变的数据 │
├─────────────────────────────────────────────────────────────┤
│ Network-First │
│ 先 fetch → 成功返回 + 写入缓存 / 失败读缓存 │
│ 适合:实时数据、API 响应 │
├─────────────────────────────────────────────────────────────┤
│ Stale-While-Revalidate │
│ 先读缓存返回 → 同时 fetch 更新缓存 → 下次命中新缓存 │
│ 适合:新闻列表、商品列表(显示旧数据比空白好) │
├─────────────────────────────────────────────────────────────┤
│ Cache-Only │
│ 只读缓存,不发起网络请求 │
│ 适合:构建时预缓存的离线页面 │
├─────────────────────────────────────────────────────────────┤
│ Network-Only │
│ 只走网络,不缓存 │
│ 适合:实时性要求极高的请求 │
└─────────────────────────────────────────────────────────────┘
4.2 策略实现
// sw.js — 策略路由
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// 1. 静态资源:Cache-First + 长期缓存
if (request.destination === 'style' || request.destination === 'script') {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// 2. API 请求:Network-First + 短时间缓存
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, API_CACHE, { maxAge: 5 * 60 * 1000 }));
return;
}
// 3. 图片:Stale-While-Revalidate
if (request.destination === 'image') {
event.respondWith(staleWhileRevalidate(request, IMAGE_CACHE));
return;
}
// 4. 页面导航:Network-First,离线回退到离线页面
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/offline.html'))
);
return;
}
// 默认
event.respondWith(fetch(request));
});
// Cache-First
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
cache.put(request, response.clone());
return response;
}
// Network-First
async function networkFirst(request, cacheName, options = {}) {
const cache = await caches.open(cacheName);
try {
const networkResponse = await fetch(request);
cache.put(request, networkResponse.clone());
return networkResponse;
} catch (err) {
const cached = await cache.match(request);
if (cached) return cached;
throw err;
}
}
// Stale-While-Revalidate
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;
}
五、Workbox:自动化 PWA
5.1 为什么选择 Workbox
手写 Service Worker 容易出错,Workbox 提供:
- 预缓存清单自动生成
- 多种缓存策略内置
- 缓存过期与大小限制
- 后台同步、离线分析
- TypeScript 支持
5.2 注入式预缓存(Vite 集成)
pnpm add -D workbox-window
pnpm add -D vite-plugin-pwa
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 }
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|avif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }
}
}
]
},
manifest: {
name: 'My Awesome App',
short_name: 'MyApp',
theme_color: '#3b82f6',
icons: [
{ src: '/icon-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512x512.png', sizes: '512x512', type: 'image/png' }
]
}
})
]
};
5.3 Vue 3 中使用
// main.ts
import { registerSW } from 'virtual:pwa-register';
const updateSW = registerSW({
immediate: true,
onNeedRefresh() {
// 显示更新提示
const toast = document.createElement('div');
toast.innerHTML = `
<div style="position:fixed;bottom:20px;right:20px;background:#333;color:#fff;padding:12px 20px;border-radius:8px;cursor:pointer;">
新版本可用,点击更新
</div>
`;
toast.onclick = () => updateSW(true);
document.body.appendChild(toast);
},
onOfflineReady() {
console.log('App ready to work offline');
}
});
六、Background Sync
6.1 离线操作,在线同步
// 页面中注册后台同步
navigator.serviceWorker.ready.then((registration) => {
document.getElementById('submit-btn').addEventListener('click', async () => {
const data = { message: 'Hello from offline' };
// 尝试直接发送
try {
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
// 网络失败 → 注册后台同步
await registration.sync.register('send-message');
// 存入 IndexedDB 等待同步
await db.messages.add({ ...data, synced: false });
}
});
});
// sw.js 中接收同步事件
self.addEventListener('sync', (event) => {
if (event.tag === 'send-message') {
event.waitUntil(sendPendingMessages());
}
});
async function sendPendingMessages() {
const messages = await db.messages.where('synced').equals(false).toArray();
for (const msg of messages) {
try {
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(msg),
headers: { 'Content-Type': 'application/json' }
});
await db.messages.update(msg.id, { synced: true });
} catch (err) {
console.error('Sync failed for message:', msg.id);
}
}
}
七、Push 通知
7.1 服务端推送
// 1. 页面订阅 Push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicVapidKey)
});
// 2. 将 subscription 发送到服务器保存
await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' }
});
// sw.js 接收推送
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? { title: 'Notification', body: '' };
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon-192x192.png',
badge: '/badge-72x72.png',
data: { url: data.url },
actions: [
{ action: 'open', title: '打开' },
{ action: 'close', title: '关闭' }
]
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'open') {
clients.openWindow(event.notification.data.url);
}
});
八、PWA Checklist
| 检查项 | Lighthous ePWA 要求 | 实现 |
|---|---|---|
| HTTPS | ✅ 必须 | 部署配置 |
| Service Worker | ✅ 必须 | sw.js + 注册 |
| Manifest | ✅ 必须 | manifest.json + |
| 离线回退 | ✅ 推荐 | offline.html + cache |
| 图标 | ✅ 必须 | 多尺寸 PNG + maskable |
| 主题色 | ✅ 推荐 | theme-color meta + manifest |
| 响应式 | ✅ 必须 | Viewport meta + CSS |
| A2HS 提示 | 可选 | beforeinstallprompt |
| Push 通知 | 可选 | Push API + 服务端 |
| Background Sync | 可选 | Sync API + IndexedDB |
参考与延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。