把白屏按在地上摩擦:那个被电梯吃掉的页面我忍了两年
两年前公司上线了一个移动端 H5 订单查询页,用户在地铁、电梯里打开永远一片空白。PM 天天追,测试在群里发截图:“连 error 提示都没有,像死了一样”。最严重的一次,某大客户在电梯里点了下单,页面转菊花转了20秒然后白屏,直接跑到微博投诉。
我们用了三个月给整个页面套上 Service Worker 离线缓存,上线后离线加载时间从 2.8s 降到 0.3s,Lighthouse PWA 评分从 48 分怼到 96 分。今天把四套缓存方案的选型决策、完整实现、以及我翻过的七个坑全写出来。
一、真实问题:80% 的用户打开页面时网络不可靠
我们的埋点显示:移动端页面请求失败率在工作日早晚高峰达到 12.7%(地铁场景);首次加载成功但后续交互请求失败的占比 9.3%(电梯/隧道切换)。这些失败直接导致白屏或部分组件缺失。
传统的 HTTP 缓存(强缓存 & 协商缓存)只能管浏览器主动缓存,但页面首次加载或者资源更新时依然依赖网络。PWA 的 Service Worker 可以劫持所有请求,在离线时直接从本地 CacheStorage 返回。
但 Service Worker 不是无脑全缓存。选错策略反而会使页面更新滞后、缓存膨胀甚至崩溃。下面给出四种主流缓存策略的实测对比。
二、四套方案实测:数据说话
2.1 方案概览
| 策略 | 核心逻辑 | 适用场景 | 离线可用 | 实时性 |
|---|---|---|---|---|
| Cache First | 有缓存直接返回,没有则请求网络并缓存 | 静态资源(JS/CSS/图片) | ✔ | 低(缓存不过期则看不到新版本) |
| Network First | 先尝试网络,成功则返回并更新缓存;失败则回退缓存 | API 接口(需要实时数据,但可接受降级) | ✔(降级) | 高 |
| Stale-While-Revalidate | 立即返回缓存,同时发起网络请求更新缓存 | 新闻列表、用户头像等可延迟更新的内容 | ✔(立即展示旧数据) | 中 |
| Network Only | 只走网络,不缓存 | 支付、验证码等必须实时的请求 | ✘ | 最高 |
2.2 压测环境与数据
测试条件:
- Chrome 120(模拟移动端 3G 网络 + 离线)
- 页面:订单列表页(1 个 HTML,3 个 CSS,5 个 JS,10 张图片,2 个 API)
- Service Worker 版本:v1.0.0,缓存命名:
v1-static,v1-api - 数据收集:Performance API + Chrome DevTools Network 面板
| 策略 | 首次加载(3G) | 离线加载 | 缓存命中率(10次重复访问) | Lighthouse PWA 离线评分 | 总缓存体积(KB) |
|---|---|---|---|---|---|
| Cache First | 1.8s | 0.3s | 100%(全部命中) | 96 | 2380 |
| Network First | 2.1s(网络超时则回退) | 0.5s(回退缓存) | 约 90%(API 若返回新数据则覆盖缓存,但离线时永远命中) | 89 | 2380 |
| Stale-While-Revalidate | 首次 1.8s,后续立即展示旧数据+后台更新 | 0.3s | 首次 0%,后续 100% | 95 | 2390(含临时缓存差异) |
| Network Only | 2.8s(全部需网络) | ❌失败,白屏 | 0% | 18 | 0 |
结论:静态资源(JS/CSS/图片)使用 Cache First,API 接口根据实时性需求选用 Network First 或 Stale-While-Revalidate。我们最终混用三种策略,下文给出完整代码。
三、完整代码实现
3.1 注册 Service Worker(主线程)
文件:sw-register.js,在页面 <head> 内加载。
// sw-register.js (主线程)
if ('serviceWorker' in navigator) {
// 避免重复注册
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then(reg => {
console.log('SW 注册成功,scope:', reg.scope);
// 检测更新(每5分钟检查一次)
setInterval(() => {
reg.update();
}, 5 * 60 * 1000);
})
.catch(err => console.error('SW 注册失败:', err));
});
}
3.2 Service Worker 完整实现(sw.js)
包含 install、activate 和 fetch 事件。采用混合策略:
- Cache First:静态资源(url 包含 .js, .css, .png, .jpg, .svg, .woff2)
- Network First:API 请求(url 包含 /api/,且 method 为 GET)
- Stale-While-Revalidate:头像类图片(url 包含 /avatar/)
// sw.js
const CACHE_VERSION = 'v1'; // 版本号,更新时修改
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const API_CACHE = `${CACHE_VERSION}-api`;
const AVATAR_CACHE = `${CACHE_VERSION}-avatar`;
// 预缓存静态资源(安装时预加载关键资源)
const PRECACHE_URLS = [
'/',
'/static/css/main.a1b2c3.css',
'/static/js/main.d4e5f6.js',
'/static/fonts/icons.woff2'
];
self.addEventListener('install', event => {
console.log('[SW] 安装版本:', CACHE_VERSION);
event.waitUntil(
caches.open(STATIC_CACHE).then(cache => {
return cache.addAll(PRECACHE_URLS);
})
);
// 立即激活新 SW
self.skipWaiting();
});
self.addEventListener('activate', event => {
console.log('[SW] 激活');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cache => {
// 删除旧版本缓存
if (!cache.startsWith(CACHE_VERSION)) {
console.log('[SW] 清除旧缓存:', cache);
return caches.delete(cache);
}
})
);
})
);
// 控制所有打开页面
return self.clients.claim();
});
self.addEventListener('fetch', event => {
const { request } = event;
// 只处理 GET 请求
if (request.method !== 'GET') return;
const url = new URL(request.url);
// 忽略非自身域名的请求(跨域资源单独处理)
if (url.origin !== location.origin) {
// 简单跨域请求可以缓存,但需要判断 response.ok
event.respondWith(staleWhileRevalidateCrossOrigin(request));
return;
}
// 判断路径,选择策略
if (isStaticResource(request)) {
event.respondWith(cacheFirst(request));
} else if (isApiRequest(request)) {
event.respondWith(networkFirst(request));
} else if (isAvatarRequest(request)) {
event.respondWith(staleWhileRevalidate(request));
} else {
// 其他请求(如页面跳转的 HTML)使用 Network First 兜底
event.respondWith(networkFirst(request));
}
});
// ---------- 工具函数 ----------
function isStaticResource(request) {
const ext = new URL(request.url).pathname.split('.').pop();
return ['js', 'css', 'png', 'jpg', 'jpeg', 'svg', 'woff2', 'ttf', 'ico'].includes(ext);
}
function isApiRequest(request) {
return request.url.includes('/api/');
}
function isAvatarRequest(request) {
return request.url.includes('/avatar/');
}
// ---------- 缓存策略实现 ----------
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(STATIC_CACHE);
cache.put(request, response.clone());
}
return response;
} catch (err) {
// 网络失败又没有缓存,返回离线占位图或空响应
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(API_CACHE);
cache.put(request, response.clone());
}
return response;
} catch (err) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
return new Response(JSON.stringify({ error: 'Offline' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(AVATAR_CACHE);
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;
}
async function staleWhileRevalidateCrossOrigin(request) {
// 跨域资源简单处理:不缓存,只网络
try {
const response = await fetch(request);
if (response.ok) {
// 跨域资源需要判断响应头包含 'Access-Control-Allow-Origin'
// 如果不想缓存跨域,直接返回
return response;
}
return response;
} catch {
return new Response('', { status: 204 });
}
}
3.3 manifest.json 配置
属性必须包含 display: standalone、icons、start_url。
{
"name": "订单查询",
"short_name": "订单",
"description": "公司移动端订单查询系统",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1890ff",
"icons": [
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
3.4 在 HTML 中引入 manifest
<link rel="manifest" href="/manifest.json">
3.5 验证脚本:检查 SW 状态
开发环境中可以用此脚本快速确认 Service Worker 是否注册并激活。
// 在控制台执行
navigator.serviceWorker.getRegistration().then(reg => {
if (reg) {
console.log('SW active at script:', reg.active?.scriptURL);
console.log('SW state:', reg.active?.state);
} else {
console.warn('No SW registered');
}
});
四、效果数据:生产环境实测
上线后我们基于 3 万台真实设备采集了数据(Chromium 内核 + iOS Safari):
- 离线可用率:从 0% 提升至 98.2%(剩下 1.8% 是首次访问未缓存时离线场景)
- 第二次访问加载时间(3G):从 2.3s 下降到 0.8s(命中缓存)
- API 接口失败率:从 9.3% 下降到 0.4%(因为 Network First 方案中多数失败回退到缓存)
- 缓存体积:平均 2.4 MB,占用户设备存储的 0.1%(大部分设备剩余存储 > 1GB)
- Lighthouse PWA 分数:从 48 分提升至 96 分(唯一失分项是 iOS 上的 manifest 图标尺寸要求 1024px,暂时未补)
缓存命中率统计(使用 Chrome DevTools 的「Service Workers」面板中的「opened caches」功能,以及自定义埋点):静态资源 100% 命中(预缓存列表 + 首次访问后缓存),API 接口 87% 命中(因为首次打开某些 API 尚未请求),头像图片 92%。
五、避坑指南(实战踩过的 7 个坑)
以下坑点按严重程度排列。直接抄我的代码可能也会触发,建议逐一核对。
5.1 坑1:Service Worker 版本更新后,旧缓存没有清除
新 SW 激活后,旧缓存会一直保留,导致用户看到旧版本页面。我们在 activate 事件中根据缓存名前缀(CACHE_VERSION)清除不匹配的缓存,但坑在于:如果新 SW 的 CACHE_VERSION 没变,缓存名称相同,则不会清除旧资源(比如某个 JS 文件更新了但缓存名称相同)。
解决方案:每次发布代码时,必须修改 CACHE_VERSION(例如改为 v2、v3),并且确保预缓存列表 URL 变更时能触发新的 fetch。我们甚至用构建工具在打包时自动生成版本号。
5.2 坑2:iOS Safari 不支持 Service Worker(2019年前)
2020 年后 iOS Safari 12.2+ 已支持 SW,但部分老旧设备(如 iPhone 6 停在 iOS 12.1)不支持。我们的用户群体有 3% 仍用旧系统,如果直接注册 SW,这些用户会报错?不会,因为条件判断 if ('serviceWorker' in navigator) 会跳过。但问题在于:这些用户无法享受离线功能,而且我们的首页依赖 SW 预缓存加速(因为有预加载),导致他们在首次访问时性能更差。
解决方案:在注册时加一个 fallback:如果 SW 不支持,加载内联的 polyfill 或直接使用普通 HTTP 缓存。我们选择维持原样,因为 3% 用户影响可控。
5.3 坑3:跨域资源的缓存失败(如 CDN 上的图片)
当我们尝试缓存第三方的 CDN 图片时,cache.put() 会抛出异常,因为跨域响应默认没有 Access-Control-Allow-Origin,导致 response.type 为 opaque,而 opaque 响应无法被 Cache API 存储。
解决方案:不对跨域资源做缓存,或者要求 CDN 加上 CORS 头部。我们选择不对跨域资源缓存,但至少保证本域资源完全离线。如果必须缓存,请设置 mode: 'cors' 并让 CDN 返回 Access-Control-Allow-Origin: *。
5.4 坑4:预缓存列表过大导致安装失败
我们一开始把整个打包后的 dist 目录(> 200 个文件)全部放在 cache.addAll() 里,结果在弱网下 install 阶段经常超时(Chrome 默认超时 5 分钟?实际上 install 事件如果没有 waitUntil 完成,SW 会被视为安装失败)。
解决方案:只预缓存首屏关键资源(HTML、核心 CSS、核心 JS、应用图标),其余资源在首次请求时通过 cacheFirst 按需缓存。我们的 PRECACHE_URLS 只有 4 个文件,总大小约 120KB,安装耗时 < 2s。
5.5 坑5:self.skipWaiting() 和 clients.claim() 导致的页面刷新问题
如果不调用 skipWaiting(),新 SW 会在所有页面关闭后才会激活;如果不调用 clients.claim(),新 SW 不会立即控制当前已打开的页面。但是用户可能正在操作,突然被新 SW 接管导致页面重新加载或请求中断。
解决方案:我们选择 立即激活并接管(因为我们的页面可以接受短暂重刷新)。但更好的做法是提示用户「有新版本,请刷新」,然后调用 skipWaiting() 并触发刷新。我们在实践中发现大多数用户不会主动刷新,所以直接强制激活,虽然会闪一下,但能保证最新代码。
5.6 坑6:localStorage 与 CacheStorage 混用导致离线时数据不同步
我们的页面有一些配置参数存在 localStorage 里,但缓存策略只缓存了 API 响应。离线时 API 返回缓存数据,但 localStorage 的配置可能是旧的,导致渲染错乱。
解决方案:把关键配置也放进预缓存,或者使用 IndexedDB 统一存储离线数据,并在 SW 中同时维护一份配置缓存。我们最终将配置参数移到了 API 接口中返回,避免两端分离。
5.7 坑7:使用 cacheFirst 导致用户看到过时的静态资源(版本更新但缓存未失效)
我们更新了某个 CSS 文件,但缓存版本号没变,结果用户一直看到旧样式持续一周(缓存最大时间设为无限)。
解决方案:除了版本号策略,我们还在构建时给资源文件名加上 hash(webpack 的 [contenthash]),这样每次发布新版本时,URL 发生变化,旧缓存自然无法命中,触发网络请求并缓存新资源。配合版本号变更,双保险。
六、总结
PWA 离线缓存不是银弹,但如果你能管好版本号、预缓存资源、跨域策略和 iOS 兼容四个点,就能让你的移动端页面在没有网络时依然正常工作。我写的这套代码在 GitHub 上已经服役两年,支撑了日均 50 万 PV,建议直接复制并按照避坑指南调整。