一、真实场景:一个离线就崩的H5活动页
2024年双11,我们团队负责一个H5秒杀活动页。上线后,用户反馈:在电梯、地铁等弱网环境下,页面白屏,商品列表加载失败,直接导致转化率下降40%。
排查发现:页面依赖大量静态资源(JS/CSS/图片)和API数据,没有做任何离线缓存。网络一断,所有请求都挂掉。
解决方案:引入PWA离线缓存。但问题来了——缓存策略怎么选?
二、问题:三种缓存策略,选哪个?
PWA离线缓存核心是Service Worker拦截请求,配合Cache API存储响应。常见策略有三种:
- Cache-First(缓存优先):先查缓存,有则返回,无则网络请求并缓存。
- Network-First(网络优先):先请求网络,成功则缓存并返回,失败则回退到缓存。
- Stale-While-Revalidate(过期时重新验证):立即返回缓存,同时发起网络请求更新缓存。
每种策略适用场景不同。选错策略,要么缓存失效,要么用户看到旧数据。
三、我的方案:按资源类型混合策略
经过压测和线上数据对比,我最终采用混合策略:
- 静态资源(JS/CSS/字体):Cache-First。这些文件版本号固定,更新频率低,缓存命中率95%以上。
- API数据(商品列表、详情):Network-First。需要实时性,但离线时提供缓存兜底。
- 图片资源:Stale-While-Revalidate。图片体积大,先展示缓存图,后台更新。
3.1 方案一:手写Service Worker(纯Cache API)
版本:Chrome 120+,Service Worker API 标准。
// sw.js - 手写缓存策略
const CACHE_NAME = 'pwa-cache-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/js/app.js',
'/css/style.css',
'/fonts/icon.woff2'
];
// 安装阶段:预缓存静态资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('预缓存静态资源');
return cache.addAll(STATIC_ASSETS);
})
);
});
// 激活阶段:清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((name) => {
if (name !== CACHE_NAME) {
console.log('删除旧缓存:', name);
return caches.delete(name);
}
})
);
})
);
});
// 拦截请求:混合策略
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// 静态资源:Cache-First
if (url.pathname.match(/\.(js|css|woff2?)$/)) {
event.respondWith(cacheFirst(event.request));
}
// API请求:Network-First
else if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(event.request));
}
// 图片:Stale-While-Revalidate
else if (url.pathname.match(/\.(png|jpg|jpeg|gif|svg|webp)$/)) {
event.respondWith(staleWhileRevalidate(event.request));
}
// 其他:Network-First
else {
event.respondWith(networkFirst(event.request));
}
});
// Cache-First 实现
async function cacheFirst(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
return new Response('离线', { status: 503 });
}
}
// Network-First 实现
async function networkFirst(request) {
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
return new Response('离线', { status: 503 });
}
}
// Stale-While-Revalidate 实现
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
const fetchPromise = fetch(request).then((networkResponse) => {
if (networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
}).catch(() => cachedResponse);
return cachedResponse || fetchPromise;
}
3.2 方案二:使用Workbox(Google官方库)
版本:Workbox 7.0.0,通过CDN引入。
// sw.js - 使用Workbox
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
if (workbox) {
console.log('Workbox 加载成功');
// 设置缓存名称
workbox.core.setCacheNameDetails({
prefix: 'pwa-app',
suffix: 'v1',
precache: 'precache',
runtime: 'runtime'
});
// 预缓存静态资源
workbox.precaching.precacheAndRoute([
{ url: '/', revision: '1' },
{ url: '/index.html', revision: '1' },
{ url: '/js/app.js', revision: '1' },
{ url: '/css/style.css', revision: '1' },
{ url: '/fonts/icon.woff2', revision: '1' }
]);
// 静态资源:Cache-First
workbox.routing.registerRoute(
/\.(?:js|css|woff2?)$/,
new workbox.strategies.CacheFirst({
cacheName: 'static-cache',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30天
})
]
})
);
// API请求:Network-First
workbox.routing.registerRoute(
/\/api\//,
new workbox.strategies.NetworkFirst({
cacheName: 'api-cache',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60 // 5分钟
}),
new workbox.backgroundSync.BackgroundSyncPlugin('api-queue', {
maxRetentionTime: 24 * 60 // 重试24小时
})
]
})
);
// 图片:Stale-While-Revalidate
workbox.routing.registerRoute(
/\.(?:png|jpg|jpeg|gif|svg|webp)$/,
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'image-cache',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 7 * 24 * 60 * 60 // 7天
})
]
})
);
// 离线回退
workbox.routing.setDefaultHandler(new workbox.strategies.NetworkOnly());
workbox.routing.registerRoute(
/\/api\//,
new workbox.strategies.NetworkFirst({
networkTimeoutSeconds: 3,
fallbackToCache: true
})
);
} else {
console.log('Workbox 加载失败');
}
3.3 方案对比
| 维度 | 手写Service Worker | Workbox |
|---|---|---|
| 代码量 | ~150行 | ~60行 |
| 学习成本 | 高(需理解Cache API) | 低(配置化) |
| 灵活性 | 完全可控 | 受限于插件 |
| 缓存过期 | 手动实现 | 内置ExpirationPlugin |
| 后台同步 | 手动实现 | 内置BackgroundSyncPlugin |
| 调试难度 | 高(需Chrome DevTools) | 低(Workbox日志) |
结论:小项目用手写,大项目用Workbox。我最终选Workbox,因为项目有20+API,后台同步需求多。
四、完整代码实现:注册Service Worker
在HTML中注册Service Worker,注意作用域和版本控制。
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PWA离线缓存示例</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div id="app"></div>
<script src="/js/app.js"></script>
<script>
// 注册Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', {
scope: '/' // 作用域,控制哪些页面受缓存影响
}).then((registration) => {
console.log('Service Worker 注册成功:', registration.scope);
// 监听更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 新版本已安装,提示用户刷新
showUpdateNotification();
}
});
});
}).catch((error) => {
console.error('Service Worker 注册失败:', error);
});
});
}
// 更新提示
function showUpdateNotification() {
const notification = document.createElement('div');
notification.className = 'update-notification';
notification.innerHTML = '新版本已可用,请刷新页面';
notification.addEventListener('click', () => {
window.location.reload();
});
document.body.appendChild(notification);
}
</script>
</body>
</html>
五、效果数据:离线加载速度提升300%
测试环境:Chrome 120,模拟网络节流(Offline模式),页面包含:
- 5个JS文件(总大小1.2MB)
- 3个CSS文件(总大小300KB)
- 10张图片(总大小2.5MB)
- 3个API请求(总大小150KB)
| 指标 | 无缓存 | Cache-First | Network-First | 混合策略 |
|---|---|---|---|---|
| 首次加载(在线) | 3.2s | 3.5s(+缓存写入) | 3.2s | 3.3s |
| 二次加载(在线) | 2.8s(浏览器缓存) | 0.5s | 2.8s | 0.5s |
| 离线加载 | 白屏 | 0.5s | 0.8s(回退缓存) | 0.5s |
| API数据离线 | 无数据 | 无数据 | 有缓存数据 | 有缓存数据 |
| 缓存命中率 | 0% | 95% | 70% | 90% |
| 内存占用 | 0MB | 4.2MB | 4.2MB | 4.2MB |
关键数据:离线加载时间从白屏降到0.5s,提升300%以上。API数据在离线时仍可展示,用户不再看到空白页面。
六、避坑指南:5个实战踩坑点
坑1:Service Worker作用域问题
现象:Service Worker注册在 /sw.js,但只拦截 / 路径下的请求,/admin/ 下的页面不受控制。
原因:Service Worker默认作用域是sw.js所在目录。要控制子目录,需在register时指定scope: '/'。
解决:
navigator.serviceWorker.register('/sw.js', { scope: '/' });
坑2:缓存版本号管理
现象:更新静态资源后,用户仍看到旧版本,因为缓存未失效。
原因:Cache-First策略下,缓存不会自动更新。
解决:使用版本号或文件哈希。Workbox的precacheAndRoute会自动处理。
// 手动版本号
const CACHE_NAME = 'pwa-cache-v2';
// 或使用文件哈希
workbox.precaching.precacheAndRoute([
{ url: '/js/app.js', revision: 'a1b2c3d4' }
]);
坑3:API请求超时导致缓存失效
现象:弱网环境下,Network-First策略的API请求超时,直接返回离线页面,没有使用缓存。
原因:fetch默认超时时间很长,但网络差时请求挂起。
解决:设置networkTimeoutSeconds,超时后回退到缓存。
new workbox.strategies.NetworkFirst({
networkTimeoutSeconds: 3,
fallbackToCache: true
});
坑4:跨域请求缓存问题
现象:CDN上的图片或字体无法缓存,因为跨域请求不带credentials。
原因:fetch默认不发送cookie,跨域资源需设置mode: 'cors'。
解决:在Workbox中配置跨域策略。
workbox.routing.registerRoute(
/^https:\/\/cdn\.example\.com\/.*/,
new workbox.strategies.CacheFirst({
cacheName: 'cdn-cache',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200]
})
]
})
);
坑5:后台同步数据丢失
现象:用户离线提交表单,后台同步时数据丢失。
原因:BackgroundSyncPlugin默认重试次数有限,且不持久化数据。
解决:使用IndexedDB持久化待同步数据,并增加重试次数。
// 使用IndexedDB存储待同步数据
const db = await idb.openDB('sync-queue', 1, {
upgrade(db) {
db.createObjectStore('requests', { keyPath: 'id' });
}
});
// 在Service Worker中处理同步
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-form') {
event.waitUntil(syncFormData());
}
});
async function syncFormData() {
const tx = db.transaction('requests', 'readwrite');
const store = tx.objectStore('requests');
const allRequests = await store.getAll();
for (const request of allRequests) {
try {
await fetch(request.url, request.options);
await store.delete(request.id);
} catch (error) {
console.error('同步失败:', error);
}
}
}
七、总结
PWA离线缓存不是银弹,但按资源类型混合策略能解决90%的离线问题。记住:静态资源用Cache-First,API用Network-First,图片用Stale-While-Revalidate。用Workbox减少手写代码,但要注意版本号、超时、跨域和后台同步的坑。
最后,上线前一定要用Chrome DevTools的Application面板测试离线场景,模拟不同网络条件。别等到用户反馈才去修。