2015年,设计师 Frances Berriman 和 Google Chrome 工程师 Alex Russell 提出了 Progressive Web App(PWA)概念——它让 Web 应用第一次拥有了真正的离线能力。十年过去了,无数开发者依然在同一个问题上栽跟头:明明按照教程实现了 Service Worker,断网后页面依然白屏,或者更糟糕——更新了代码,用户看到的却还是旧版本。

这不是开发者不够努力,而是 Service Worker 的设计哲学本身就在与传统 Web 开发思维对抗。它像一个顽固的中间人,在浏览器和网络之间建立了一层可编程的缓存代理。这层代理的力量强大到可以完全控制所有网络请求,但也正是这种力量,让每一个决策都可能成为日后的技术债务。

Cache 接口与 HTTP 缓存:两套完全不同的系统

很多开发者最困惑的问题之一是:我已经在服务器配置了 Cache-Control 头,为什么还需要 Service Worker 缓存?

答案是:这是两套完全独立、互不干扰的缓存系统。

HTTP 缓存由浏览器自动管理,通过响应头(如 Cache-ControlETag)控制资源的有效期。它的规则相对简单——一个键值对映射,过期就失效。而 Cache 接口则是通过 JavaScript API 手动管理的持久化存储,存储的是完整的 Request/Response 对。这意味着你可以在运行时决定何时缓存、何时使用缓存、何时更新缓存,灵活性远超 HTTP 缓存。

更重要的是,HTTP 缓存在网络不稳定时会直接失效。当浏览器无法与服务器验证资源新鲜度时,它不会冒险使用过期资源。而 Service Worker 缓存则完全由开发者控制——你可以选择在离线时提供"足够好"的旧版本,而不是什么都不给。

浏览器缓存流程:Service Worker 缓存 → HTTP 缓存 → 网络

图片来源: web.dev

这种分层设计的代价是复杂度。当你同时在 HTTP 层和 Service Worker 层配置了不同的过期策略时,两者可能产生冲突。比如,Service Worker 使用 Stale-While-Revalidate 策略尝试后台更新资源,但 HTTP 缓存返回的是旧版本——因为服务器返回的 max-age 还没过期。解决方案是在 revalidate 请求时添加 cache-busting 参数或使用不同的请求头。

五种核心缓存策略的本质区别

Workbox 将缓存策略归纳为五种模式,每种模式的背后都是对"新鲜度"和"可用性"这两个目标的权衡。

仅缓存(Cache Only)

最激进的策略。资源只从缓存中获取,永远不会访问网络。这要求所有资源在 Service Worker 安装时就被预缓存,否则请求会直接失败。

Cache Only 策略流程图

图片来源: developer.chrome.com

适用场景:版本化的静态资源。比如 app.a8f3d2.js,文件名中的哈希值保证了不同版本之间的互斥性——只要文件名不变,内容就不会变。一旦缓存,永远有效。

self.addEventListener('fetch', (event) => {
  event.respondWith(caches.match(event.request));
});

缓存优先,网络兜底(Cache First)

这是最常见的策略。先查缓存,命中则立即返回;未命中则请求网络,并将响应存入缓存。

Cache First 策略流程图

图片来源: developer.chrome.com

问题在于:一旦某个资源被缓存,它可能永远不会更新,直到你发布新版本的 Service Worker。对于那些 URL 不变但内容会变的资源(如 /api/news),这就是灾难。

适用场景:版本化的静态资源、字体文件、不易变的内容。

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request).then((networkResponse) => {
        return caches.open('dynamic').then((cache) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
      });
    })
  );
});

网络优先,缓存兜底(Network First)

优先访问网络获取最新内容,失败时才回退到缓存。这保证了用户在线时永远看到最新版本,离线时至少能获得上次成功访问的快照。

Network First 策略流程图

图片来源: developer.chrome.com

但这个策略有一个致命缺陷:在网络不稳定(不是完全断开)的情况下,用户需要等待网络请求超时才能看到缓存内容。在移动端,这个超时可能长达数十秒。用户可能会在等待中关闭页面,永远不知道其实缓存里有一份可用的内容。

适用场景:需要保持新鲜度的动态内容,如新闻列表、用户消息。

self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const responseClone = response.clone();
        caches.open('dynamic').then((cache) => {
          cache.put(event.request, responseClone);
        });
        return response;
      })
      .catch(() => caches.match(event.request))
  );
});

