给 H5 轻应用加离线能力、做资源缓存,本来是为了让二次打开更快、弱网下也能用,但很多团队第一次接入 Service Worker 都会遇到同一个反直觉问题:代码明明发布了,用户看到的却还是旧页面,甚至要手动清缓存、多次刷新才生效。问题几乎都不在缓存本身,而在 Service Worker 的生命周期和版本激活顺序没处理对。这篇把 install、waiting、activate 三个阶段讲透,给出可直接抄走的缓存策略模板和版本更新流程,文末附策略对照表与排查清单。
Service Worker(以下简称 SW)有自己独立的生命周期,它和页面是分离的:
"更新不出来"最常见的原因就是新 SW 一直停在 waiting:用户的标签页长期开着,旧 SW 不释放,新 SW 永远等不到激活。理解这条链路,后面的方案才有意义。
install 里把这一版依赖的静态资源预缓存下来,命名缓存时带上版本号。要用到 cache.addAll,它是原子的——只要列表里有一个资源请求失败,整个 Promise 就 reject,install 失败,避免缓存出一个残缺版本。
const CACHE_VERSION = 'v20260914';
const PRECACHE_URLS = [
'/',
'/index.html',
'/assets/app.css',
'/assets/app.js',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_VERSION)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});这里先放上 skipWaiting,让新 SW 安装完不等旧标签页关闭、直接进入激活。但要注意:skipWaiting 只是让它尽快激活,新 SW 激活的瞬间当前页面已经加载的资源并不会自动替换,所以还需要配合客户端接管,后面会讲。
每次版本号变化都会产生一份新缓存,如果不清理旧缓存,缓存会越积越多。activate 里白名单制地删除所有不属于当前版本的缓存:
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_VERSION)
.map((key) => caches.delete(key))
)
).then(() => self.clients.claim())
);
});clients.claim 的作用是让激活后的新 SW 立刻接管当前打开的所有页面,而不是等下次刷新。skipWaiting 加 clients.claim 组合起来,才能让新版本"尽快上线";但页面已经渲染出来的 DOM 和已执行的旧脚本不会回滚,所以真正稳妥的做法是检测到新版本后提示用户刷新一次。
不是所有请求都该用同一种缓存方式,把策略按资源类型分开,是既快又不出错的关键。下面是一个组合策略示例:带版本哈希的静态资源用缓存优先,HTML 文档用网络优先保证拿到最新,接口请求用网络优先并兜底缓存。
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
// 带哈希的静态资源:缓存优先,长期不变
if (/\/assets\//.test(request.url)) {
event.respondWith(cacheFirst(request));
return;
}
// HTML 文档:网络优先,拿不到新的再用缓存(离线兜底)
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request));
return;
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
return cached || fetch(request).then((resp) => {
return caches.open(CACHE_VERSION).then((cache) => {
cache.put(request, resp.clone());
return resp;
});
});
}
async function networkFirst(request) {
const cache = await caches.open(CACHE_VERSION);
try {
const fresh = await fetch(request);
cache.put(request, fresh.clone());
return fresh;
} catch (e) {
const cached = await caches.match(request);
if (cached) return cached;
throw e;
}
}关键原则:HTML 入口文档绝不能用 cacheFirst,否则一旦入口被缓存死,后续所有版本更新都进不来,这是"永远更新不出来"的另一大成因。
skipWaiting 会让新 SW 在后台激活,但当前页仍是旧代码渲染的结果。标准做法是在页面里监听 controllerchange,并在发现有 waiting 的新版本时给出一个温和的刷新提示:
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) return;
refreshing = true;
window.location.reload();
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then((reg) => {
reg.addEventListener('updatefound', () => {
const next = reg.installing;
next.addEventListener('statechange', () => {
if (next.state === 'installed' && navigator.serviceWorker.controller) {
// 已有旧 SW 控制,且新版本安装完成,可提示刷新
console.log('发现新版本,可提示用户刷新');
}
});
});
});
}用一个 refreshing 标志位防止重复刷新。是否自动 reload 取决于业务:后台型、表单型页面不宜强制刷新(会丢掉用户正在填的内容),更适合弹一个"有新版本,点击刷新"的轻提示,由用户决定。
预缓存解决的是发版时确定的静态资源,而运行时缓存(用户访问过程中动态写入缓存的资源)如果只进不出,同样会埋下隐患:一是缓存无限膨胀占用存储,二是某些资源被长期缓存后内容过期。对这类资源,更合适的是 stale-while-revalidate 策略——先用缓存快速响应,同时在后台拉取新版本更新缓存,下一次访问就是新的:
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_VERSION);
const cached = await cache.match(request);
const network = fetch(request).then((resp) => {
cache.put(request, resp.clone());
return resp;
}).catch(() => cached);
return cached || network;
}同时给缓存设一个容量上限,写入时如果条目数或总大小超过阈值,就按"最久未访问"优先淘汰一批,避免缓存只增不减。对带哈希的静态资源可以放心长缓存,对没有哈希、内容会变的资源,则要带上过期判断,超过一定时长就不再直接用旧缓存,而是优先走网络。把"预缓存、运行时缓存、过期淘汰"三者分开管理,缓存才不会从提速手段变成更新阻碍。
资源类型 | 推荐策略 | 理由 |
|---|---|---|
带内容哈希的 JS/CSS/图片 | 缓存优先 cacheFirst | 文件名变即新资源,可长期缓存 |
HTML 入口文档 | 网络优先 networkFirst | 必须优先拿新版,缓存仅离线兜底 |
数据接口(GET) | 网络优先 + 缓存兜底 | 保证数据新鲜,弱网可回退 |
字体文件 | 缓存优先(StaleWhileRevalidate) | 基本不变,先用缓存再后台更新 |
POST/PUT 等写请求 | 不拦截 | SW 不适合缓存非 GET 请求 |
推荐的标准组合是:sw.js 自身禁用缓存、每次发版修改缓存版本号;install 用 addAll 预缓存并 skipWaiting;activate 白名单清理旧缓存并 clients.claim;fetch 按资源类型分流,静态资源缓存优先、HTML 与接口网络优先;页面侧监听 updatefound 与 controllerchange,用轻提示让用户在合适时机刷新。上线后用浏览器的 Application 面板模拟 update、offline 两类场景各走一遍,确认新版本能激活、断网能兜底。以上为通用前端工程实践,具体兼容性以目标浏览器为准。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。