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-Control、ETag)控制资源的有效期。它的规则相对简单——一个键值对映射,过期就失效。而 Cache 接口则是通过 JavaScript API 手动管理的持久化存储,存储的是完整的 Request/Response 对。这意味着你可以在运行时决定何时缓存、何时使用缓存、何时更新缓存,灵活性远超 HTTP 缓存。
更重要的是,HTTP 缓存在网络不稳定时会直接失效。当浏览器无法与服务器验证资源新鲜度时,它不会冒险使用过期资源。而 Service Worker 缓存则完全由开发者控制——你可以选择在离线时提供"足够好"的旧版本,而不是什么都不给。

图片来源: web.dev
这种分层设计的代价是复杂度。当你同时在 HTTP 层和 Service Worker 层配置了不同的过期策略时,两者可能产生冲突。比如,Service Worker 使用 Stale-While-Revalidate 策略尝试后台更新资源,但 HTTP 缓存返回的是旧版本——因为服务器返回的 max-age 还没过期。解决方案是在 revalidate 请求时添加 cache-busting 参数或使用不同的请求头。
五种核心缓存策略的本质区别
Workbox 将缓存策略归纳为五种模式,每种模式的背后都是对"新鲜度"和"可用性"这两个目标的权衡。
仅缓存(Cache Only)
最激进的策略。资源只从缓存中获取,永远不会访问网络。这要求所有资源在 Service Worker 安装时就被预缓存,否则请求会直接失败。

图片来源: developer.chrome.com
适用场景:版本化的静态资源。比如 app.a8f3d2.js,文件名中的哈希值保证了不同版本之间的互斥性——只要文件名不变,内容就不会变。一旦缓存,永远有效。
self.addEventListener('fetch', (event) => {
event.respondWith(caches.match(event.request));
});
缓存优先,网络兜底(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)
优先访问网络获取最新内容,失败时才回退到缓存。这保证了用户在线时永远看到最新版本,离线时至少能获得上次成功访问的快照。

图片来源: 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)
这是最复杂但也最实用的策略。无论缓存是否存在,都立即发起网络请求。如果缓存存在,先返回缓存;网络请求完成后,更新缓存,下次访问时用户就能看到最新内容。

图片来源: 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 控制。
让新版本生效的方法:
- 关闭所有使用旧版本的标签页,重新打开
- 在 DevTools 中点击 “skip waiting”
- 在代码中调用
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% |
这些数值听起来很大,但有几个重要的细节:
-
配额是所有存储的总和:Cache API、IndexedDB、localStorage 共享同一个配额。如果你的应用使用 IndexedDB 存储大量数据,Cache 可用空间会相应减少。
-
配额基于磁盘总大小,而非可用空间:一台 1TB 硬盘的设备,Chrome 会给每个源分配最多 600GB 的配额,即使磁盘只剩 10GB 可用空间。这是为了防止通过配额推断磁盘大小,造成隐私泄露。
-
浏览器会在存储压力下驱逐数据:当设备存储空间不足时,浏览器会按照 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.js、sw-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 不变,通过文件内容变化触发更新。浏览器会在以下情况检查更新:
- 用户导航到 Service Worker 作用域内的页面
- 收到 push 或 sync 等功能事件(且 24 小时内未检查)
- 调用
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
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 面板:
- Service Workers 面板:查看注册状态,手动触发更新,模拟 push 事件
- “Update on reload”:每次刷新都重新安装 Service Worker,开发时必备
- “Bypass for network”:绕过 Service Worker 直接访问网络
- 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 之前,先问自己:我的应用真的需要离线能力吗?如果需要,用户最关心的功能是什么?从核心功能开始,逐步扩展缓存范围,而不是试图一次性缓存所有东西。
参考资料
- Strategies for service worker caching - Chrome Developers
- The Offline Cookbook - web.dev
- The service worker lifecycle - web.dev
- Service worker caching and HTTP caching - web.dev
- Storage quotas and eviction criteria - MDN
- Offline and background operation - MDN
- Speed up service worker with navigation preloads - web.dev
- Expectations around service worker deployment - Chrome Developers
- PWA Architecture - web.dev
- Using Service Workers - MDN
- Fresher service workers, by default - Chrome Developers
- History of Progressive Web Apps - Medium