边缓存边更新(Stale While Revalidate)

这是最复杂但也最实用的策略。无论缓存是否存在,都立即发起网络请求。如果缓存存在,先返回缓存;网络请求完成后,更新缓存,下次访问时用户就能看到最新内容。

Stale While Revalidate 策略流程图

图片来源: developer.chrome.com

这个策略承认了一个事实:很多场景下,“稍旧但可用"比"最新但慢"更好。用户头像、文章列表、商品信息——这些内容即使展示的是几分钟前的版本,也不会造成严重问题。

适用场景:需要较好性能且内容更新频繁但不那么关键的数据。

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open('dynamic').then((cache) => {
      return cache.match(event.request).then((cachedResponse) => {
        const fetchPromise = fetch(event.request).then((networkResponse) => {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return cachedResponse || fetchPromise;
      });
    })
  );
});

仅网络(Network Only)

最简单的策略——完全不使用缓存,所有请求都走网络。这看起来与 Service Worker 的初衷背道而驰,但在某些场景下是必要的:分析埋点、支付请求、实时数据。这些请求要么不需要缓存,要么缓存会带来副作用。

self.addEventListener('fetch', (event) => {
  event.respondWith(fetch(event.request));
});

策略选择的本质

策略 优先目标 牺牲目标 适用资源类型
Cache Only 性能 新鲜度 版本化静态资源
Cache First 性能 + 离线可用 新鲜度 字体、图片
Network First 新鲜度 性能 动态内容
Stale While Revalidate 平衡 实时性 列表数据
Network Only 实时性 + 正确性 离线可用 支付、埋点

真正的应用不会只使用一种策略。一个典型的 PWA 会根据请求类型和 URL 模式组合使用多种策略:HTML 文档使用 Network First(保证内容新鲜),CSS/JS 使用 Cache First(版本化保证一致性),图片使用 Stale While Revalidate(快速展示 + 后台更新),API 请求使用 Network First 或 Network Only。

生命周期:隐藏在幕后的状态机

Service Worker 的生命周期是开发者最容易踩坑的地方。它的设计初衷是保证"同一时间只有一个版本在运行”,但这导致更新流程远比直觉复杂。

stateDiagram-v2
    [*] --> Installing: 注册/更新
    Installing --> Installed: 安装成功
    Installing --> Redundant: 安装失败
    Installed --> Activating: 无其他客户端
    Activating --> Activated: 激活成功
    Activating --> Redundant: 激活失败
    Activated --> Redundant: 被新版本替换

安装阶段(Installing)

当浏览器检测到 Service Worker 文件发生变化(字节级差异)时,会启动新的 Service Worker 进入安装阶段。这里最容易犯的错误是在 install 事件中缓存过多资源。

预缓存的本意是在用户离线前就把所有必要资源存好。但如果你在安装阶段同时发起几十个网络请求,会与页面关键资源竞争带宽。更糟糕的是,这些请求可能会消耗用户的流量配额。

正确做法是:只预缓存应用 Shell(HTML 骨架、核心 CSS/JS、离线页面),其他资源在运行时按需缓存。同时,应该在页面 load 事件触发后再注册 Service Worker,避免与首屏加载竞争。

// 错误:立即注册,可能与首屏资源竞争
navigator.serviceWorker.register('/sw.js');

// 正确:等页面加载完成后再注册
window.addEventListener('load', () => {
  navigator.serviceWorker.register('/sw.js');
});

等待阶段(Waiting)

新安装的 Service Worker 不会立即激活,它会进入"等待"状态,直到旧版本不再控制任何客户端。这是最容易让人困惑的地方:刷新页面并不会让新版本生效。

原因在于浏览器导航的实现机制:当用户刷新页面时,旧页面在新页面响应头返回之前并不会卸载。这意味着在整个刷新过程中,至少有一个标签页始终由旧 Service Worker 控制。

让新版本生效的方法:

  1. 关闭所有使用旧版本的标签页,重新打开
  2. 在 DevTools 中点击 “skip waiting”
  3. 在代码中调用 self.skipWaiting()

skipWaiting() 看起来是解决更新问题的终极方案,但它有风险:新版本可能正在使用与旧版本不兼容的缓存结构。如果新版本激活时,旧版本正在读写缓存,可能导致数据不一致。更安全的做法是提示用户"有新版本可用,点击刷新",让用户主动触发更新。

激活阶段(Activating)

这是清理旧缓存的时机。当新 Service Worker 激活时,可以安全地删除不再需要的旧缓存。

const CACHE_VERSION = 'v3';
const EXPECTED_CACHES = [`static-${CACHE_VERSION}`, `dynamic-${CACHE_VERSION}`];

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => !EXPECTED_CACHES.includes(name))
          .map((name) => caches.delete(name))
      );
    })
  );
});

clients.claim() 的陷阱

默认情况下,Service Worker 只会控制在其激活后打开的页面。对于已经打开的页面,即使 Service Worker 已激活,也需要刷新才能被控制。

clients.claim() 可以改变这个行为,让 Service Worker 立即接管所有同源客户端。但这同样有风险:页面可能已经加载了一部分资源,而 Service Worker 可能会以不同的方式处理后续请求,导致不一致的行为。

存储配额:被忽视的边界条件

Service Worker 缓存并非无限大,不同浏览器有不同的配额策略:

浏览器 普通模式 持久化模式
Chrome/Edge 磁盘总大小的 60% 磁盘总大小的 60%
Firefox min(磁盘 10%, 10GB) min(磁盘 50%, 8TB)
Safari 磁盘总大小的 60% 磁盘总大小的 60%

这些数值听起来很大,但有几个重要的细节:

  1. 配额是所有存储的总和:Cache API、IndexedDB、localStorage 共享同一个配额。如果你的应用使用 IndexedDB 存储大量数据,Cache 可用空间会相应减少。

  2. 配额基于磁盘总大小,而非可用空间:一台 1TB 硬盘的设备,Chrome 会给每个源分配最多 600GB 的配额,即使磁盘只剩 10GB 可用空间。这是为了防止通过配额推断磁盘大小,造成隐私泄露。

  3. 浏览器会在存储压力下驱逐数据:当设备存储空间不足时,浏览器会按照 Least Recently Used(LRU)策略清理最久未使用源的存储数据。除非你申请了持久化存储。

持久化存储

navigator.storage.persist().then((persisted) => {
  if (persisted) {
    console.log('数据已持久化,不会被自动清理');
  }
});

申请持久化存储后,数据只有在用户主动清理时才会被删除。但浏览器不一定批准这个请求:

  • Firefox 会弹窗询问用户
  • Chrome/Edge 会根据用户与站点的交互历史自动决定,不显示任何提示
  • Safari 也会自动决定

Safari 的特殊规则

Safari 有一个独特的策略:如果用户在 7 天内没有与网站交互,Safari 会主动清理该网站通过脚本创建的所有数据(包括 Service Worker 缓存),只保留服务器设置的 Cookie。这意味着对于不常访问的 PWA,离线能力可能会在一周后失效。

更新机制:版本控制的复杂性

Service Worker 的更新机制是最容易出问题的地方。核心原则是:不要更改 Service Worker 文件的 URL

很多开发者习惯给静态资源加版本号:sw-v1.jssw-v2.js。这在 Service Worker 场景下是反模式。原因如下:

假设你的 index.html 注册了 sw-v1.js,而 sw-v1.js 缓存了 index.html。当你发布新版本,修改 index.html 让它注册 sw-v2.js 时,用户访问的仍然是缓存的旧版 index.html,它只会注册 sw-v1.js。新版本永远不会被激活。

正确做法是保持 Service Worker 文件 URL 不变,通过文件内容变化触发更新。浏览器会在以下情况检查更新:

  1. 用户导航到 Service Worker 作用域内的页面
  2. 收到 push 或 sync 等功能事件(且 24 小时内未检查)
  3. 调用 registration.update()

从 Chrome 68 版本开始,浏览器默认忽略 Service Worker 文件本身的 HTTP 缓存头,每次都会向服务器请求最新版本。但这不包括 importScripts() 引入的文件,后者仍受 HTTP 缓存影响。

预缓存版本控制

Workbox 提供了一套预缓存版本控制机制。构建时,它会生成一个 manifest,包含每个文件的 URL 和修订号(通常是内容哈希):

self.__WB_MANIFEST = [
  { url: '/index.html', revision: 'abc123' },
  { url: '/styles.css', revision: 'def456' },
  { url: '/app.js', revision: 'ghi789' }
];

当 Service Worker 安装时,Workbox 会下载所有资源,验证其内容与修订号匹配,然后存入以当前版本命名的缓存。旧版本缓存在激活阶段被清理。

性能陷阱:启动延迟

