PWA is natively supported in Chrome/Edge/Safari/iOS 16.4+. Installs to user home screen, works offline, accepts writes offline, replays on network return. Experience approaches native without App Store review walls.
Service Worker Caching Strategies
App Shell (HTML + first-paint assets) uses NetworkFirst with 3s timeout fallback to cache for instant weak-network render. Static images/fonts/scripts use Cache First with capacity caps. API GET list endpoints use Stale-While-Revalidate — return cache instantly while background silently fetches fresh copy — always-new data with zero perceived latency.
// ====== sw.ts Service Worker - Vite 推荐用 vite-plugin-pwa ======
import { PrecacheEntry } from 'workbox-precaching/_types';
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate, NetworkFirst } from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';
import { backgroundSyncQueue } from './lib/bg-sync';
declare const self: ServiceWorkerGlobalScope;
cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST as unknown as PrecacheEntry[]);
// 1. SPA 导航请求:NetworkFirst,失败 fallback 到 /index.html
registerRoute(
new NavigationRoute(new NetworkFirst({
networkTimeoutSeconds: 3,
cacheName: 'app-shell',
plugins: [new CacheableResponsePlugin({ statuses: [0, 200] })]
}))
);
// 2. 静态资源:Cache First 且限制数量
registerRoute(
({ request, sameOrigin }) => sameOrigin &&
['style', 'script', 'worker', 'font', 'image'].includes(request.destination),
new CacheFirst({
cacheName: 'static-assets-v1',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 })
]
})
);
// 3. API GET:Stale-While-Revalidate — 立返缓存,后台静默更新
registerRoute(
({ url, request }) => request.method === 'GET' && url.pathname.startsWith('/api/v1/'),
new StaleWhileRevalidate({
cacheName: 'api-read-v1',
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 500, maxAgeSeconds: 60 * 5 })
]
})
);
// 4. API POST/PUT/DELETE:失败时进入 Background Sync 队列
self.addEventListener('fetch', (event: FetchEvent) => {
const req = event.request;
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return;
if (!req.url.includes('/api/v1/')) return;
event.respondWith((async () => {
try {
const cloned = req.clone();
return await fetch(req);
} catch (err) {
await backgroundSyncQueue.enqueue(cloned);
await self.registration.sync.register('outbox-sync');
return new Response(JSON.stringify({
ok: false,
queued: true,
message: '已进入离线队列,网络恢复后自动发送'
}), { status: 202, headers: { 'Content-Type': 'application/json' } });
}
})());
});
// ====== lib/idb.ts — IndexedDB 封装(用 idb 库)======
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface AppDB extends DBSchema {
drafts: {
key: string;
value: { id: string; title: string; body: string; createdAt: number; pending: boolean };
indexes: { 'by-created': number; 'by-pending': boolean };
};
settings: { key: string; value: any };
}
const DB_NAME = 'pwa-app-db';
const DB_VERSION = 2;
export async function getDB(): Promise<IDBPDatabase<AppDB>> {
return openDB<AppDB>(DB_NAME, DB_VERSION, {
upgrade(db, oldVer, newVer, tx) {
if (!db.objectStoreNames.contains('drafts')) {
const s = db.createObjectStore('drafts', { keyPath: 'id' });
s.createIndex('by-created', 'createdAt');
s.createIndex('by-pending', 'pending');
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings');
}
}
});
}
export const DraftRepo = {
async list(pending?: boolean) {
const db = await getDB();
if (pending !== undefined) return db.getAllFromIndex('drafts', 'by-pending', pending);
return db.getAll('drafts');
},
async save(draft: AppDB['drafts']['value']) {
const db = await getDB();
await db.put('drafts', draft);
},
async delete(id: string) {
const db = await getDB();
await db.delete('drafts', id);
}
};
// ====== 应用入口 main.ts 注册 SW + 离线状态监听 ======
import { registerSW } from 'virtual:pwa-register';
const updateSW = registerSW({
immediate: true,
onOfflineReady() { console.log('[PWA] 缓存就绪,可离线使用'); },
onNeedRefresh() {
if (confirm('检测到新版本,是否立即刷新?')) updateSW(true);
},
});
window.addEventListener('online', async () => {
if ('SyncManager' in window) return;
// 浏览器不支持 SyncManager 时兜底:上线立即重放队列
const pending = await DraftRepo.list(true);
for (const d of pending) {
try {
await fetch('/api/v1/drafts', { method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(d) });
d.pending = false; await DraftRepo.save(d);
} catch {}
}
});
export function useApp() {
const isOnline = ref(navigator.onLine);
onMounted(() => {
window.addEventListener('online', () => (isOnline.value = true));
window.addEventListener('offline', () => (isOnline.value = false));
});
return { isOnline, DraftRepo };
}
IndexedDB Structured Store + Background Sync
localStorage is 5MB sync-blocking and unsuitable for structured data. IndexedDB stores drafts/chat/orders with queries, indexes, batches, multi-GB capacity. All writes (POST/PUT/DELETE) that fail go into Background Sync queue; sync event fires when user reconnects and SW replays them in sequence, guaranteeing zero loss for offline actions.
| Storage | Quota | Async | Structured/Indexes | Persistence | Use Case |
|---|---|---|---|---|---|
| localStorage | ~5MB | No | No | Partial manual purge | Tokens, prefs |
| sessionStorage | ~5MB | No | No | Per session only | Temp state |
| Cache API | Unbounded (policy) | Yes | URL key / Response | Per cache strategy | Static files + API GET responses |
| IndexedDB | Tens of GB | Yes | Full schema/indexes/transactions | Persistent | Structured business data |
| OPFS (File System) | Hundreds GB | Yes | File stream API | Persistent | Large files, media editing |
Best Practices
Use Vite + vite-plugin-pwa to skip manual Manifest/SW boilerplate. Three iOS gotchas: SW resets every 7 days, push requires add-to-home-screen, video autoplay needs mute. Always give app-layer UI feedback for writes: "Saved as draft. Will sync when you're back online."