你有没有遇到过这种场景:用户在电梯里点提交订单,页面转圈几秒钟后弹出一个“网络异常,请重试”,用户气得直接关掉了页面,订单也丢了。这种问题能不能从根上解决?能,靠的是两个东西的配合——一个是 Service Worker 的生命周期管理,一个是后台同步(Background Sync)。很多人把这两个当成独立知识点,其实它们的关系非常紧密:后台同步任务能跑起来,前提是Service Worker处于正确的生命周期状态;而生命周期里那些看似繁琐的状态切换,恰恰是为了让后台同步这类任务可靠、可控地执行。
这篇文章我想从一个实战者的角度,把Service Worker从注册到失效的整个过程讲透,再把后台同步的机制、实现方式和坑一次说完。适合正在做PWA改造、离线优先应用,或者被“断网提交丢数据”困扰的前端同学。无论你是刚接触Service Worker的新手,还是已经写过一段sw.js但被生命周期状态绕晕的人,这篇都能给你一份可以直接抄作业的参考。
1. Service Worker 生命周期六态详解:从注册到废弃的完整旅程
1.1 注册与解析:一切从 register 开始
Service Worker 的起点是 JavaScript 里调用navigator.serviceWorker.register('/sw.js')。这一步看似简单,实际上浏览器要完成脚本下载、解析、以及作用域绑定三件事。
作用域(scope)是一个特别容易被忽略的点。默认情况下,脚本放在哪个路径,作用域就是哪个路径。比如你把sw.js放在/static/目录下,那么它的默认作用域就是/static/,这意味着它只能拦截/static/路径下的请求。想让整个站点都被控制,你得把脚本放在根目录,或者在注册时显式传scope: '/'。
注册过程中最常见的失败场景有两个:一个是脚本 MIME 类型不对,服务器必须以text/javascript或application/javascript返回;另一个是 HTTPS 限制——Service Worker 只能在安全上下文(HTTPS 或 localhost)下工作。我见过不少团队在线上环境一切正常,到了本地联调时用http://192.168.x.x访问,注册直接静默失败,这就是协议问题。
解析完成后,浏览器会创建 Worker 实例,并准备进入安装阶段。这里要注意,register()返回的 Promise resolve 不代表 SW 已经激活,只代表脚本注册成功。很多人写代码在注册后立刻调用registration.active,拿到的往往是null,就是因为没搞清生命周期状态。
1.2 Installing 阶段:install 事件里的 waitUntil 是“倒计时闸门”
注册成功后,SW 进入 installing 状态,此时会触发install事件。在这个事件里,开发者最常见的操作是预缓存静态资源——把首屏要用的 HTML、CSS、JS 提前放进 Cache Storage。
但这里有一个关键设计:install事件本身是异步的,浏览器不会等你缓存完再继续。你必须调用event.waitUntil(),把 Promise 传进去,浏览器才会“等”。只有 Promise resolve,安装才算成功;如果 Promise reject,安装失败,这个 SW 直接报废。
// sw.js 片段 const CACHE_NAME = 'my-site-v1'; const PRECACHE_URLS = ['/', '/index.html', '/app.js', '/style.css']; self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)) ); });为什么要这么设计?因为 SW 是一个被浏览器托管的进程,它不像普通页面有明确的加载进度。waitUntil相当于给宿主环境一个明确信号:“我还没准备好,请继续等待”。反过来,如果不需要预缓存任何东西,你甚至可以不用调用waitUntil,install事件结束即安装完成。
一个需要记住的边界:安装失败后浏览器不会立刻重试,除非用户下一次访问时触发了更新检查。所以install里别放太重的逻辑,尽量只做缓存预热,把容易出错的东西放在运行时处理。
1.3 Installed 与 Waiting:新版本“排队”等待的逻辑
安装成功后,SW 进入 installed 状态。这个状态有两种走向:
- 如果当前没有任何活跃的 SW 控制页面(比如用户第一次访问你的站点),installed 之后会直接进入 activating。
- 如果已经有一个旧版本的 SW 正在控制页面,新版本会进入 waiting 状态,也就是“排队等待”。
Waiting 状态让很多人困惑:明明我改了代码,也重新部署了,为什么用户还是旧的?原因就在这个阶段。浏览器这么做是为了保证一致性——如果用户在多个标签页里开着你的站点,旧 SW 正在处理请求,你突然切到一个新版本,不同标签页可能瞬间处于不同版本的缓存策略下,这种割裂状态很容易出问题。让新版 SW 等一等,等所有页面都关闭或下次导航时,再完成切换,是浏览器的一种保护策略。
self.skipWaiting()就是用来跳过这个等待的。在 install 事件里调用它,可以让刚安装完的新 SW 立刻进入 activating。但这会引入刚才说的“多个标签页在不同版本下”的窗口期,所以我建议谨慎使用。如果你做的是纯静态站、页面刷新即切换,问题不大;但如果是重度 SPA,用户在旧标签页有未提交的表单,忽然被新 SW 接管,轻则缓存错乱,重则数据丢失。
这里想说一个容易误解的地方:waiting 阶段的 SW 虽然还没正式生效,但它已经“有效”了——它完成了安装、占用了注册项,只差临门一脚。它的有效生命周期是“待命但不工作”的状态,理解这一点,后面调试后台同步时会省很多力气。
1.4 Activating 与 activate 事件:接管控制权的过程
从 waiting 被激活的那一刻起,SW 进入 activating 状态,activate事件触发。这是清理工作最好的时机:删除旧版本缓存、迁移数据、以及通过self.clients.claim()让页面立即被当前 SW 接管。
默认情况下,一个页面第一次加载时,即使 SW 已经激活,也不能控制当前页面——必须刷新一次,SW 才能真正拦截该页面的请求。这个“刷新一次才生效”的体验很低级,所以很多团队会在 activate 事件里调用clients.claim()。
self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((keys) => { return Promise.all( keys .filter((key) => key !== CACHE_NAME) .map((key) => caches.delete(key)) ); }).then(() => self.clients.claim()) ); });这段代码里有两点值得展开。第一,删除旧缓存前,先caches.keys()列出所有缓存,然后只保留当前版本,其他全部删掉。这个模式是所有缓存版本管理的基础。第二,clients.claim()要在waitUntil内部调用,保证激活事件完成前,页面已经被接管。
有些人把清理逻辑放在install里做,这是不对的。install时旧 SW 可能还在控制页面,这时候删除旧缓存,旧 SW 读取缓存就会扑空;只有activate阶段,浏览器才允许你动旧资源。
1.5 Activated 与 Redundant:真正的“工作期”与生命周期终点
激活完成后,SW 进入 activated 状态,这是它唯一能正常工作的阶段。在这个阶段,它可以响应fetch事件拦截网络请求、响应push事件处理推送、响应sync事件处理后合同步。后台同步属于后者,所以它必须在这个阶段才能被触发——这也是生命周期与后台同步最直接的耦合点。
而 Redundant 状态是 SW 生命周期的终点。常见的情况是:新版 SW 激活后,旧版就被标记为 redundant;或者 SW 脚本下载失败、安装失败、被开发者通过 DevTools 注销、用户清除了站点数据。进入 redundant 后,Worker 不再运行,也不会有任何事件派发给它,该 SW 的有效生命周期正式结束。
这也揭示了一个重要事实:SW 不是注册一次就永远待命的常驻进程。它的存活取决于浏览器策略、用户行为和版本管理。后台同步任务依附在 SW 上,一旦 SW 被废弃,任务也随之消失。所以我们设计同步机制时,不能天真地以为“注册了就一定会执行”,而是要有重新注册、兜底处理的预案。
2. 生命周期实操要点:版本更新、缓存清理与切换避坑
2.1 SW 更新机制:不是改代码重启就能生效
很多新手以为,上了新版本代码,用户自动就会用新的 SW。实际上,浏览器检查更新依赖两个条件:一是用户访问作用域内的页面时触发更新检查,二是浏览器每 24 小时至少检查一次。
即使检查到脚本字节变化,新版本也不会立刻替代旧版本,而是走完整个生命周期流程:下载新脚本 → 解析 → installing → installed → waiting → activating → activated。如果旧 SW 还在控制页面,新版本会在 waiting 阶段卡住,直到你调用skipWaiting()或用户关闭所有相关标签页。
调试时最直观的操作是在 DevTools 的 Application 面板里勾选 “Update on reload”,这样每次刷新页面都会强制检查并更新 SW。但线上用户没有这个选项,你需要自己设计更新提示——比如监听registration.updatefound或controllerchange事件,检测到新版本准备好后,用 UI 引导用户刷新。
2.2 缓存更新的正确姿势:不是清掉旧的就行
缓存版本管理最常见的坑是:只创建新缓存,忘了删旧缓存,或者删得太早。一个可复用的套路是:
- 新版本部署时,给缓存名加版本号,比如
my-site-v2。 - install 事件里打开新版本缓存并预热资源。
- activate 事件里遍历所有缓存名,删除非当前版本。
- 如果页面正在使用旧缓存里的资源,删除操作不会影响已加载的资源;但下一次 fetch 时会按当前 SW 的策略走,这点要评估清楚。
还有一个容易踩的坑是cache.addAll()的原子性:如果列表里有一个文件请求失败,整个 addAll 会 reject,安装失败。保护性写法是逐个cache.add()并且 catch 单个失败,而不是一把梭 addAll。尤其当列表里有第三方域名的资源时,跨域请求即使响应成功,只要 CORS 不对,也会导致缓存失败。
2.3 页面与 SW 的“时间差”:何时用 ready,何时用 active
业务代码里经常需要拿 SW registration 对象去注册后台同步或推送。这里要注意:navigator.serviceWorker.register()返回的 Promise 在 SW 进入 installing 阶段就 resolve 了,而registration.sync.register()这类 API 需要 SW 处于 activated 状态才能用。稳妥的写法是用navigator.serviceWorker.ready来获取——这个 Promise 会等待 SW 激活后才 resolve。
async function registerBackgroundSync() { const registration = await navigator.serviceWorker.ready; await registration.sync.register('submit-order'); }如果你拿着register()的结果直接调sync.register(),在首次访问时大概率会遇到TypeError: Registration failed之类的错误,原因就是 SW 还没激活。这类问题在生命周期章节里几乎必考,但在实际代码里很多人要踩一次才能避开。
3. 后台同步:核心机制与生命周期联动
3.1 Background Sync 到底解决了什么问题
后台同步解决的问题一句话概括:用户在离线状态下发的请求,可以等网络恢复后自动补发。这比“提示用户重试”或“草稿箱存起来等用户再来”要优雅得多。
工作原理并不复杂。页面端调用registration.sync.register(tag)注册一个带有标签的同步任务;浏览器在认为网络可用时,唤醒 SW,触发sync事件。在事件回调里,你读取离线缓存的数据,重新发起网络请求。
tag是任务的唯一标识,同一作用域下同名 tag 只有一个待处理任务。如果在已有同名任务的情况下再次注册,浏览器不会重复排队。这个特性可以让业务代码放心地多次调用 register,不会造成任务堆积。
3.2 触发时机:网络恢复不等于立即执行
很多人问:我恢复了网络,sync 事件怎么还没触发?这里要澄清一个机制:浏览器不是“网络一恢复就立刻触发”,而是“在网络条件允许时,尽快但不保证立即触发”。
实际表现是,Chrome 通常会在网络恢复后的几十秒到几分钟内触发 sync。但如果你处于省电模式,或者页面一直在后台休眠,触发可能会更晚。某些安卓手机上,厂商后台清理策略激进的话,sync 事件甚至可能不触发或者被延迟到用户下一次主动打开站点。
所以后台同步是“尽力而为”的机制,不是“严格实时”的队列。你的业务逻辑必须能接受同步延迟。这一点在产品设计阶段就要跟需求方讲清楚,否则上线后发现“网络恢复后不是秒同步”,会被当成 bug。
3.3 与生命周期联动的几个重要节点
后台同步与生命周期的关系可以从三个角度看。
第一,注册时机。sync.register()必须在 SW activated 之后调用,原因前面已经说过,navigator.serviceWorker.ready是你最稳的保障。
第二,事件派发目标。sync 事件只派发给当前激活且有效的 SW。如果你的 SW 处于 waiting 状态,即便它脚本里写了sync监听器,也收不到事件。
第三,生命周期失效的影响。SW 被新版本替换、被注销、或站点数据被清理,都会导致后台同步任务丢失。特别是“设备清理站点数据”这个操作,在移动端很常见,用户一旦清理浏览器数据,等待同步的离线数据可能一并清除。所以重要的离线数据不能只存在 Cache Storage 或 SW 的生命周期内,关键数据要尽量落到 IndexedDB,并且每次页面打开时做一次“兜底清理”——检查是否还有未发送的数据,有就尝试补发。
3.4 后台同步、周期同步与推送的边界
刚接触时容易把Background Sync和Periodic Background Sync搞混。前者是“网络恢复后的补偿性同步”,只保证把没发出去的请求补上;后者是“定期执行的任务”,比如每隔几小时拉取一次天气数据。周期同步对站点参与度有要求,Chrome 对触发频率限制很多,而且也需要在生命周期激活后才能使用。
推送则是另一个维度的能力:服务端主动向用户发消息,跟客户端断网补发数据是两回事。实践中它们常常配合使用——离线提交的数据通过后台同步补发,而补发成功后如果需要更新 UI,可以用推送通知通知用户。
4. 从零到一:实现一个断网表单提交自动重发的完整方案
4.1 先看清整体流程
我们要实现的目标:用户提交订单时如果断网,先把订单数据存到本地,等网络恢复后自动补发,成功后提示用户。
整体链路是这样的:
- 页面提交表单 →
fetch()发送请求。 - 请求失败(网络错误或超时) → 把表单数据写入 IndexedDB。
- 注册后台同步任务(tag 如
submit-order)。 - SW 收到
sync事件后,从 IndexedDB 读取所有待发送数据,逐个重新请求。 - 请求成功则删除对应记录;失败则保留,等下一次 sync 再试。
- 页面再次打开时,检查 IndexedDB 是否有残留数据,有则尝试补发。
4.2 页面端:提交失败时写入队列并注册同步
先看页面端的核心代码。这里的重点在于:不能只在navigator.onLine为 false 时才走离线逻辑,因为移动设备的网络状态经常是“假在线”——Wi-Fi 连着但实际没网。所以判断提交是否失败的标准应该只看fetch()是否抛错。
// main.js async function submitOrder(formData) { try { const response = await fetch('/api/order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: formData.orderId, amount: formData.amount, items: formData.items }) }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } showToast('订单提交成功'); } catch (error) { // 写入 IndexedDB await saveToQueue(formData); // 注册后台同步 if ('serviceWorker' in navigator && 'SyncManager' in window) { const registration = await navigator.serviceWorker.ready; await registration.sync.register('submit-order'); showToast('网络异常,已为你保存订单,恢复网络后会自动提交'); } else { // 降级方案:本地保存,下次打开页面时提示 showToast('网络异常,订单已保存,请稍后重试'); } } }saveToQueue我用了一个简单的 IndexedDB 封装。IndexedDB 相比 localStorage 有几个决定性优势:容量大(通常至少几十 MB)、异步 API(不会阻塞主线程)、可以存储结构化数据。这里给出一个极简封装:
// queue.js function saveToQueue(data) { return new Promise((resolve, reject) => { const request = indexedDB.open('offline-queue', 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains('orders')) { db.createObjectStore('orders', { keyPath: 'id' }); } }; request.onsuccess = () => { const db = request.result; const tx = db.transaction('orders', 'readwrite'); tx.objectStore('orders').put(data); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }; request.onerror = () => reject(request.error); }); }注意onupgradeneeded只会在第一次打开、数据库版本升级时调用。如果你改了数据结构,比如字段从orderId变成id,要记得升级数据库版本号,并在该回调里处理旧数据迁移。
4.3 SW 端:处理 sync 事件并重发请求
接下来是 SW 的关键部分。sync事件回调里必须调用event.waitUntil(),把整个重发过程包进去。这样浏览器才知道任务还在处理中,不会强制关闭 SW。
// sw.js self.addEventListener('install', (event) => { event.waitUntil(caches.open('app-v1').then((cache) => { return cache.addAll(['/', '/index.html', '/app.js', '/queue.js']); })); }); self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((keys) => { return Promise.all( keys.filter((key) => key !== 'app-v1').map((key) => caches.delete(key)) ); }).then(() => self.clients.claim()) ); }); self.addEventListener('sync', (event) => { if (event.tag === 'submit-order') { event.waitUntil(flushPendingOrders()); } }); async function flushPendingOrders() { const pending = await readAllPending(); await Promise.all(pending.map(async (order) => { try { const response = await fetch('/api/order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(order) }); if (response.ok) { await removeFromQueue(order.id); } } catch (error) { // 单条失败不阻塞其他订单,这条留到下次 sync } })); }这里有个设计细节值得强调:Promise.all里逐条处理,每一条都单独 try/catch。如果其中一条因为网络波动失败,不应该让整个同步过程失败,否则其他本该成功的订单也会被连累。实际生产环境中,我会再加一个请求超时控制(比如 AbortController),避免某条请求一直挂起阻塞后续队列。
readAllPending和removeFromQueue是对 IndexedDB 的另一层封装,逻辑与saveToQueue类似,只是在readAllPending里遍历所有记录返回数组。这里不再重复贴代码,但强调一点:IndexedDB 的事务处理一定要等transaction.oncomplete再返回,否则容易读到半截数据。
4.4 调试方法:用 DevTools 模拟断网与手动触发
这套流程调试起来有个很方便的点:Chrome DevTools 可以模拟离线状态,也可以手动触发 sync 事件。
调试步骤我实测下来是这样:
- 打开 DevTools → Network 面板,勾选 Offline,模拟断网。
- 在页面上提交表单,确认数据被写入 IndexedDB、sync 任务注册成功。
- 不要急着取消 Offline,先在 Application → Service Workers 面板里查看当前 SW 状态,应该为 activated。
- 取消 Offline,等待几秒到几十秒,观察 Network 面板里是否出现
/api/order的 POST 请求。 - 如果想手动触发 sync,可以在 Application → Service Workers 面板中,找到对应 SW 后的 “Sync” 按钮(版本不同位置略有差别)。点一下,立刻触发
sync事件,不用干等网络恢复。
另外一个很实用的小技巧:把fetch请求直接指向一个本地 mock 接口,输出日志到 Console,方便确认每次同步的执行情况和参数。我在调试 queue 数据是否正确时,会在flushPendingOrders里加一句console.log('[sync] flushing:', order),这样每次事件触发都能直观看到队列内容。
5. 常见问题与排查技巧实录
5.1 sync 事件迟迟不触发
这是后台同步最常被诟病的问题。排查顺序建议从外到内:
- 先确认当前 SW 是 activated 状态,而不是 waiting。用 DevTools 看状态,如果一直 waiting,说明新版本在排队,旧版本注册的同步任务可能根本没炒起来。
- 再确认网络确实验证通过。DevTools 的 Offline 勾选虽然直观,但它模拟的是完全断网;如果网络环境有认证门户(比如连上了需要登录的 Wi-Fi),浏览器会认为没有可用网络。
- 再看设备是否开启省电模式。省电模式下浏览器可能推迟 sync,这是系统级限制,前端代码无法绕开。
- 最后查兼容性。Safari 对 Background Sync 的支持一直很保守,移动端 Safari 基本不触发 sync 事件,需要做降级处理。
我建议在项目里加一个“兜底重试”机制:页面每次加载时,检查 IndexedDB 里有没有残留的待发送数据,有就尝试补发。这样即使浏览器层面的 sync 事件被系统限制,用户只要再次打开页面,数据也能送出去。
5.2 重复提交与幂等设计
后台同步最大的隐患在于重复请求。场景:SW 发出 POST 请求,服务端处理成功并返回 200,但响应在回传途中丢失,SW 认为请求失败,数据留在队列里,等下次 sync 再次发送。于是服务端收到两笔相同的订单。
解决方案是幂等。做法很简单:每个订单生成一个唯一 ID(可以用 UUID),请求时带上;服务端在处理前检查这个 ID 是否已经处理过,处理过则直接返回成功。这个 ID 应该由客户端生成而不是服务端生成,因为在离线状态下客户端无法请求服务端生成 ID。
5.3 SW 更新了但页面还是旧逻辑
这个问题本质是生命周期里的 waiting 阶段没有处理。用户打开页面后,新的 SW 可能已经下载完成并 waiting,但旧 SW 还在控制页面。
最直接的解法是组合使用skipWaiting()和clients.claim()。但前面也提到过,这会让多标签页出现版本不一致的风险。安全的做法是:只跳过 waiting、不立即 claim,或者通过 UI 提示用户“新版本已准备好,点击刷新体验新版”,由用户决定什么时候切换。
5.4 兼容性与环境限制速查
把常见兼容性情况整理成一张表,方便对照:
| 环境 | 后台同步支持情况 | 备注 |
|---|---|---|
| Chrome / Edge 桌面端 | 支持良好 | 实测网络恢复后约几十秒内触发 |
| Chrome / Edge Android | 支持,但受厂商后台策略影响 | 某些 ROM 会延迟或阻止后台执行 |
| Firefox | 不支持 SyncManager | 需要降级方案 |
| Safari (iOS / macOS) | 支持非常有限 | 基本不要依赖,必须降级 |
| 微信内置浏览器 / 部分 WebView | 不支持或受限 | 以实际内核为准 |
降级方案的核心思路是:把“自动补发”降级为“下次打开时补发”。具体实现就是页面加载时读取队列并尝试发送,同时用可见的提示告诉用户有未完成的操作。
5.5 生命周期视角下的“有效生命周期”提醒
最后想补充一个容易被忽略的心得:当我们谈论后台同步时,真正的关键词是“有效生命周期”。SW 的有效生命周期不是无限的,它受版本更新、浏览器策略、用户清理行为等多方面影响。所以不要在设计里假定一个同步任务必然会在几分钟内被执行——更稳妥的预期是“它可能很快被执行,也可能在极端情况下完全丢失”。基于这个预期去设计数据冗余、幂等逻辑和用户提示,你的方案才会真正抗造。
我在自己的项目里已经跑通了这个流程,大约半年里处理了几千笔离线订单,最终的成功率在 99% 以上,剩下的 1% 基本都是用户长时间不开页面、设备数据被系统清理之类的情况。如果你正在打算用后台同步解决断网丢数据的问题,建议从最小场景开始:先接一个表单提交,跑通离线、注册、触发、补发、去重这五个环节,再逐步扩展到更多业务。这样后面踩坑时,你至少知道问题出在哪一环节,而不是对着整条链路一头雾水。