什么是 PWA?
Progressive Web App(渐进式 Web 应用) 是一种使用 Web 技术构建的应用,它能提供接近原生应用的体验。PWA 不是某种特定技术,而是一组最佳实践的集合。
PWA 的核心特征
| 特征 | 说明 |
|---|---|
| 可安装 | 用户可将应用添加到主屏幕,无需应用商店 |
| 离线工作 | 无网络时仍能访问核心功能 |
| 推送通知 | 可向用户发送消息提醒 |
| 响应式 | 适配任何设备和屏幕尺寸 |
| 安全 | 必须通过 HTTPS 提供服务 |
| 可发现 | 搜索引擎可以索引内容 |
为什么需要 PWA?
- 跨平台:一套代码,运行在所有设备上
- 无需安装:降低用户使用门槛
- 更新自动:无需用户手动更新版本
- 体积小:相比原生应用,占用空间极小
- 分享方便:一个 URL 即可分享
PWA 的三大支柱
1. Service Worker
Service Worker 是运行在后台的脚本,独立于网页,是 PWA 的核心引擎。
// service-worker.js
const CACHE_NAME = 'my-pwa-v1';
const ASSETS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png'
];
// 安装阶段:缓存核心资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS);
})
);
});
// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
);
})
);
});
// 拦截请求:缓存优先策略
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
2. Web App Manifest
Manifest 是一个 JSON 文件,定义应用的元数据,让浏览器知道如何安装和显示应用。
// manifest.json
{
"name": "我的 PWA 应用",
"short_name": "PWA Demo",
"description": "一个渐进式 Web 应用示例",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#42b983",
"orientation": "portrait-primary",
"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"
},
{
"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"
}
]
}
在 HTML 中引用:
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#42b983">
<link rel="apple-touch-icon" href="/icons/icon-152x152.png">
3. HTTPS
PWA 必须通过 HTTPS 提供服务(本地开发除外)。这是为了保护用户数据和防止中间人攻击。
缓存策略
不同的场景需要不同的缓存策略:
1. Cache First(缓存优先)
适合静态资源(CSS、JS、图片):
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request).then((response) => {
return caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, response.clone());
return response;
});
});
})
);
});
2. Network First(网络优先)
适合需要最新内容的场景(新闻、数据):
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(event.request);
})
);
});
3. Stale While Revalidate
适合不频繁变化的内容(头像、配置):
self.addEventListener('fetch', (event) => {
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;
});
})
);
});
实现离线页面
当用户离线且访问未缓存的页面时,显示友好的离线提示:
// 创建离线页面
// offline.html
<!DOCTYPE html>
<html>
<head>
<title>离线了</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-family: sans-serif;
text-align: center;
}
</style>
</head>
<body>
<div>
<h1>📡 你已离线</h1>
<p>请检查网络连接后重试</p>
<button onclick="location.reload()">重试</button>
</div>
</body>
</html>
// 在 Service Worker 中
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request).catch(() => {
if (event.request.headers.get('accept').includes('text/html')) {
return caches.match('/offline.html');
}
})
);
});
推送通知
1. 请求用户授权
async function requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('此浏览器不支持通知');
return;
}
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('通知权限已授予');
// 注册推送
registerPushSubscription();
}
}
2. 注册推送订阅
async function registerPushSubscription() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array('YOUR_VAPID_PUBLIC_KEY')
});
// 将订阅信息发送到你的服务器
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
3. 处理推送消息
// service-worker.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-192x192.png',
badge: '/icons/badge-72x72.png',
data: data.url || '/',
actions: [
{ action: 'open', title: '打开' },
{ action: 'dismiss', title: '关闭' }
]
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'open') {
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clientList) => {
for (const client of clientList) {
if (client.url === event.notification.data && 'focus' in client) {
return client.focus();
}
}
return clients.openWindow(event.notification.data);
})
);
}
});
安装提示
当用户可以将应用添加到主屏幕时,显示安装提示:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
showInstallButton();
});
async function installApp() {
if (!deferredPrompt) {
console.log('安装提示不可用');
return;
}
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('用户接受了安装');
}
deferredPrompt = null;
}
<button id="installBtn" style="display: none;">
安装应用
</button>
<script>
const installBtn = document.getElementById('installBtn');
function showInstallButton() {
installBtn.style.display = 'block';
}
installBtn.addEventListener('click', installApp);
</script>
检测 PWA 支持
function checkPWASupport() {
const checks = {
serviceWorker: 'serviceWorker' in navigator,
pushManager: 'PushManager' in window,
notification: 'Notification' in window,
manifest: !!document.querySelector('link[rel="manifest"]'),
https: window.location.protocol === 'https:' ||
window.location.hostname === 'localhost'
};
checks.isPWA = Object.values(checks).every(v => v);
console.log('PWA 支持情况:', checks);
return checks;
}
使用 Vite Plugin PWA(推荐)
如果你使用 Vite 构建项目,可以用这个插件自动生成 Service Worker:
npm install vite-plugin-pwa -D
// 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: '我的 PWA',
short_name: 'PWA',
description: '渐进式 Web 应用',
theme_color: '#42b983',
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 // 1 天
}
}
}
]
}
})
]
};
Lighthouse 测试
使用 Chrome DevTools 的 Lighthouse 检查 PWA 合规性:
- 打开 Chrome DevTools
- 切换到 Lighthouse 标签
- 选择 “Progressive Web App”
- 运行测试
关键检查项:
- ✅ 使用了 HTTPS
- ✅ 注册了 Service Worker
- ✅ 有 Web App Manifest
- ✅ 有
<meta name="viewport"> - ✅ 有合适的图标尺寸
- ✅ 支持离线访问
- ✅ 响应式设计
常见陷阱
1. Service Worker 作用域
Service Worker 只能控制其所在路径及子路径:
/service-worker.js → 控制整个站点
/scripts/sw.js → 只控制 /scripts/ 及以下
建议: 放在根目录
2. 缓存版本管理
忘记更新缓存名会导致旧资源一直被使用:
// 每次更新时改变版本号
const CACHE_NAME = 'my-app-v2'; // 从 v1 升级到 v2
3. 推送通知滥用
不要频繁请求通知权限,只在用户完成某个操作后请求:
// ❌ 页面加载就请求
// ✅ 用户点击"订阅更新"按钮后请求
总结
PWA 让 Web 应用拥有原生体验:
- ✅ 离线工作:Service Worker 缓存核心资源
- ✅ 可安装:Manifest 定义应用元数据
- ✅ 推送通知:保持用户参与
- ✅ 跨平台:一套代码,多端运行
- ✅ 易部署:无需应用商店审核
开始构建 PWA 的步骤:
- 确保使用 HTTPS
- 创建 Web App Manifest
- 注册 Service Worker
- 实现缓存策略
- 添加推送通知(可选)
- 通过 Lighthouse 测试
PWA 不是未来,而是现在。你的下一个 Web 项目,值得拥有 PWA 的能力。
参考资料: