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 一样,在后台接收消息并弹出通知。

流程其实不复杂:

  1. 订阅:前端调用 registration.pushManager.subscribe(),获取 PushSubscription 对象。
  2. 发送:服务器端使用 Web Push 协议,把消息推送给浏览器。
  3. 接收: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'
  });
});
避坑指南:推送通知必须配合 VAPID 协议使用。你需要生成一对公私钥,公钥给前端,私钥留在服务器。我曾经直接用明文推送,结果被浏览器拦截了——现在 Chrome 要求所有推送都必须经过 VAPID 验证。

后台同步(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 做持久化存储,确保数据不丢失。

注意:后台同步在 Android 上的表现比 iOS 好得多。iOS 的 Safari 对 Service Worker 的支持一直比较保守,后台同步的可靠性不如 Chrome。如果你的用户群体以 iOS 为主,可能需要考虑降级方案。

知识体系总览

下面这张图,是我梳理的 Service Worker 核心知识结构。你可以把它当作一个“地图”,随时回来对照。

Service Worker 生命周期 注册 (Register) 安装 (Install) 激活 (Activate) 核心能力 Cache API 离线存储 推送通知 (Push) 后台同步 (Sync) Service Worker 知识体系总览

Service Worker 的强大之处,在于它让 Web 应用真正拥有了“离线优先”的能力。但能力越大,责任越大。你需要仔细设计缓存策略,处理好生命周期中的各种边界情况。嗯,我见过太多因为缓存没更新导致线上事故的例子了。

最后说一句:调试 Service Worker 的时候,记得打开 Chrome DevTools 的 Application 面板。那里能看到当前激活的 Worker、缓存的资源列表,还能手动触发更新。这个工具,救过我不少次。

公众号:蓝海资料掘金营,微信deep3321