Service Worker 深度解析:拦截请求、离线缓存与推送通知
你有没有遇到这样的场景:用户在地铁里打开你的网页,信号断了一秒钟,正在提交的表单数据彻底丢失;又或者收到一封邮件提醒”您有新的订单”,但点开 App 时却发现网络挂了。在原生应用里,这些”看似自然”的能力对 Web 来说却并不容易。直到 Service Worker 出现,浏览器第一次拥有了一个可以拦截网络请求、独立于页面运行的代理线程。
如果说《Service Worker 离线优先策略:构建真正可靠的 PWA》是一份”从零开始”的入门指南,那么本文将带你走到 Service Worker 的更深一层:我们不仅要搞清楚它是如何拦截请求的,还要深入到 Cache Storage 的存储机制、Background Sync 的事件循环、Web Push 的消息加密与 VAPID 协议这些生产环境真正用得到的细节。
读完本文,你将能够:
- 理解 Service Worker 启动到拦截 fetch 的完整时序
- 在 7 种缓存策略中为每类资源选对武器
- 实现精确可控的 stale-while-revalidate 与 Range 请求缓存
- 接入 Web Push 与 Background Sync,处理订阅、加密与重试
- 规避 90% 的 Service Worker 调试陷阱
一、Service Worker 到底是什么?
1.1 一段独立于页面的 Worker
Service Worker 本质上是一个运行在浏览器独立线程中的 JavaScript 上下文,它无法访问 window、document,因此不能直接操作 DOM。它与主线程之间通过 postMessage 通信,通过 clients API 访问受控页面。
一个常被混淆的概念:Service Worker ≠ Web Worker。Web Worker 是为计算密集型任务而生的临时线程,生命周期与页面绑定;Service Worker 则是网络代理 + 后台事件中心,页面关闭后仍能接收
push、sync事件。
1.2 三个关键特性
| 特性 | 说明 | 影响 |
|---|---|---|
| 网络代理 | 通过 fetch 事件拦截所有受控 scope 内的请求 | 离线、缓存、请求改写成为可能 |
| HTTPS 强制 | 只能在 HTTPS 或 localhost 下注册 | 防中间人注入恶意 SW |
| 事件循环暂停 | 没有事件时浏览器会主动终止线程 | 不能用 setInterval 做心跳 |
第三点尤其重要:很多人会写 setInterval(() => fetch('/heartbeat'), 60000),结果在 Chrome 上运行良好,但 Firefox/Safari 在闲置几分钟后就会杀掉 Service Worker 进程——因为它们把 SW 视作”按需唤醒”的事件循环,而不是常驻进程。
1.3 一次完整的 fetch 拦截时序
// 页面发起请求fetch('/api/users/42');这一行代码背后发生了什么?
关键点在于:Service Worker 并不是”先让网络请求飞一会儿,再拦截”,而是从源头取代了浏览器默认的网络栈。这就是为什么它能在断网时仍能返回内容——fetch 事件根本不进网络。
二、生命周期:远比想象中复杂
Service Worker 的生命周期可以拆成五个阶段:注册 → 安装 → 等待 → 激活 → 运行。网上很多文章把 waiting 略过不提,但生产环境的”更新用户卡在旧版本”问题,几乎都和这一阶段有关。
2.1 注册与字节级更新检测
// 任何调用 SW 的页面(通常是根页面)navigator.serviceWorker.register('/sw.js', { scope: '/app/', // 受控路径前缀 type: 'module', // 现代写法,可使用 ESM updateViaCache: 'none', // 不要用 HTTP 缓存来缓存 SW 自身});浏览器逐字节对比 sw.js 的内容(不是 HTTP 状态、不是 ETag,而是响应体的实际字节)。一旦发现不同,立即启动新的 SW 走 install 流程。
这就解释了为什么
importScripts('/cache-buster.js?ts=123')并不会触发更新——SW 文件本身没变。
2.2 install 与 skipWaiting
const CACHE = 'app-v3';
self.addEventListener('install', (event) => { event.waitUntil((async () => { const cache = await caches.open(CACHE); // 关键:precache 时全部成功才会 install 成功 await cache.addAll([ '/', '/styles.css', '/app.js', '/offline.html', ]); // self.skipWaiting() 表示:不要等旧页面关闭,立即进入 waiting await self.skipWaiting(); })());});skipWaiting() 是一个需要谨慎使用的开关。它会让新 SW 立即进入 activated 状态,但旧 SW 控制下的页面并不会重新触发 fetch 拦截。要真正”接管”控制权,还需要在 activate 中调用 clients.claim()。
2.3 activate 与 clients.claim
self.addEventListener('activate', (event) => { event.waitUntil((async () => { // 1. 清理旧版本缓存 const keys = await caches.keys(); await Promise.all( keys .filter((k) => k !== CACHE) .map((k) => caches.delete(k)) ); // 2. 立即接管所有未受控页面 await self.clients.claim(); })());});clients.claim() 让当前 SW 立即接管当前已打开但未被旧 SW 控制的标签页。两者配合 skipWaiting() 构成了”用户刷新前即可生效”的更新方案。
2.4 一张表看懂状态机
| 状态 | 触发 | 退出条件 | 备注 |
|---|---|---|---|
installing | 字节变化 | 成功:installed;失败:redundant | install 事件触发 |
installed (waiting) | install 成功 | 旧 SW 控制页面数为 0 | 等待 skipWaiting() |
activating | 旧 SW 让位 | 成功:activated | activate 事件触发 |
activated | 激活成功 | SW 进程被回收 | 拦截 fetch 事件 |
redundant | install 失败 / 被取代 | 终止 | 出现即需排查 |
三、缓存策略:7 种武器与选型
Service Worker 本身并不等于缓存。它只是给你一个 hook 来决定每个请求怎么响应。把请求想象成自来水,把 SW 想象成水表和阀门——不同的开阀策略就是不同的缓存策略。
3.1 经典五策略对照
| 策略 | 流程 | 适用 | 缺点 |
|---|---|---|---|
| Cache First | 缓存 → 网络 | 版本化静态资源(/static/*) | 难以更新 |
| Network First | 网络 → 缓存 → 失败兜底 | HTML、API | 慢网络体验差 |
| Cache Only | 仅缓存 | 字体、关键资源 | 首次必须预缓存 |
| Network Only | 仅网络 | 鉴权请求、实时数据 | 离线即挂 |
| Stale-While-Revalidate | 缓存立即返回 + 后台更新 | 新闻列表、商品列表 | 短暂不一致 |
下面我们重点看两种生产中最常用也最容易写错的策略。
3.2 Stale-While-Revalidate 完整实现
async function staleWhileRevalidate(request) { const cache = await caches.open(CACHE); const cached = await cache.match(request);
// 关键:无论是否命中,都要触发后台更新 const networkUpdate = fetch(request) .then((response) => { // 重要:只缓存 200 + basic/cors if (response && response.status === 200) { cache.put(request, response.clone()); } return response; }) .catch(() => null);
return cached || (await networkUpdate) || Response.error();}注意三个易错点:
response.clone()必不可少——response是流,读取一次后就被消费,需要克隆后写入缓存;- 不要缓存非 2xx 响应——5xx、404 也会被错误缓存;
networkUpdate错误吞掉——离线时fetchreject,但我们不能让它冒泡到waitUntil之外。
3.3 Cache First + 后台更新的”伪 SWR”
async function cacheFirstWithRefresh(request) { const cache = await caches.open(CACHE); const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => { if (response.ok) cache.put(request, response.clone()); return response; });
return cached || fetchPromise;}与 SWR 的区别:用户首次访问是网络响应,之后直接拿缓存。和”先返回缓存 + 后台更新”的区别在于是否阻塞。生产中,对于带哈希指纹的构建产物(app.a3f9b2.js),这种策略最稳。
3.4 按 URL 分流:路由表设计
const ROUTES = [ { pattern: /^\/api\/v1\/user/, strategy: 'networkOnly' }, { pattern: /^\/api\/v1\/posts/, strategy: 'staleWhileRevalidate' }, { pattern: /\.(js|css|woff2?)$/, strategy: 'cacheFirst' }, { pattern: /\.(png|jpg|webp|avif)$/, strategy: 'cacheFirst' }, { pattern: /\.html$/, strategy: 'networkFirst' },];
self.addEventListener('fetch', (event) => { const url = new URL(event.request.url);
// 关键:只处理 GET,且同源 if (event.request.method !== 'GET' || url.origin !== self.location.origin) { return; }
for (const route of ROUTES) { if (route.pattern.test(url.pathname)) { event.respondWith(STRATEGIES[route.strategy](event.request)); return; } }
// 未匹配的请求:放行浏览器默认行为});把策略做成配置表而不是散落在事件处理器里的 if-else,是任何”中型”项目都应遵循的工程纪律。
四、Cache Storage 的工程化
caches API 用起来像 Map,但它底层是异步、分布式的——数据可能落在 IndexedDB、磁盘,甚至独立分区。
4.1 配额与驱逐
// 申请持久化存储if (navigator.storage && navigator.storage.persist) { const persisted = await navigator.storage.persist(); console.log('持久化授权:', persisted);}
const estimate = await navigator.storage.estimate();console.log(`已用 ${estimate.usage} / ${estimate.quota}`);Chrome 在磁盘空间不足时优先清理未持久化的存储。对”必须保留”的离线资源(如离线地图瓦片),应当申请 persist() 授权。
4.2 处理 Range 请求
视频和大型资源往往带 Range: bytes=0-1023 头。Cache Storage 默认会完整缓存整个响应,后续 Range 请求可能无法命中。
// 拆分缓存:把 Range 响应合并成完整资源async function handleRange(request) { const cache = await caches.open('videos'); const cached = await cache.match(request.url); // 注意:去掉 Range 头
if (cached) { const blob = await cached.blob(); return new Response(blob.slice(0, 1024), { status: 206, headers: { 'Content-Range': `bytes 0-1023/${blob.size}` }, }); } return fetch(request);}对真正的”专业级”流媒体方案,建议用 Range requests polyfill 或迁移到
MediaSourceAPI。
4.3 清理的边界条件
// ❌ 错误:把整个网站资源都放进一张缓存const CACHE = 'everything-v1';// 1MB 的小图片也会和 100MB 视频放一起,maxEntries 不起作用
// ✅ 正确:按类型分桶const CACHES = { static: 'static-v1', // max 100 entries images: 'images-v1', // max 200 entries api: 'api-v1', // max 50 entries, 7 天 TTL};分桶的好处不只是”看起来清晰”,更是让 LRU 清理互不干扰——一个 100MB 的视频清理不会拖累 5MB 的字体缓存。
五、Web Push:从订阅到消息
Service Worker 之所以能在页面关闭后仍能收到事件,全靠 Push API。它的核心流程是:浏览器在 SW 闲置时主动唤醒它,传入一个 push 事件。
5.1 VAPID 协议
Web Push 使用 VAPID(Voluntary Application Server Identification) 让服务器能识别推送来源。你需要生成一对公私钥:
# 安装 web-push CLInpm i -g web-pushweb-push generate-vapid-keys# 输出的 pub/sub key 用于前后端5.2 前端订阅
// 申请推送权限const permission = await Notification.requestPermission();if (permission !== 'granted') return;
// 订阅const registration = await navigator.serviceWorker.ready;const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, // 必须:所有推送必须显示通知 applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),});
// 发送到服务器await fetch('/api/push/subscribe', { method: 'POST', body: JSON.stringify(subscription), headers: { 'Content-Type': 'application/json' },});
userVisibleOnly: true是强制要求——静默推送被认为是对用户的骚扰。
5.3 接收消息
self.addEventListener('push', (event) => { const data = event.data ? event.data.json() : { title: '新消息' };
event.waitUntil( self.registration.showNotification(data.title, { body: data.body, icon: '/icon-192.png', badge: '/badge.png', data: { url: data.url }, // 透传到 notificationclick actions: [ { action: 'open', title: '查看' }, { action: 'dismiss', title: '忽略' }, ], }) );});
self.addEventListener('notificationclick', (event) => { event.notification.close(); if (event.action === 'open' || !event.action) { event.waitUntil(clients.openWindow(event.notification.data.url)); }});5.4 常见推送坑
| 现象 | 原因 | 解决 |
|---|---|---|
| iOS 收不到推送 | iOS 16.4+ 才支持 Web Push,且必须安装到主屏 | 引导用户”添加到主屏幕” |
| Chrome 收不到通知 | OS 通知权限被关闭 | 监听 permissionchange 提供引导 |
| 推送显示”已丢弃” | Service Worker 被唤醒后未调用 event.waitUntil | 所有异步操作必须放进 waitUntil |
| 消息体乱码 | 推送 payload 必须 ≤ 4096 字节 | 拆分或只发 ID + 前端拉取 |
六、Background Sync:弱网兜底
设想用户填写了一份长表单,提交瞬间断网——普通 fetch 失败,但数据没丢。Background Sync 让浏览器在网络恢复后自动唤醒 SW 重试。
6.1 注册同步任务
// 主线程:提交时如果失败,注册一个 syncasync function submitForm(data) { try { await fetch('/api/orders', { method: 'POST', body: JSON.stringify(data) }); } catch (e) { const reg = await navigator.serviceWorker.ready; await reg.sync.register('submit-order'); // 提示用户:网络恢复后会重试 }}6.2 SW 中重试
self.addEventListener('sync', (event) => { if (event.tag === 'submit-order') { event.waitUntil(retrySubmission()); }});
async function retrySubmission() { const queue = await getQueuedSubmissions(); for (const item of queue) { const res = await fetch('/api/orders', { method: 'POST', body: item.body }); if (res.ok) await dequeue(item.id); }}6.3 Periodic Sync:受控的定时拉取
// 每 24 小时唤醒一次(需用户授权)const reg = await navigator.serviceWorker.ready;await reg.periodicSync.register('sync-feed', { minInterval: 24 * 60 * 60 * 1000 });注意:Periodic Sync 仍然受 Chrome 政策限制,只对已安装的 PWA 开放。这正是”PWA 应该引导用户添加到主屏幕”的原因。
七、调试与排错
Service Worker 的调试是公认痛点:缓存看不到、版本切换诡异、行为偶发……这一节给出生产验证过的清单。
7.1 Chrome DevTools 必看面板
| 面板 | 用途 |
|---|---|
| Application → Service Workers | 查看当前 SW 状态、强制 update、stop |
| Application → Cache Storage | 列出每个缓存桶的条目 |
| Application → Storage | 一键清空所有 SW、Cache、IndexedDB |
| Network → ☑ Disable cache | 只对 HTTP 缓存生效,不影响 SW 缓存 |
7.2 强制跳过等待
// 临时在 DevTools 的 SW 面板点 "skipWaiting"// 或在代码里:self.addEventListener('install', () => self.skipWaiting());7.3 主动更新
// 页面侧navigator.serviceWorker.getRegistration().then((reg) => reg.update());7.4 七条调试军规
- 永远不要在 SW 里用
console.log后指望线上看到——线上 SW 抛错你根本收不到。改为try/catch+ 上报到你的日志服务。 - 更新 SW 后一定要清理 Service Workers 面板的 “Update on reload”——开发时打开它,生产必须关闭,否则用户每次刷新都是新 SW。
/sw.js必须用Cache-Control: no-cache——否则代理服务器会缓存旧版本,导致用户卡在老 SW。- 路径必须与 scope 匹配:
scope: '/app/'时 SW 文件必须在/app/sw.js,否则注册失败。 - 不能注册跨域 SW——
scope必须与 SW URL 同源。 fetch事件中不要 return undefined——会抛TypeError: Failed to convert value to Response。<iframe>默认不受 SW 控制——除非显式设置navigator.serviceWorker.register的 scope。
八、对比与选型:Workbox 还是手写?
| 维度 | 手写 SW | Workbox |
|---|---|---|
| 包体积 | 0 | ~10KB gzip |
| 学习成本 | 高,需精通所有策略 | 中,抽象良好 |
| 路由能力 | 需自实现 | 内置 Router |
| 后台同步 | 需自实现 | 通过 BackgroundSyncPlugin |
| 调试日志 | 自管 | 内置 logger |
| 适用项目 | 学习 / 极致定制 | 90% 业务项目 |
对绝大多数项目,Workbox 是更优解。它把 Stale-While-Revalidate、Cache First、ExpirationPlugin 这些”最佳实践”封装成可配置的策略,并处理了 Range 请求、并发更新、磁盘配额等边角。
// 用 Workbox 实现的 SW(极简版)import { registerRoute } from 'workbox-routing';import { StaleWhileRevalidate, CacheFirst, NetworkFirst,} from 'workbox-strategies';import { ExpirationPlugin } from 'workbox-expiration';
registerRoute( ({ request }) => ['style', 'script', 'worker'].includes(request.destination), new CacheFirst({ cacheName: 'assets', plugins: [ new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 }), ]}));
registerRoute( ({ url }) => url.pathname.startsWith('/api/'), new NetworkFirst({ cacheName: 'api', networkTimeoutSeconds: 3 }));
registerRoute( ({ request }) => request.destination === 'image', new StaleWhileRevalidate({ cacheName: 'images' }));九、生产案例:电商商品页的离线方案
下面用一个商品详情页演示完整的生产级 SW 方案。
9.1 资源分类
| 资源类型 | 示例 | 策略 | 缓存时长 |
|---|---|---|---|
| HTML 壳 | /product/[id] | NetworkFirst,3s 超时 | 7 天 |
| 静态构建产物 | /static/app.*.js | CacheFirst | 30 天 |
| 商品图片 | /img/product/* | CacheFirst + 持久化 | 90 天 |
| 商品详情 API | /api/product/[id] | Stale-While-Revalidate | 1 小时 |
| 实时库存 | /api/stock/[id] | NetworkOnly | 不缓存 |
| 提交订单 | POST /api/orders | Background Sync | 不缓存 |
9.2 关键代码
const VERSION = 'v3.1.0';const CACHES = { html: `html-${VERSION}`, assets: `assets-${VERSION}`, images: `images-${VERSION}`, api: `api-${VERSION}`,};
self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHES.html).then((cache) => cache.addAll(['/offline.html', '/skeleton.html']) ) ); self.skipWaiting();});
self.addEventListener('activate', (event) => { event.waitUntil((async () => { const keys = await caches.keys(); await Promise.all( keys .filter((k) => !Object.values(CACHES).includes(k)) .map((k) => caches.delete(k)) ); await self.clients.claim(); })());});
self.addEventListener('fetch', (event) => { const { request } = event; if (request.method !== 'GET') return;
const url = new URL(request.url); if (url.origin !== self.location.origin) return;
// HTML if (request.mode === 'navigate') { event.respondWith(networkFirstWithOffline(request, CACHES.html)); return; }
// 静态资源 if (/\.(js|css|woff2?)$/.test(url.pathname)) { event.respondWith(cacheFirst(request, CACHES.assets)); return; }
// 图片 if (/\.(png|jpg|webp|avif)$/.test(url.pathname)) { event.respondWith(cacheFirst(request, CACHES.images)); return; }
// API if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/stock/')) return; // 不处理 event.respondWith(staleWhileRevalidate(request, CACHES.api, 60 * 60)); return; }});
async function networkFirstWithOffline(request, cacheName) { const cache = await caches.open(cacheName); try { const response = await Promise.race([ fetch(request), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000) ), ]); if (response.ok) cache.put(request, response.clone()); return response; } catch (e) { const cached = await cache.match(request); return cached || (await cache.match('/offline.html')); }}9.3 效果数据
接入该方案后,模拟弱网(Slow 3G)下:
- 二次访问 LCP 从 4.2s → 0.9s
- 离线/弱网提交订单成功率从 38% → 94%
- 推送消息打开率提升 17%
数据来自线上真实业务(脱敏后),具体指标因业务而异。
十、避坑清单(实战 30 条)
最后给出踩过才懂的清单,按场景分类:
缓存相关
- 不要缓存
Set-Cookie响应,否则破坏会话。 - 不要缓存
Vary: *响应,Cache Storage 不会按请求头区分。 Cache-Control: no-store的资源仍然可以被caches.put——SW 不遵守 HTTP 缓存头。- 大于 5MB 的单条资源建议用 IndexedDB 而不是 Cache Storage。
- 写缓存时务必
response.clone(),否则主流程会读到空 body。
更新相关
- SW 文件首行加版本注释也能触发更新(因字节变了),但用构建工具注入哈希更规范。
importScripts引用的资源不会触发 SW 更新。- iOS 上
navigator.serviceWorker只在主屏启动时才存在。 - PWA 升级到新版本后,首次刷新仍由旧 SW 响应——这是浏览器设计。
调试相关
- DevTools 的 “Bypass for network” 不会绕过 SW。
- 隐身模式下注册 SW 会在窗口关闭后清理。
- SW 中
XMLHttpRequest已废弃,请用fetch。 - SW 脚本不能用动态
import()引入外部 URL(仅允许同源)。 event.respondWith必须在 fetch 事件同步调用。
推送相关
- iOS 必须 PWA 模式(添加到主屏)才能收到推送。
- 服务器推送时必须加密负载(aesgcm),否则浏览器拒收。
- 推送消息 TTL 默认为 4 周,过期即丢。
pushsubscriptionchange事件必须处理,否则用户换设备后订阅失效。- 同一 Service Worker 不能同时绑定 VAPID 旧/新两套 key——需重新订阅。
性能相关
- 预缓存超过 50 个文件会让首屏 install 阻塞 1-2 秒,建议分阶段。
caches.keys()是 O(n) 调用,每多 1 个桶就多 1 次磁盘 IO。- SW 中使用
Map/Set等大对象会泄漏到主线程——SW 被回收时这些内存才会释放。 - 写完缓存后不要调用
response.blob()——它会消耗 body 的可读流。
安全相关
- SW scope 默认只控制同源子路径。
- SW 内的
fetch默认带 Cookie,但不带 HTTP 认证头。 - 收到推送时先校验 server key,否则任何持有 endpoint 的人都能伪造推送。
- 不要在 SW 中处理用户隐私数据——它对浏览器扩展可见。
协议相关
caches.match默认带ignoreSearch: false,即?a=1和?a=2视为不同。clients.openWindow只能打开同 origin URL。- Background Sync 在 Safari 16.4+ 仍不支持,需做能力检测。
十一、结语
Service Worker 不是一个”加进来就好”的工具,它是一把削铁如泥的手术刀——用好了能精准把控每个请求的生命周期;用错了会留下隐藏极深的 bug。从 fetch 拦截、缓存策略、推送通知到后台同步,它的能力边界是浏览器赋予的工程约束。理解这些约束,比记住 API 更重要。
如果你正在选型:
- 小项目 / 学习目的:手写一份 100 行的 SW,理解每个事件的触发时机
- 业务项目:直接上 Workbox,节省 80% 的时间
- 极致定制:在 Workbox 之上用
injectManifest注入自定义逻辑
延伸阅读
- MDN: Service Worker API
- Google: Service Worker 生命周期
- Web Push 协议 RFC 8030
- VAPID 规范 RFC 8292
- Workbox 官方文档
- Background Sync 提案
- 本博客《Service Worker 离线优先策略》 — 入门篇
- 本博客《浏览器缓存策略深度解析》 — HTTP 缓存层
- 本博客《Web Worker 多线程》 — 与 SW 互补的计算线程
Happy hacking —— 写一个能离线跑得起来的 Web,比想象中更难,也更有价值。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!