Service Worker 运行在独立线程中。当它空闲一段时间后,会被停止。下次需要处理请求时,浏览器需要重新启动它。

启动时间因设备而异:桌面端通常 50ms 左右,移动端可能 250ms,低端设备甚至超过 500ms。如果你使用 Network First 策略处理导航请求,这个延迟会直接加到页面加载时间上。

Navigation Preload 是专门为解决这个问题设计的 API。它允许浏览器在启动 Service Worker 的同时并行发起网络请求:

// 在 activate 事件中启用
self.addEventListener('activate', (event) => {
  event.waitUntil(
    self.registration.navigationPreload.enable()
  );
});

// 在 fetch 事件中使用预加载响应
self.addEventListener('fetch', (event) => {
  event.respondWith(async function() {
    const cachedResponse = await caches.match(event.request);
    if (cachedResponse) return cachedResponse;
    
    // 使用预加载的响应
    const preloadResponse = await event.preloadResponse;
    if (preloadResponse) return preloadResponse;
    
    return fetch(event.request);
  }());
});

预加载请求会携带一个特殊的请求头:Service-Worker-Navigation-Preload: true。服务器可以据此返回不同的内容——比如只返回页面的内容部分,而不是完整的 HTML。这比 App Shell 模型更高效,因为网络请求在 Service Worker 启动前就已经开始了。

调试技巧

调试 Service Worker 最有效的工具是 Chrome DevTools 的 Application 面板:

  1. Service Workers 面板:查看注册状态,手动触发更新,模拟 push 事件
  2. “Update on reload”:每次刷新都重新安装 Service Worker,开发时必备
  3. “Bypass for network”:绕过 Service Worker 直接访问网络
  4. Cache Storage 面板:查看和手动清理缓存内容

注意:Service Worker 只能在 HTTPS 或 localhost 上运行。在开发时,确保你的测试环境符合要求。

后台同步:离线操作的完整闭环

Service Worker 不仅能在离线时提供缓存内容,还能在用户离线时暂存操作,待网络恢复后执行。这是通过 Background Sync API 实现的:

// 主线程:注册同步任务
navigator.serviceWorker.ready.then((registration) => {
  registration.sync.register('send-message');
});

// Service Worker:处理同步事件
self.addEventListener('sync', (event) => {
  if (event.tag === 'send-message') {
    event.waitUntil(sendMessage());
  }
});

用户点击"发送"后,即使立即关闭页面,消息也会在网络恢复后自动发送。这对移动端尤为重要——网络切换频繁是常态。

更高级的 Periodic Background Sync API 允许应用在后台定期同步数据,但它的使用有严格限制:用户必须将应用添加到主屏幕,且使用频率会影响同步频率。

常见失败模式

回顾最开始的那些问题,大部分失败都可以归结为几个模式:

白屏:通常是 Service Worker 安装失败(网络问题、代码错误),或缓存策略配置错误(HTML 应该 Network First 却用了 Cache Only)。

旧版本不更新:Service Worker 文件 URL 被更改,或 HTTP 缓存阻止了更新检查(旧版浏览器行为)。

缓存无限增长:没有在 activate 阶段清理旧缓存,或运行时缓存没有设置上限。

首次加载变慢:预缓存资源过多,在 install 阶段竞争带宽。

随机失败:依赖的资源未被正确缓存,或跨域请求未正确处理(opaque response 不能被缓存读取状态码)。

Service Worker 是一把双刃剑。用好了,你的应用可以获得接近原生的体验;用不好,它就是一个难以调试的中间人层。理解它的设计哲学——版本隔离、原子更新、开发者完全控制——是避免踩坑的前提。在引入 Service Worker 之前,先问自己:我的应用真的需要离线能力吗?如果需要,用户最关心的功能是什么?从核心功能开始,逐步扩展缓存范围,而不是试图一次性缓存所有东西。


参考资料

  1. Strategies for service worker caching - Chrome Developers
  2. The Offline Cookbook - web.dev
  3. The service worker lifecycle - web.dev
  4. Service worker caching and HTTP caching - web.dev
  5. Storage quotas and eviction criteria - MDN
  6. Offline and background operation - MDN
  7. Speed up service worker with navigation preloads - web.dev
  8. Expectations around service worker deployment - Chrome Developers
  9. PWA Architecture - web.dev
  10. Using Service Workers - MDN
  11. Fresher service workers, by default - Chrome Developers
  12. History of Progressive Web Apps - Medium