上个月的活动页白屏事故
我们上线了一个暑期旅游活动页,投放抖音和微信广告。运营第二天就反馈:用户在地铁、地下商场扫码进入,页面白屏,几乎秒退。
当时的数据很难看:
- 弱网(4G 两格信号)白屏率 37%
- 平均首屏时间 12.6s
- 落地页跳出率 58%
我把 Network 面板翻了一遍,所有静态资源都走了 HTTP 缓存,但网络一断,浏览器直接报 net::ERR_INTERNET_DISCONNECTED,连缓存都没机会用。PWA 的 Service Worker 才是浏览器层面的"代理",能在网络不可用时从 Cache Storage 兜底。这篇文章记录我对离线缓存的完整设计和落地过程。
01 HTTP 缓存为什么不够用
HTTP 缓存(Cache-Control、ETag)有个本质缺陷:它只能在"在线"时生效。浏览器发起请求,网络不可达,请求直接失败,缓存不会被命中。
更麻烦的是:
- 用户下拉刷新或强制刷新时,HTTP 缓存会被绕过
- 离线时浏览器不会给你任何兜底页面
Service Worker 拦截 fetch 事件后,可以完全自己决定:走网络、走缓存、或者两者结合。这才是离线缓存的技术基础。
02 三种离线缓存策略对比
实现前先想清楚策略。离线缓存不是"全部缓存优先",不同资源要有不同取舍。我用三种策略做对比:
| 策略 | 命中后行为 | 离线可用 | 数据新鲜度 | 响应速度 | 适用场景 |
|---|---|---|---|---|---|
| Cache-First 缓存优先 |
直接返回缓存,后台不请求网络 | ✅ | 低 | 最快,约 0.1s | 带 hash 的静态 JS/CSS、图片、字体 |
| Network-First 网络优先 |
先请求网络,成功则返回并更新缓存;失败才回退缓存 | ✅ | 高 | 慢,受网络影响 | 页面 HTML、列表接口、实时数据 |
| Stale-While-Revalidate SWR |
先返回缓存,后台发起请求更新缓存供下次使用 | ✅ | 中 | 快,同时后台异步更新 | 低频变化的接口、埋点上报 |
决策建议:
- 资源文件名带 hash(如
main.2f3a9c.js):用 Cache-First,因为 hash 变了说明内容变了,缓存永不过期 index.html:用 Network-First,保证发版后拿到最新 HTML 引用的最新资源- 活动接口:用 Network-First + 3s 超时,弱网下最多等 3 秒,超时直接返回缓存
03 完整实现:手写 SW + Workbox 构建
3.1 前端注册 SW
注册代码放在页面入口文件。注意 updateViaCache: 'none',这是为了避免浏览器用 HTTP 缓存机制去缓存 sw.js,导致更新失效。
// register-sw.js
export function registerSW() {
if (!('serviceWorker' in navigator)) {
console.warn('当前浏览器不支持 Service Worker,PWA 功能不可用');
return;
}
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
});
console.log(`[SW] 注册成功,scope: ${registration.scope}`);
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
console.log('[SW] 新版本已激活');
}
});
});
} catch (err) {
console.error('[SW] 注册失败', err);
}
});
}
3.2 手写 SW:理解原理
生产环境我会推荐 Workbox,但先写一版手写的,把核心原理讲清楚。逻辑只有三个事件:install(预缓存)、activate(清理旧缓存)、fetch(路由策略)。
// sw.js – 手写简化版,仅用于理解原理
const CACHE_VERSION = '2024-07-01';
const CACHE_PREFIX = 'summer-campaign';
const CACHE_NAME = `${CACHE_PREFIX}-${CACHE_VERSION}`;
// 预缓存列表:构建时由脚本生成
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll([
'/',
'/index.html',
'/assets/main.2f3a9c.js',
'/assets/style.7d1b4c.css',
'/assets/hero-image.webp',
]))
.then(() => self.skipWaiting())
);
});
// 清理旧版本缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
.then(() => self.clients.claim())
);
});
// 请求路由:不同资源走不同策略
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
// HTML:网络优先
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request));
return;
}
// 带 hash 的静态资源:缓存优先
const url = new URL(request.url);
if (/\/assets\//.test(url.pathname)) {
event.respondWith(cacheFirst(request));
return;
}
// 其余 GET 请求:SWR
event.respondWith(staleWhileRevalidate(request));
});
async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME);
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (err) {
const cached = await cache.match(request);
if (cached) return cached;
return caches.match('/offline.html');
}
}
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 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;
}
这里有几个关键细节:
skipWaiting():新 SW 安装完成后立即跳过"等待"状态,否则必须关闭所有页面才能激活clients.claim():让当前页面立即被 SW 控制,否则第一次访问时页面不受控,需要刷新一次response.clone():Response 是流,只能消费一次。放入缓存和返回给页面需要两份- install 阶段
addAll一旦失败,整个 SW 会被浏览器丢弃,不会激活
3.3 生产版本:Workbox 6.10.2 配置
手写版能跑,但生产环境要考虑缓存过期、异常兜底、跨域处理。这些 Workbox 都帮你处理了。我们用的版本是 Workbox 6.10.2,用 generateSW 模式,配置如下:
// workbox-config.js
// https://developer.chrome.com/docs/workbox/reference/workbox-build/
module.exports = {
globDirectory: 'dist/',
globPatterns: [
'**/*.{html,js,css,png,svg,webp,woff2}',
],
globIgnores: ['**/*.map'],
maximumFileSizeToCacheInBytes: 2 * 1024 * 1024,
swDest: 'dist/sw.js',
runtimeCaching: [
{
// 图片缓存策略:Cache-First + 数量/时间上限
urlPattern: /\.(?:png|jpg|jpeg|svg|webp|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'campaign-images',
expiration: {
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30天
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
// 接口缓存策略:Network-First,3秒超时回退缓存
urlPattern: /\/api\/v1\/summer-campaign\//,
handler: 'NetworkFirst',
options: {
cacheName: 'campaign-api',
networkTimeoutSeconds: 3,
expiration: {
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60, // 1天
},
},
},
],
};
statuses: [0, 200] 里的 0 是给跨域不透明响应(opaque response)用的。如果 CDN 资源没开 CORS,Workbox 也能缓存,但要小心它会占用额外内存。后面避坑里细说。
3.4 构建脚本 + 版本号注入
Workbox 的 generateSW 会自动生成 sw.js,但它不会自动处理"SW 内容变化"这个更新机制。浏览器靠字节级对比判断 SW 是否更新。如果每次构建生成的 sw.js 内容不变,就不会触发 install,缓存的旧资源永远不更新。所以要把版本号写进 sw.js。
我们用 Vite 5.2.0 构建,增加一个版本号生成脚本:
#!/bin/bash
# generate-sw-version.sh
# 把 Git 短哈希 + 构建时间写入 SW 版本号,确保每次发版生成新 SW
VERSION=$(git rev-parse --short HEAD)-$(date +%Y%m%d%H%M%S)
cat > src/sw-version.js <<EOF
export const CACHE_VERSION = '${VERSION}';
EOF
echo "✅ SW 版本号已写入: ${VERSION}"
然后在 package.json 里串起来:
{
"scripts": {
"build": "bash generate-sw-version.sh && vite build",
"build:pwa": "bash generate-sw-version.sh && vite build && workbox generateSW workbox-config.js"
}
}
需要先安装 workbox-cli:
npm install --save-dev workbox-cli@6.10.2
3.5 nginx 上 sw.js 的响应头
Service Worker 是一个特殊的文件。浏览器检查 sw.js 更新时,会遵循 HTTP 缓存规则,但标准建议 max-age 不得超过 24 小时。为了每次发版都能及时拿到新 SW,必须关掉 sw.js 的 HTTP 缓存:
# nginx.conf
location /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Service-Worker-Allowed /;
}
04 效果数据
测试环境必须写清楚,不然数据没有参考意义:
- Chrome 119.0.6045.105,MacBook Pro M1 / 16GB
- Lighthouse 10.1.0
- DevTools CPU 4x slowdown,Fast 3G(1.6 Mbps / 750ms RTT)
- 页面:summer-campaign 活动页,27 个静态资源,总大小 3.8MB
4.1 加载耗时对比
| 场景 | 优化前(无 SW) | 优化后(SW + Cache-First) | 提升 |
|---|---|---|---|
| 首次加载(网络) | 白屏 6.2s / 可交互 12.8s | 白屏 6.0s / 可交互 12.5s | 首次基本无差异,预缓存资源不阻塞首屏 |
| 二次加载(有 HTTP 缓存且在线) | 白屏 2.4s / 可交互 5.1s | 白屏 480ms / 可交互 1.3s | 约 4 倍 |
| 完全离线加载 | 白屏,直接报错 | 白屏 400ms / 可交互 1.5s | 从不可用到可用 |
| 单张图片重复加载(图片 1.8MB) | 1.8s | 80ms | 22 倍 |
| Lighthouse PWA 得分 | 0 | 100 | - |
4.2 缓存命中率统计
离线加载时,DevTools Network 面板显示 27 个请求全部 served from ServiceWorker,0 个失败。二次访问在线时:
- 11 个 JS/CSS:全部 from ServiceWorker,命中率 100%
- 12 个图片:8 个 from ServiceWorker,4 个 from memory cache
- 2 个 XHR:NetworkFirst 命中缓存,后台重新请求
4.3 Cache Storage 占用
| 缓存名称 | 内容 | 占用大小 |
|---|---|---|
| 预缓存 | HTML / JS / CSS | 2.3MB |
| campaign-images | 图片 | 最多 60 张,约 12MB |
| campaign-api | 接口 JSON | 最多 50 条,约 200KB |
Chrome 会为每个域名动态分配配额,我们实测最大约 30MB。缓存控制策略(maxEntries)必须设置,否则超过配额会被浏览器整个清空。
05 避坑指南(5 个真实坑)
坑 1:sw.js 被 HTTP 缓存,导致永远不更新
我们第一次发布后,改了缓存策略,但用户侧 24 小时内一直执行老逻辑。原因是 nginx 给 sw.js 加了 Cache-Control: max-age=86400,浏览器在 24 小时内如果发现本地有缓存就直接跳过网络检查。解决:sw.js 必须返回 no-cache,同时注册时加 updateViaCache: 'none'。
坑 2:CACHE_NAME 不变,install 不触发
浏览器用"逐字节对比"判断 SW 是否更新。如果代码改了但 sw.js 内容没变(比如版本号是写死常量),新版本永远推不上去。我们踩过:改了 fetch 策略,但用户一直跑旧缓存策略,排查了半天发现 CACHE_VERSION 字符串没改。解决:把版本号生成脚本放进构建流程,每次发版都改 sw.js 内容。
坑 3:缓存了 index.html,发版后白屏
最初我们把 HTML 也做了 Cache-First,结果新版本发布后,index.html 还是旧的,里面引用的 JS 文件名是 main.2f3a9c.js,但构建产物已经变成 main.8b4de0.js,请求 404,白屏。解决:HTML 必须用 Network-First。我们现在对导航请求强制走网络,网络失败才回退缓存。
坑 4:跨域 opaque response 撑爆缓存
CDN 上的资源如果不带 Access-Control-Allow-Origin,浏览器会返回 opaque response,status 是 0,体积很大且无法读取内容。如果直接缓存,Cache Storage 占用会快速上升。我们在活动页缓存了 20 个大图,结果配额爆了,整个缓存被浏览器清掉。解决:要么让 CDN 开启 CORS,要么用 cacheableResponse: { statuses: [0, 200] } 显式声明缓存 opaque response,并严格控制 maxEntries。
坑 5:开发环境 HTTPS 限制
localhost 下 SW 能跑,但用内网 IP(192.168.x.x)访问时,注册直接失败。Service Worker 只能在安全上下文使用(HTTPS 或 localhost)。我们一个小伙伴用手机通过内网 IP 调试,折腾了一下午。解决:开发调试用 vite --host + localhost 访问,内网联调需自签 HTTPS 证书,上线必须 HTTPS。
06 什么时候不需要 PWA 离线缓存
如果只是单次活动页、PV 不过万、用户全是强网环境,不需要 PWA。SW 的生命周期管理是有复杂度的:版本更新、缓存清理、跨域资源处理,每个环节都可能出问题。团队没有足够精力时,直接用 Workbox 一键生成,别手写。
但如果你的业务用户在弱网场景占比高(地铁、地下商业、偏远离线地区),PWA 离线缓存是投入产出比最高的优化手段之一。一个 SW 文件 + 一份配置,首屏速度提升 4 倍,离线从不可用到可用,值得投入。
结语
PWA 离线缓存的核心不是 Service Worker API,而是版本管理和缓存策略的取舍。先想清楚哪些资源追求最新、哪些资源追求最快,再动手写代码。希望这套方案和这些坑能帮你少走弯路。