19、Service Worker 与离线缓存
聊到 Web 的离线能力,Service Worker 是绕不开的核心。说实话,我第一次接触它的时候,觉得这东西就是个“浏览器里的代理服务器”。你想想看,它独立于网页运行,能拦截网络请求,能管理缓存,还能在后台接收推送消息——这不就是个中间人嘛。
但真正用起来,坑也不少。我记得在某个项目中,因为没搞清楚它的生命周期,导致用户明明在线,却一直加载旧版本页面。嗯,今天我们就来彻底捋清楚 Service Worker 的方方面面。
Service Worker 的生命周期
Service Worker 的生命周期,说白了就三个阶段:注册 → 安装 → 激活。但每个阶段都有它的“潜规则”。
1. 注册(Registration)
注册是第一步。你需要在主线程里调用 navigator.serviceWorker.register()。这里有个细节:Service Worker 的作用域由它所在的路径决定。比如你把 sw.js 放在 /js/sw.js,那它默认只能控制 /js/ 路径下的页面。想控制整个站点?把 sw.js 放在根目录就行。
// 注册 Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('注册成功:', reg.scope))
.catch(err => console.error('注册失败:', err));
}
scope 参数,明确指定作用域。比如 register('/sw.js', { scope: '/' })。这样即使以后移动文件位置,也不会影响控制范围。
2. 安装(Install)
注册成功后,浏览器会触发 install 事件。这是预缓存资源的最佳时机。我一般在这里把关键资源(HTML、CSS、JS、图标)全部缓存起来。
self.addEventListener('install', event => {
event.waitUntil(
caches.open('v1').then(cache => {
return cache.addAll([
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png'
]);
})
);
});
注意 event.waitUntil()——它会告诉浏览器,安装过程还没结束,别急着激活。等所有资源缓存完毕再说。
3. 激活(Activate)
安装完成后,Service Worker 并不会立即接管页面。它要等到所有已打开的页面都关闭后,才会激活。这就是所谓的“等待阶段”。
激活时,通常用来清理旧缓存。我曾经踩过一个坑:旧缓存没清理干净,导致用户一直看到老版本页面。后来我养成了在 activate 事件里做缓存清理的习惯。
self.addEventListener('activate', event => {
const cacheWhitelist = ['v2']; // 只保留 v2 版本
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (!cacheWhitelist.includes(cacheName)) {
return caches.delete(cacheName);
}
})
);
})
);
});
activate 事件里忘记调用 event.waitUntil(),结果缓存还没删完,Service Worker 就已经激活了。新页面加载时,旧缓存还在,导致资源混乱。记住:异步操作一定要包在 waitUntil 里。
Cache API 与离线存储
Cache API 是 Service Worker 的“左膀右臂”。它提供了 caches.open()、cache.put()、cache.match() 等方法,让你可以精细控制缓存策略。
缓存策略的选择
我个人常用的策略有这几种:
| 策略 | 适用场景 | 说明 |
|---|---|---|
| Cache First | 静态资源(图片、CSS) | 先查缓存,有就直接返回;没有才去网络请求,并缓存结果 |
| Network First | API 数据、动态内容 | 先请求网络,成功就返回并更新缓存;失败则回退到缓存 |
| Stale While Revalidate | 新闻列表、用户头像 | 立即返回缓存,同时在后台发起网络请求更新缓存 |
| Network Only | 支付、登录等实时操作 | 只走网络,不缓存 |
举个例子,对于图片资源,我通常用 Cache First:
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
// 有缓存就直接返回
if (cachedResponse) return cachedResponse;
// 没有缓存就去网络请求
return fetch(event.request).then(response => {
// 把新资源缓存起来
return caches.open('images').then(cache => {
cache.put(event.request, response.clone());
return response;
});
});
})
);
});
response.clone() 是因为 Response 对象只能被消费一次。你要缓存一份,返回一份,就必须克隆。
推送通知(Push Notification)
推送通知是 Service Worker 的“杀手锏”之一。它让 Web 应用能像原生 App 一样,在后台接收消息并弹出通知。
流程其实不复杂:
- 订阅:前端调用
registration.pushManager.subscribe(),获取 PushSubscription 对象。 - 发送:服务器端使用 Web Push 协议,把消息推送给浏览器。
- 接收:Service Worker 的
push事件被触发,然后调用self.registration.showNotification()显示通知。
// 订阅推送
navigator.serviceWorker.ready.then(reg => {
reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array('你的公钥')
}).then(sub => {
console.log('订阅成功:', sub.toJSON());
// 把 sub 发送到服务器保存
});
});
// Service Worker 中接收推送
self.addEventListener('push', event => {
const data = event.data.json();
self.registration.showNotification(data.title, {
body: data.body,
icon: '/images/icon.png',
badge: '/images/badge.png'
});
});
后台同步(Background Sync)
后台同步,说白了就是“等网络好了再干活”。比如用户离线时提交了一个表单,你可以把请求存起来,等网络恢复后自动发送。
用法很简单:
// 注册一个同步事件
navigator.serviceWorker.ready.then(reg => {
reg.sync.register('sync-form-data');
});
// Service Worker 中处理同步
self.addEventListener('sync', event => {
if (event.tag === 'sync-form-data') {
event.waitUntil(sendFormData());
}
});
async function sendFormData() {
// 从 IndexedDB 读取待发送的数据
const data = await getPendingData();
// 发送到服务器
await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data)
});
}
这里有个细节:浏览器不会无限重试。如果网络一直不可用,同步事件可能会被延迟甚至丢弃。我建议你结合 IndexedDB 做持久化存储,确保数据不丢失。
知识体系总览
下面这张图,是我梳理的 Service Worker 核心知识结构。你可以把它当作一个“地图”,随时回来对照。
Service Worker 的强大之处,在于它让 Web 应用真正拥有了“离线优先”的能力。但能力越大,责任越大。你需要仔细设计缓存策略,处理好生命周期中的各种边界情况。嗯,我见过太多因为缓存没更新导致线上事故的例子了。
最后说一句:调试 Service Worker 的时候,记得打开 Chrome DevTools 的 Application 面板。那里能看到当前激活的 Worker、缓存的资源列表,还能手动触发更新。这个工具,救过我不少次。