为什么你的下一个项目需要 PWA
在移动优先的时代,用户期望应用能像原生一样快速、可靠、可安装。PWA(渐进式 Web 应用)通过 Service Worker 赋予 Web 应用离线能力、后台同步和推送通知,而无需上架应用商店。
但很多开发者对 Service Worker 的理解停留在“缓存 HTML”的层面,实际落地时会遇到缓存更新不及时、推送权限被拒、后台同步失败等问题。本文将用一个可运行的 Demo(一个简单的笔记应用)带你完整走一遍 PWA 的实现路径,并分享我在生产环境中总结的 7 个最佳实践。
环境准备与项目初始化
我们使用原生 JavaScript + Vite 构建,避免框架干扰。先创建项目:
npm create vite@latest pwa-notes -- --template vanilla
cd pwa-notes
npm install
npm install workbox-window -D
项目结构如下:
pwa-notes/
├── index.html
├── main.js
├── sw.js # Service Worker 入口
├── manifest.json # PWA 清单文件
└── icons/ # 应用图标
第一步:编写 manifest.json 让应用可安装
PWA 可安装的前提是提供有效的 manifest。在项目根目录创建 manifest.json:
{
"name": "PWA Notes",
"short_name": "Notes",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4f46e5",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
在 index.html 的 中引入:
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#4f46e5" />
注意:start_url 必须是相对路径或同源路径,否则安装提示不会出现。图标至少提供 192×192 和 512×512 两个尺寸,Chrome 才会触发安装横幅。
第二步:Service Worker 生命周期与注册
Service Worker 的生命周期是理解一切缓存行为的基础。它经历 install → waiting → activate → fetch 等阶段。关键点:新的 SW 不会立即接管页面,直到所有旧页面关闭。
在 main.js 中注册:
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('SW 注册成功,作用域:', registration.scope);
} catch (error) {
console.error('SW 注册失败:', error);
}
});
}
💡 最佳实践:不要用 navigator.serviceWorker.ready 来等待激活,它永远不会在注册失败时 resolve。更好的做法是监听 updatefound 事件,提示用户刷新。
第三步:实现离线缓存策略
在 sw.js 中,我们采用 Stale-While-Revalidate 策略:优先返回缓存,同时后台更新缓存。这是平衡速度和新鲜度的最佳方案。
const CACHE_NAME = 'pwa-notes-v1';
const PRECACHE_URLS = ['/', '/index.html', '/main.js', '/manifest.json'];
// 安装时预缓存核心资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting(); // 强制新 SW 立即激活
});
// 激活时清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
)
)
);
self.clients.claim(); // 立即接管所有页面
});
// 拦截请求
self.addEventListener('fetch', (event) => {
const { request } = event;
// 只处理 GET 请求
if (request.method !== 'GET') return;
event.respondWith(
caches.match(request).then((cached) => {
const fetchPromise = fetch(request)
.then((networkResponse) => {
// 只缓存成功的响应
if (networkResponse && networkResponse.status === 200) {
const responseClone = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
}
return networkResponse;
})
.catch(() => cached); // 网络失败时回退到缓存
return cached || fetchPromise;
})
);
});
注意:skipWaiting() 和 clients.claim() 虽然方便,但可能导致新旧版本资源混用。生产环境建议配合版本号控制,或使用 Workbox 的 GenerateSW 插件自动处理。
第四步:后台同步(Background Sync)
当用户离线时提交笔记,我们希望网络恢复后自动重试。后台同步 API 可以做到:
// main.js 中提交表单
async function submitNote(note) {
try {
const response = await fetch('/api/notes', {
method: 'POST',
body: JSON.stringify(note),
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) throw new Error('提交失败');
} catch (error) {
// 离线或失败,注册后台同步
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-notes');
// 将数据存入 IndexedDB 以便后续重试
await saveNoteToIndexedDB(note);
}
}
在 sw.js 中监听同步事件:
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-notes') {
event.waitUntil(retryPendingNotes());
}
});
async function retryPendingNotes() {
const notes = await getPendingNotesFromIndexedDB();
for (const note of notes) {
try {
await fetch('/api/notes', {
method: 'POST',
body: JSON.stringify(note),
headers: { 'Content-Type': 'application/json' }
});
await deleteNoteFromIndexedDB(note.id);
} catch (error) {
// 仍然失败,等待下次同步
throw error;
}
}
}
💡 最佳实践:后台同步只在用户有网络时触发,但 Chrome 要求页面在后台时才能触发同步。测试时可以通过 DevTools 的 Application → Background Services → Sync 手动触发。
第五步:消息推送(Push API)
推送需要后端配合,这里我们用 Node.js 演示一个极简的推送服务。先在前端订阅:
// main.js 中请求通知权限并订阅
async function subscribePush() {
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('YOUR_PUBLIC_VAPID_KEY')
});
// 将 subscription 发送到后端保存
await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' }
});
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
在 sw.js 中处理推送事件:
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {};
event.waitUntil(
self.registration.showNotification(data.title || '新消息', {
body: data.body || '你有一条新通知',
icon: '/icons/icon-192.png',
badge: '/icons/badge.png',
data: { url: data.url || '/' }
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
注意:VAPID 密钥对必须通过 web-push 库生成,公钥在前端使用,私钥在后端签名。不要将私钥暴露在前端代码中。
常见坑与解决方案
1. 缓存了错误的响应
cache.put() 会缓存所有响应,包括 404 和 500。务必检查 response.status === 200 且 response.type === 'basic'(同源)。
2. 开发环境缓存导致热更新失效
在开发环境中,建议禁用 Service Worker 或使用 network-first 策略。Vite 项目可以通过环境变量判断:
if (import.meta.env.DEV) {
// 开发环境不注册 SW
} else {
navigator.serviceWorker.register('/sw.js');
}
3. 推送权限被拒绝后无法再次请求
一旦用户拒绝,Notification.requestPermission() 不会再弹出。必须引导用户手动在浏览器设置中开启。
4. iOS Safari 的兼容性
iOS 从 16.4 开始支持 Web Push,但要求 PWA 必须添加到主屏幕。且不支持 beforeinstallprompt 事件,需要手动引导用户通过分享菜单安装。
使用 Workbox 简化生产环境
手动管理缓存版本和策略容易出错,推荐使用 Workbox。安装:
npm install workbox-webpack-plugin -D
在 vite.config.js 中配置:
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,png,svg}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 86400 }
}
}
]
}
})
]
};
Workbox 会自动生成 sw.js 并处理版本控制,你只需关注业务逻辑。
总结与下一步
通过本文,你完成了:
- 配置 manifest 使应用可安装
- 实现 Stale-While-Revalidate 缓存策略
- 使用 Background Sync 处理离线提交
- 集成 Push API 实现消息推送
- 了解常见坑与 Workbox 最佳实践
下一步建议:
- 学习 Workbox 的
BroadcastUpdatePlugin实现页面无刷新更新 - 探索 Periodic Background Sync 实现定时同步
- 使用 Lighthouse 审计 PWA 得分,优化性能指标
PWA 不是银弹,但它是目前最接近原生体验的 Web 方案。希望这篇指南能帮你少走弯路。