如何用Service Worker实现PWA离线缓存与更新
Service Worker 是 PWA 实现离线缓存的唯一底层能力:用「缓存版本号 + 预缓存安装 + activate 清理旧缓存 + skipWaiting/clientsClaim」四步,就能同时拿到离线可用和自动更新两个效果。
Service Worker 为什么能实现离线缓存?
结论:Service Worker 是一个跑在独立线程、独立于页面的脚本代理,它注册后能拦截本作用域内所有网络请求,把响应写进 Cache Storage,网络断开时直接从缓存返回。
它的生命周期是 install → installed → activating → activated → redundant,页面关闭后依然存活。浏览器在 2015 年 Chrome 40 起支持,Safari 从 iOS 11.3(2018 年 3 月)开始支持,现在主流浏览器已全部覆盖。
三个硬性前提必须满足:页面运行在 HTTPS 或 localhost(file:// 协议不生效);sw.js 文件必须放在能覆盖目标路径的目录下,因为它的 scope 默认就是自己所在目录;只能缓存 GET 请求,Cache API 不接受 POST。
Service Worker 怎么注册和安装?
结论:注册代码写在主页面脚本里,缓存清单写在 sw.js 的 install 事件里,cache.addAll() 是原子操作——清单里任何一个文件 404,整个缓存的写入都会失败。
注册(主页面):
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none' // 顶层 sw.js 不读 HTTP 缓存
});
}
预缓存(sw.js):
const CACHE = 'app-v1';
const ASSETS = ['/', '/index.html', '/offline.html', '/main.css', '/main.js'];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE)
.then((c) => c.addAll(ASSETS))
.then(() => self.skipWaiting()) // 不等旧页面关闭,直接进入 activating
);
});
skipWaiting() 让新 SW 立即从 waiting 状态跳到 activating,否则默认要等所有旧页面标签关闭才更新。
离线时请求该怎么走?三种缓存策略怎么选
结论:HTML 导航请求用 Network First(失败回退 /offline.html),带 hash 的静态资源用 Cache First,数据接口用 Stale-While-Revalidate。
self.addEventListener('fetch', (e) => {
const { request } = e;
if (request.method !== 'GET') return;
if (new URL(request.url).origin !== location.origin) return;
if (request.mode === 'navigate') {
e.respondWith(fetch(request).catch(() => caches.match('/offline.html')));
return;
}
e.respondWith(
caches.match(request).then((cached) => cached || fetch(request).then((res) => {
const copy = res.clone(); // 响应流只能读一次,必须先克隆
caches.open(CACHE).then((c) => c.put(request, copy));
return res;
}))
);
});
关键点:Cache First 只适合文件名带内容哈希的资源(如 main.a1b2c3.js),否则用户会一直拿到旧版本;HTML 必须走网络优先,否则代码更新后页面永远刷新不出来。
Service Worker 更新是怎么触发的?
结论:浏览器会在每次导航时拉取 sw.js,只要文件字节有任何差异(哪怕只改一个注释或版本号)就装成新的 worker;只改业务代码而不改 sw.js,SW 不会更新。
标准做法是每次发版改 CACHE = 'app-v2',并在 activate 里删掉所有非当前版本的缓存:
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) => Promise.all(
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
)).then(() => self.clients.claim()) // 立即接管未受控页面
);
});
注意 skipWaiting() 有个副作用:旧页面还在运行时,新 SW 已经开始按新规则返回资源,可能出现版本错配。稳妥做法是取消自动 skipWaiting,改为页面监听 controllerchange 后弹提示让用户点击刷新:
navigator.serviceWorker.addEventListener('controllerchange', () => location.reload());
想省掉手写缓存逻辑,可以直接用 Google 的 Workbox 7,workbox-precaching 会自动生成带哈希的预缓存清单并处理版本清理。
有哪些容易踩的坑?
结论:Cache Storage 不会自动过期,必须自己清理旧版本;它的容量按 origin 计算,Chrome 允许使用磁盘剩余空间的 60%,远超 localStorage 的 5MB 限制。
- 开发阶段调试要在 DevTools → Application → Service Workers 勾选 "Update on reload",否则改动看不到效果。
- 不要缓存
sw.js自身,也不要在服务器给它设长Cache-Control。 - 第三方 CDN 资源(跨域)需要对方返回
Access-Control-Allow-Origin,或用new Response('', {status: 200})做兜底,否则addAll会整体失败。 - 用户清空浏览器数据会一并删除 Cache Storage,离线能力不是永久保证。
总结:把 sw.js 拆成"预缓存清单 + fetch 策略 + activate 清理"三段,版本号随发版递增,导航走网络优先、静态资源走缓存优先,再用 controllerchange 控制刷新时机,离线可用和自动更新就能同时成立。
原文链接:https://www.gj0.com/thread-262.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。