PWA 渐进式应用
大约 9 分钟约 2622 字
PWA 渐进式应用
简介
PWA(Progressive Web App)使用现代 Web 技术让网页应用拥有类似原生应用的体验。通过 Service Worker 离线缓存、Web App Manifest 添加到主屏幕和推送通知,PWA 兼具 Web 的便捷性和原生应用的体验。
特点
Web App Manifest
配置文件
// public/manifest.json
{
"name": "My PWA App",
"short_name": "MyApp",
"description": "我的渐进式 Web 应用",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0078D4",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["business", "productivity"],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}<!-- HTML 中引入 -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0078D4">
<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="/icons/icon-192x192.png">Service Worker
缓存策略
// public/sw.js
const CACHE_NAME = 'myapp-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/manifest.json',
'/icons/icon-192x192.png'
];
// 安装:预缓存静态资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// 激活:清理旧缓存
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())
);
});
// 请求拦截:缓存优先
self.addEventListener('fetch', (event) => {
const { request } = event;
// API 请求:网络优先
if (request.url.includes('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// 静态资源:缓存优先
event.respondWith(cacheFirst(request));
});
// 缓存优先策略
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
}
// 网络优先策略
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ error: '离线' }), {
headers: { 'Content-Type': 'application/json' }
});
}
}注册 Service Worker
// 注册
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('Service Worker 注册成功:', registration.scope);
// 检查更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
// 新版本就绪,提示用户刷新
if (confirm('发现新版本,是否立即更新?')) {
window.location.reload();
}
}
});
});
} catch (error) {
console.error('Service Worker 注册失败:', error);
}
});
}Vite PWA 插件
自动化配置
// npm install -D vite-plugin-pwa
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
manifest: {
name: 'My PWA App',
short_name: 'MyApp',
theme_color: '#0078D4',
icons: [
{ src: '/icons/icon-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-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: 300 },
cacheableResponse: { statuses: [0, 200] }
}
}
]
}
})
]
})推送通知
通知功能
// 请求通知权限
async function requestNotificationPermission() {
if (!('Notification' in window)) return;
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('通知权限已授予');
}
}
// 显示通知
function showNotification(title, options = {}) {
if (Notification.permission === 'granted') {
new Notification(title, {
icon: '/icons/icon-192x192.png',
badge: '/icons/badge.png',
...options
});
}
}
// Service Worker 推送
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
const title = data.title || '新消息';
const options = {
body: data.body || '',
icon: '/icons/icon-192x192.png',
data: data.url
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
// 通知点击
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data || '/')
);
});缓存策略对比
| 策略 | 说明 | 适用场景 |
|---|---|---|
| Cache First | 缓存优先 | 静态资源 |
| Network First | 网络优先 | API 请求 |
| Stale While Revalidate | 先缓存后台更新 | 非关键数据 |
| Cache Only | 仅缓存 | 离线页面 |
| Network Only | 仅网络 | 非幂等请求 |
Stale While Revalidate 策略
// 先返回缓存,同时在后台更新缓存
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME)
const cached = await cache.match(request)
// 后台更新(不阻塞响应)
const fetchPromise = fetch(request).then(response => {
if (response.ok) {
cache.put(request, response.clone())
}
return response
}).catch(() => cached)
// 优先返回缓存,无缓存时等待网络
return cached || fetchPromise
}
// 使用场景:非关键数据如用户头像、文章封面图
self.addEventListener('fetch', (event) => {
if (request.url.match(/\.(png|jpg|webp|svg)$/)) {
event.respondWith(staleWhileRevalidate(request))
}
})离线回退页面
// sw.js — 离线时显示自定义页面
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(async () => {
const cache = await caches.open(CACHE_NAME)
return cache.match('/offline.html') || new Response(
'<h1>您当前处于离线状态</h1><p>请检查网络连接后重试。</p>',
{ headers: { 'Content-Type': 'text/html; charset=utf-8' } }
)
})
)
}
})
// public/offline.html
// <!DOCTYPE html>
// <html>
// <head><title>离线</title></head>
// <body>
// <h1>离线模式</h1>
// <p>无法连接到网络,请稍后再试。</p>
// </body>
// </html>后台同步(Background Sync)
// sw.js — 离线时排队请求,恢复网络后自动发送
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-messages') {
event.waitUntil(syncPendingMessages())
}
})
async function syncPendingMessages() {
const db = await openDB()
const pending = await db.getAll('pending-messages')
for (const msg of pending) {
try {
await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(msg.data),
})
await db.delete('pending-messages', msg.id)
} catch (error) {
console.error('同步失败:', error)
// 下次 sync 事件会重试
}
}
}
// 在主线程中注册 sync 事件
async function registerSync() {
const registration = await navigator.serviceWorker.ready
await registration.sync.register('sync-messages')
}Web Push 订阅管理
// 订阅推送通知的完整流程
async function subscribePush() {
const registration = await navigator.serviceWorker.ready
// 获取服务端公钥(VAPID)
const response = await fetch('/api/push/vapid-key')
const vapidKey = await response.text()
// 创建订阅
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
})
// 发送订阅到服务端
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
})
console.log('推送订阅成功')
}
// 取消订阅
async function unsubscribePush() {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.endpoint),
})
}
}
// VAPID 公钥转换
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
}高级通知交互
// sw.js — 带操作按钮的通知
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {}
const options = {
body: data.body || '您有新消息',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge.png',
image: data.image,
data: { url: data.url },
actions: [
{ action: 'open', title: '查看详情' },
{ action: 'dismiss', title: '忽略' },
],
tag: data.tag || 'default', // 相同 tag 会替换旧通知
renotify: true,
requireInteraction: data.important || false,
}
event.waitUntil(
self.registration.showNotification(data.title || '通知', options)
)
})
// 处理通知操作点击
self.addEventListener('notificationclick', (event) => {
event.notification.close()
switch (event.action) {
case 'open':
event.waitUntil(
clients.openWindow(event.notification.data.url || '/')
)
break
case 'dismiss':
default:
// 关闭通知,不做额外操作
break
}
})IndexedDB 离线数据存储
// utils/offlineDB.ts — 使用 IndexedDB 存储离线数据
class OfflineDB {
constructor(dbName, version) {
this.dbName = dbName
this.version = version
this.db = null
}
async open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onupgradeneeded = (event) => {
const db = event.target.result
if (!db.objectStoreNames.contains('pending-requests')) {
db.createObjectStore('pending-requests', { keyPath: 'id', autoIncrement: true })
}
if (!db.objectStoreNames.contains('cached-data')) {
const store = db.createObjectStore('cached-data', { keyPath: 'key' })
store.createIndex('timestamp', 'timestamp')
}
}
request.onsuccess = (event) => {
this.db = event.target.result
resolve(this.db)
}
request.onerror = (event) => reject(event.target.error)
})
}
async savePendingRequest(requestData) {
const tx = this.db.transaction('pending-requests', 'readwrite')
const store = tx.objectStore('pending-requests')
store.add({
...requestData,
timestamp: Date.now(),
})
}
async getCachedData(key) {
const tx = this.db.transaction('cached-data', 'readonly')
const store = tx.objectStore('cached-data')
return new Promise((resolve) => {
const request = store.get(key)
request.onsuccess = () => resolve(request.result?.data)
})
}
async setCachedData(key, data, ttl = 3600000) {
const tx = this.db.transaction('cached-data', 'readwrite')
const store = tx.objectStore('cached-data')
store.put({ key, data, timestamp: Date.now(), ttl })
}
}
export const offlineDB = new OfflineDB('my-pwa-db', 1)PWA 更新策略
// 使用 Workbox 的更新策略
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
VitePWA({
registerType: 'prompt', // 手动提示更新
workbox: {
// 版本化缓存名
cacheId: 'myapp-pwa',
// 预缓存文件
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
// 跳过等待,立即激活
skipWaiting: true,
clientsClaim: true,
},
})
]
})
// 在 Vue 组件中提示用户更新
// composables/usePWAUpdate.ts
import { ref } from 'vue'
export function usePWAUpdate() {
const updateAvailable = ref(false)
const registration = ref(null)
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (registration.value?.waiting) {
updateAvailable.value = true
}
})
}
function updateApp() {
registration.value?.waiting?.postMessage({ type: 'SKIP_WAITING' })
window.location.reload()
}
return { updateAvailable, registration, updateApp }
}优点
缺点
总结
PWA 核心三大件:Web App Manifest(应用信息)、Service Worker(离线缓存)、HTTPS(安全前提)。缓存策略:静态资源用 Cache First、API 请求用 Network First。Vite 项目用 vite-plugin-pwa 自动生成 manifest 和 Service Worker。推送通知需用户授权。iOS 支持有限,主要面向 Android 和桌面 Chrome。
关键知识点
- 先判断主题更偏浏览器原理、框架机制、工程化还是性能优化。
- 前端问题很多看似是页面问题,实际源头在构建、缓存、状态流或接口协作。
- 真正成熟的前端方案一定同时考虑首屏、交互、可维护性和线上诊断。
- 前端主题最好同时看浏览器原理、框架机制和工程化约束。
项目落地视角
- 把组件边界、状态归属、网络层规范和错误处理先定下来。
- 上线前检查包体积、缓存命中、接口失败路径和关键交互降级策略。
- 如果主题和性能有关,最好用 DevTools、Lighthouse 或埋点验证。
- 对关键页面先建立状态流和数据流,再考虑组件拆分。
常见误区
- 只盯框架 API,不理解浏览器和运行时成本。
- 把状态、请求和 UI 更新混成一层,后期难维护。
- 线上问题出现时没有日志、埋点和性能基线可对照。
- 只追框架新特性,不分析实际渲染成本。
进阶路线
- 继续补齐 SSR、边缘渲染、设计系统和监控告警能力。
- 把主题和后端接口约定、CI/CD、缓存策略一起思考。
- 沉淀组件规范、页面模板和性能基线,减少团队差异。
- 继续补齐设计系统、SSR/边缘渲染、监控告警和组件库治理。
适用场景
- 当你准备把《PWA 渐进式应用》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合中后台应用、门户站点、组件库和实时交互页面。
- 当需求涉及状态流、路由、网络缓存、SSR/CSR 或性能治理时,这类主题很关键。
落地建议
- 先定义组件边界和状态归属,再落地 UI 细节。
- 对核心页面做首屏、体积、缓存和错误路径检查。
- 把安全、兼容性和可访问性纳入默认交付标准。
排错清单
- 先用浏览器 DevTools 看请求、性能面板和控制台错误。
- 检查依赖版本、构建配置、环境变量和静态资源路径。
- 如果是线上问题,优先确认缓存、CDN 和构建产物是否一致。
复盘问题
- 如果把《PWA 渐进式应用》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《PWA 渐进式应用》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《PWA 渐进式应用》最大的收益和代价分别是什么?
