渐进式网页应用
概述
使用服务工作线程、Web 应用清单、离线支持和可安装性构建渐进式网页应用,以在浏览器中提供类似应用的体验。
使用场景
- 类似应用的网页体验
- 需要离线功能
- 移动设备安装要求
- 推送通知
- 快速加载体验
实施示例
1. Web 应用清单
// public/manifest.json
{
"name": "我的超棒应用",
"short_name": "AwesomeApp",
"description": "一个渐进式网页应用",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#007bff",
"icons": [
{
"src": "/images/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/images/icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/images/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/images/screenshot-1.png",
"sizes": "540x720",
"type": "image/png",
"form_factor": "narrow"
},
{
"src": "/images/screenshot-2.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
],
"categories": ["productivity", "utilities"],
"shortcuts": [
{
"name": "快速笔记",
"short_name": "Note",
"description": "创建一个快速笔记",
"url": "/new-note",
"icons": [
{
"src": "/images/note-icon.png",
"sizes": "192x192"
}
]
}
]
}
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#007bff">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/images/icon-192.png">
<title>我的超棒应用</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
2. 服务工作线程实现
// public/service-worker.ts
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/css/main.css',
'/js/app.js',
'/images/icon-192.png',
'/offline.html'
];
// 安装事件
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
// 激活事件
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
})
);
self.clients.claim();
});
// 使用缓存优先策略处理静态资源的获取事件
self.addEventListener('fetch', (event: FetchEvent) => {
const { request } = event;
// 跳过非GET请求
if (request.method !== 'GET') {
return;
}
// 静态资源缓存优先
if (request.destination === 'image' || request.destination === 'font') {
event.respondWith(
caches.match(request).then(response => {
return response || fetch(request).then(res => {
if (res.ok) {
const clone = res.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(request, clone);
});
}
return res;
});
}).catch(() => {
return caches.match('/offline.html');
})
);
}
// API调用网络优先
if (request.url.includes('/api/')) {
event.respondWith(
fetch(request)
.then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(request, clone);
});
}
return response;
})
.catch(() => {
return caches.match(request);
})
);
}
// HTML缓存失效重验证
if (request.destination === 'document') {
event.respondWith(
caches.match(request).then(cachedResponse => {
const fetchPromise = fetch(request).then(response => {
if (response.ok) {
caches.open(CACHE_NAME).then(cache => {
cache.put(request, response.clone());
});
}
return response;
});
return cachedResponse || fetchPromise;
})
);
}
});
// 后台同步
self.addEventListener('sync', (event: any) => {
if (event.tag === 'sync-notes') {
event.waitUntil(syncNotes());
}
});
async function syncNotes() {
const db = await openDB('notes');
const unsynced = await db.getAll('keyval', IDBKeyRange.bound('pending_', 'pending_\uffff'));
for (const item of unsynced) {
try {
await fetch('/api/notes', {
method: 'POST',
body: JSON.stringify(item.value)
});
await db.delete('keyval', item.key);
} catch (error) {
console.error('同步失败:', error);
}
}
}
3. 安装提示和应用安装
// hooks/useInstallPrompt.ts
import { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export const useInstallPrompt = () => {
const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
const [isIOSInstalled, setIsIOSInstalled] = useState(false);
useEffect(() => {
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
setPromptEvent(e as BeforeInstallPromptEvent);
};
const handleAppInstalled = () => {
setIsInstalled(true);
setPromptEvent(null);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);
// 检查是否作为安装应用运行
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
}
// 检查iOS
const isIOSDevice = /iPad|iPhone|iPod/.test(navigator.userAgent);
const isIOSApp = navigator.standalone === true;
if (isIOSDevice && !isIOSApp) {
setIsIOSInstalled(false);
}
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, []);
const installApp = async () => {
if (promptEvent) {
await promptEvent.prompt();
const { outcome } = await promptEvent.userChoice;
if (outcome === 'accepted') {
setIsInstalled(true);
}
setPromptEvent(null);
}
};
return {
promptEvent,
canInstall: promptEvent !== null,
isInstalled,
isIOSInstalled,
installApp
};
};
// components/InstallPrompt.tsx
export const InstallPrompt: React.FC = () => {
const { canInstall, isInstalled, installApp } = useInstallPrompt();
if (isInstalled || !canInstall) return null;
return (
<div className="install-prompt">
<h2>安装应用</h2>
<p>安装我们的应用以快速访问和离线支持</p>
<button onClick={installApp}>安装</button>
</div>
);
};
4. 使用IndexedDB的离线支持
// db/notesDB.ts
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface Note {
id: string;
title: string;
content: string;
timestamp: number;
synced: boolean;
}
interface NotesDB extends DBSchema {
notes: {
key: string;
value: Note;
indexes: { 'by-timestamp': number; 'by-synced': boolean };
};
}
let db: IDBPDatabase<NotesDB>;
export async function initDB() {
db = await openDB<NotesDB>('notes-db', 1, {
upgrade(db) {
const store = db.createObjectStore('notes', { keyPath: 'id' });
store.createIndex('by-timestamp', 'timestamp');
store.createIndex('by-synced', 'synced');
}
});
return db;
}
export async function addNote(note: Omit<Note, 'timestamp'>) {
return db.add('notes', {
...note,
timestamp: Date.now(),
synced: false
});
}
export async function getNotes(): Promise<Note[]> {
return db.getAll('notes');
}
export async function getUnsyncedNotes(): Promise<Note[]> {
return db.getAllFromIndex('notes', 'by-synced', false);
}
export async function updateNote(id: string, updates: Partial<Note>) {
const note = await db.get('notes', id);
if (note) {
await db.put('notes', { ...note, ...updates });
}
}
export async function markAsSynced(id: string) {
await updateNote(id, { synced: true });
}
5. 推送通知
// services/pushNotification.ts
export async function subscribeToPushNotifications() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.log('推送通知不支持');
return;
}
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: process.env.REACT_APP_VAPID_PUBLIC_KEY
});
// 发送订阅到服务器
await fetch('/api/push-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
return subscription;
} catch (error) {
console.error('推送订阅失败:', error);
}
}
// service-worker.ts
self.addEventListener('push', (event: PushEvent) => {
const data = event.data?.json() ?? {};
const options: NotificationOptions = {
title: data.title || '新通知',
body: data.message || '',
icon: '/images/icon-192.png',
badge: '/images/badge-72.png',
tag: data.tag || 'notification'
};
event.waitUntil(
self.registration.showNotification(options.title, options)
);
});
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close();
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then(clients => {
if (clients.length > 0) {
return clients[0].focus();
}
return self.clients.openWindow('/');
})
);
});
最佳实践
- 实现服务工作线程以支持离线功能
- 创建全面的Web应用清单
- 使用适合内容类型的缓存策略
- 提供离线回退页面
- 在各种网络条件下进行测试
- 针对慢速3G网络进行优化
- 包括安装提示
- 使用IndexedDB进行本地存储
- 监控同步状态和连接性
- 优雅地处理更新通知