这段时间我在做一个移动端的数据采集项目最头疼的不是界面而是用户在地铁、山区、电梯里那种“断断续续的网络”环境。表单填了半天一点提交界面直接转圈再刷新什么都没了。当时第一反应是上 IndexedDB 存草稿但后来发现浏览器早就提供了更合适的东西Service Worker 生命周期和后台同步Background Sync。真正把这套东西跑通之后数据传输变得跟没断网一样丝滑。但我也必须坦白第一次接入时sync事件等了整整一下午没触发最后查来查去发现是 Service Worker 根本没有接管页面。所以如果你只想要一个快速能用的代码片段这篇文章会给你但如果你想让它在生产环境里真的稳定可控建议你先把生命周期这条线彻底吃透。1. 先搞清楚 Service Worker 站在浏览器请求链路的哪个位置已经有人在别的项目里给我说过“Service Worker 不就是一个跑在浏览器里的缓存工具吗”这个说法不算错但太局限。它本质上是浏览器提供的一个网络代理层所有从当前页面发出的请求都有机会先经过一段你写的代码由这段代码决定直接走网络、返回缓存、还是两者一起做。1.1 一个没有 SW 的页面请求到底怎么走正常情况下用户在地址栏输入网址浏览器发起 HTML 请求拿到文档后解析里面的 CSS、JS、图片再逐个发起资源请求。这一路基本是“页面 → 浏览器 → 服务器 → 浏览器 → 页面”中间没有第三方的代码可以干预。一旦网络断开这整条链路就断了。用户能看到页面崩溃、请求失败、表单丢失。即便你用了 cookie、localStorage也只能把数据存在本地等到网络恢复后靠页面自己再次发起请求。这个“再次发起”的动作页面本身做不到自动等待用户也不会乐意一直盯着屏幕等网络恢复。1.2 加了 SW 之后请求多了一道“代理闸门”Service Worker 被注册并激活后浏览器会把指定scope范围内的页面请求全部交给fetch事件处理。你可以在里面写一套非常灵活的规则。self.addEventListener(fetch, (event) { event.respondWith( caches.match(event.request).then((cached) { return cached || fetch(event.request); }) ); });这段代码的意思就是有缓存就用缓存没有缓存才走网络。很多 PWA 应用“秒开”的体验就来自这里。但后台同步和fetch事件不是一回事。后台同步解决的是“现在没网等有网的时候再自动执行某个任务”而不是“现在请求一个资源我来决定怎么返回”。两者经常配合使用但职责完全不同。1.3 SW 运行的独立世界线程、生命周期和限制Service Worker 不是跑在页面线程里的它由浏览器专门管理与页面完全不共享 DOM也拿不到 window 对象。它有自己的全局对象self所有事件都是靠self.addEventListener来注册的。另一个重要限制是它随时可能被浏览器“杀死”。内存不足、系统省电模式、长时间空闲都会导致 SW 停止运行。所以你不能在里面写“setInterval 定时上传”这种逻辑因为定时器随时会被清掉。浏览器 Web 平台能提供给你的“后台触发机会”就是sync、push、periodicsync这类事件。这也是为什么后台同步必须依赖 SW只有 SW 能在页面关闭之后仍然被浏览器在合适的时机唤醒。需要说明的是SW 只对安全上下文开放。实际部署时生产环境必须走 HTTPS否则浏览器根本不会注册。本地调试时localhost会被当作安全上下文这点开发阶段不用太担心。2. install 到 activate再到被替换生命周期不是五个等长的阶段很多文档喜欢把 Service Worker 生命周期画成 Installing、Installed、Activating、Activated、Redundant 五个阶段像五个格子依次排队。实际跑起来你会发现真正需要你写代码做决策的只有几个关键节点而且每个节点的时机都直接影响用户体验。2.1 注册与下载浏览器什么时候真正开始安装注册发生在页面里通常用navigator.serviceWorker.register()。if (serviceWorker in navigator) { window.addEventListener(load, () { navigator.serviceWorker.register(/sw.js).then((registration) { console.log(注册成功scope 为, registration.scope); }); }); }这里有两个细节要注意。第一register()传的 SW 脚本路径直接决定了控制范围。比如/sw.js控制整个站点而/assets/sw.js只能控制/assets/下的页面。这个 scope 在开发者工具里能看到如果发现页面不被控制先检查是不是路径问题。第二register()只是让浏览器去下载并解析脚本。第一次下载成功后会进入 installing 状态。如果脚本下载失败、语法错误、或者内容里引用了不存在的资源浏览器会在这一步直接放弃安装SW 进入 fail 状态页面之后也不会被接管。2.2 install 阶段唯一要做的事是“准备”浏览器的设计哲学是install 事件里只做预缓存准备不要在这个阶段修改旧的缓存更不要主动去请求用户数据。最常见的动作是把首屏需要的资源塞进 Cache Storage。const CACHE_NAME my-cache-v1; const urlsToCache [/, /index.html, /app.js, /app.css]; self.addEventListener(install, (event) { event.waitUntil( caches.open(CACHE_NAME).then((cache) cache.addAll(urlsToCache)) ); });event.waitUntil()是这段代码的灵魂。它接收一个 Promise浏览器会等这个 Promise 完成后再把 SW 状态切到 installed。如果 Promise 一直 rejectedinstall 会失败SW 直接废弃。所以在cache.addAll的时候不要把那个返回 404 的 URL 放进去否则整个安装都会失败。如果你希望新版本 SW 不要一直等旧页面关闭才激活可以在 install 里调用self.skipWaiting()。我个人给的策略是静态资源版本更新时用跳过等待涉及业务逻辑变更时不要随便跳过。因为跳过等待会把新版 SW 立刻推给用户如果新版有 bug你连回滚的缓冲期都没有。2.3 activate 阶段清理和接管页面installed 状态下的 SW 还只是个“预备队员”。默认情况下它要等到所有受控页面都关闭或者用户重新打开一个新页面时才会进入 activate 状态。很多人第一次测试时发现 SW 一直停留在“Waiting”状态就是这个原因。activate 阶段最该做的就是清理旧缓存。self.addEventListener(activate, (event) { event.waitUntil( caches.keys().then((cacheNames) { return Promise.all( cacheNames .filter((name) name ! CACHE_NAME) .map((name) caches.delete(name)) ); }) ); });同时可以调用self.clients.claim()让新 SW 立刻接管所有同源页面。self.addEventListener(activate, (event) { event.waitUntil(self.clients.claim()); });这个调用非常重要。没有它第一次访问的用户在刷新页面之前都不会被 SW 控制。你注册了 SW但页面上navigator.serviceWorker.controller一直是 null后台同步自然也就注册不上去。我在项目里总结的完整 activate 写法是先清理旧缓存再clients.claim()顺序别反过来。清理旧缓存时如果旧页面还在跑可能会偶发资源文件被删导致的加载失败所以在清理之前先接管页面能降低这种风险。2.4 新版本替换旧版本时的三个关键决策更新场景是生命周期里最容易翻车的部分。浏览器会定期检查 SW 脚本是否有变化也会在register()时做检查。只要脚本文件内容有一丁点变化就会触发新 SW 的 install。关键决策有三个是否skipWaiting()跳过等待旧 SW 立刻淘汰不调用则要等旧页面全部关闭。是否clients.claim()让新 SW 立刻控制已打开的页面不调用则要等下一次刷新。旧缓存怎么处理在 activate 里手动删掉否则磁盘空间会被旧版本占满。你可以看到它们各自解决不同的问题。一个典型的生产配置是“skipWaiting 加 clients.claim 加缓存清理”。但这个组合意味着用户在当前会话里就切换到新版本如果页面上有长连接、正在播放的音乐等复杂场景切换 SW 可能会导致页面资源加载异常。所以我的建议是默认更新但给关键业务做一个版本开关必要时放弃自动更新。2.5 “卡在 redundant”究竟是什么情况当你打开 DevTools 的 Application 面板偶尔会看到一个 stale 或 redundant 状态的 SW。它表示这个 SW 已经被新版本顶替浏览器已经废弃它了。这是正常现象不代表代码有 bug。真正需要警惕的是新 SW 安装失败导致没有 active SW或者页面长期没有任何 SW 受控。遇到这种情况我通常会直接清空站点数据重新加载页面然后从 Network 面板里观察 SW 脚本是否下载成功。这里还有个容易忽略的问题很多框架打包时会生成带 hash 的文件名但sw.js本身可能被 CDN 缓存了。CDN 返回 304 时浏览器认为没有任何变化就不会触发更新。所以 sw.js 最好设置成no-cache或者干脆放在不经过长缓存 CDN 的路径下。3. 后台同步当用户提交瞬间断网数据不应该消失生命周期讲完我们回头看后台同步。它解决的是一个很朴素但特别痛的场景用户在提交数据的那一刻网络掉了数据不能丢。3.1 一个非常典型的表单提交场景最简单的实现是用户点提交后先发送到服务器成功就提示成功失败就提示失败。但断网时请求会直接打挂用户要么重试要么离开数据丢失。后台同步的思路完全不一样用户点提交时把请求参数或整个请求体保存到 IndexedDB / Cache Storage。通过registration.sync.register(tag)注册一个同步任务。浏览器检测到网络恢复后唤醒 SW触发sync事件。SW 里读取暂存数据重发请求。这个过程对用户是完全透明的页面关上也没关系。网络恢复后浏览器在后台完成重发用户下一次打开页面时发现数据已经传上去了体验非常自然。我在真实项目里对用户做的文案提示是“已保存将在网络恢复后自动提交”而不是“离线模式”。这样用户既有确定性又不会觉得自己在用一款残废版本。3.2 Background Sync、Periodic Background Sync、Web Push 三兄弟怎么分工经常有人把这三个搞混我用一张表理清它们的差异。API触发条件典型用途主要限制Background Sync网络恢复后浏览器自行决定时机离线提交、消息重发触发时间不精确、iOS 支持有限Periodic Background Sync按照最小时间间隔定期触发每天拉取一次最新天气、订阅源更新需要权限、浏览器支持率更低Web Push服务器下发推送用户在通知栏点击后进入页面促销通知、订单状态通知需要订阅 push依赖系统通知权限后台同步并不是“定时任务”它更像是“网络恢复后的任务补发”。浏览器会基于你注册时的 tag、网络状态、用户行为来决定何时触发开发者不应该假设“断网恢复后的下一秒 sync 就会执行”。那 Periodic Background Sync 呢它才更接近“定时任务”而且它也有自己的生命周期依赖同样要求 SW 处于 activated 状态同时还需要用户授予站点通知权限否则权限查询和注册都过不了。3.3 为什么后台同步只能在 SW 活着的时候用因为页面 JS 在页面关闭后一定被销毁而同步任务需要在任意时间被唤起执行。SW 是浏览器唯一能在后台执行任务的机制。而且 sync 事件挂在self上不是挂在页面 window 上。self.addEventListener(sync, (event) { if (event.tag upload-pending) { event.waitUntil(processUploads()); } });同时注意同步事件并不保证 100% 执行。浏览器为了续航可能延迟、合并甚至丢弃一次同步。所以业务代码里必须考虑“如果 sync 一直没触发用户下一次打开页面时也要能补传”。我通常在页面加载时也做一次兜底检查有本地队列就主动触发同步或者在可见时直接补发请求。4. 后台同步从零到一的完整代码示例理论部分已经足够现在给一套真正能落地的实现。完整流程分成三块主线程注册同步任务、SW 处理同步事件、以及定期后台同步的接入。4.1 主线程端注册 SW、等待 ready 再注册 sync 任务主线程要做的第一件事仍然是注册 SW因为只有 SW 激活后registration.sync才能使用。// 主线程代码 async function requestBackgroundSync(tag) { if (!(serviceWorker in navigator)) { throw new Error(当前浏览器不支持 Service Worker); } const registration await navigator.serviceWorker.ready; if (sync in registration) { await registration.sync.register(tag); } else { // 不支持 Background Sync 时落到普通请求 await uploadNow(); } }这里的关键是navigator.serviceWorker.ready。它会等当前站点的 SW 完成激活后才 resolve这样你拿到的registration一定处于可用状态。如果你直接用register()的返回值SW 可能还在 installingsync接口未必可用。注册 sync 的 tag 是你的业务标识同一个页面可以注册多个不同 tag。tag 不能为空最好是语义化字符串比如upload-pending、order-1357。4.2 SW 端sync 事件里的重试与请求回放sync 事件触发后SW 要从 IndexedDB 里把未提交的数据取出来再把请求重发出去。// sw.js self.addEventListener(sync, (event) { if (event.tag upload-pending) { event.waitUntil(flushPendingUploads()); } }); async function flushPendingUploads() { const pending await readPendingFromIDB(); for (const item of pending) { try { const response await fetch(item.url, { method: item.method, headers: item.headers, body: item.body, credentials: include, }); if (response.ok) { await removeFromIDB(item.id); } else { await markFailed(item.id); } } catch (e) { await keepInIDB(item.id); } } }这里有几个实操细节发送请求时不要把请求体重新序列化成 JSON 字符串存的时候存成一个对象取出来直接用。除非你的接口体量非常大否则没必要做压缩。credentials: include要显式加上SW 里的 fetch 默认不含 Cookie如果你的接口需要登录态缺了它重发会一直 401。每一条都单独 try/catch避免一条坏数据卡住整批数据。后台同步虽然能处理网络恢复但它不能处理“请求发送了但服务器超时”的情况。这种没有明确失败响应的请求可能需要靠幂等键来去重。我会在 body 里放一个客户端生成的requestId服务器端做幂等校验宁可重复收到也不要丢失。4.3 定期后台同步的完整接入流程周期性后台同步和普通后台同步不是同一个 API使用前要先查权限。async function setupPeriodicSync(registration) { const permission await navigator.permissions.query({ name: periodic-background-sync, }); if (permission.state granted) { await registration.periodicSync.register(daily-data-refresh, { minInterval: 12 * 60 * 60 * 1000, // 12 小时 }); console.log(periodic sync 权限已授予); } else { console.warn(用户没有授予 periodic sync 权限); } }SW 端的事件监听self.addEventListener(periodicsync, (event) { if (event.tag daily-data-refresh) { event.waitUntil(refreshDailyData()); } });这个 API 对用户设备的影响很大所以浏览器会从电量、用户访问频率等维度综合决定实际触发时间。minInterval只是个期望值不是你设了 12 小时就一定是 12 小时。它可能更长也可能更短但通常不会比minInterval更频繁。4.4 不让用户丢数据的兜底方案后台同步做完了不代表就没有丢数据的风险。我的生产环境中后台同步通常和这几种策略搭配页面加载兜底打开页面时检查本地是否有残留队列有就尝试补发。手动重试入口在设置页提供“立即同步”按钮调用registration.sync.register(tag)触发一次新同步。用户反馈如果同步多次失败给用户一个明确提示而不是让数据静默躺在本地。配套的 SW 里我还会维护一个“最近同步时间”的缓存 key。页面能读取它展示“上次同步时间”用户就知道数据有没有真正传上去。5. 实战中绕不开的坑生命周期与后台同步的“连环坑”现在说踩坑。以下每个问题我都真实遇到过而且它们经常串在一起出现看起来像玄学实际上链路很清晰。5.1 初始化了却拿不到 controller症状navigator.serviceWorker.controller返回 null注册也显示成功了但页面从未被 SW 控制。原因有两种。第一种是首次访问SW 注册完成后并不能立刻控制当前页面必须等刷新。这其实是符合规范的但容易被误判成注册失败。第二种是 SW 在 activate 阶段没有调用self.clients.claim()导致即使刷新了也要再等一次导航。我在第一次做后台同步时就是栽在这个上面。registration.sync.register(tag)可能不会报错但浏览器根本不会排定任何 sync 任务你等多久都不会被触发。解决方式SW 的 activate 里永远调用self.clients.claim()同时在前端监听controllerchange事件来感知页面真正受控。navigator.serviceWorker.addEventListener(controllerchange, () { console.log(页面已被新的 SW 接管可以安全注册后台同步了); });5.2 sync 事件为什么不触发这是一个 DEBUG 起来相当痛苦的问题。我们做项目时发现在某台测试机上注册了sync.register(test)断网、恢复、再断网、再恢复sync 事件就是不触发。最后把问题拆成三步才定位第一步确认 SW 是否真的处于 activated 状态。如果还是 installing 或 waitingsync 不会注册成功。第二步确认注册时页面是否是受控的。你在 DevTools 里如果看到controller一直是 null那后台同步注册的是个“僵尸注册”不会有后续。第三步确认浏览器版本和平台限制。电脑端 Chromium 生态对后台同步支持比较积极移动端的第三方浏览器则不一定。还要注意一个限制sync事件的触发依赖浏览器对“有网络”的判断。你把 Wi-Fi 断开又立刻打开它可能不会马上触发因为系统网络栈还没完全恢复。给它几分钟或者干脆在 DevTools 的 Application 面板里手动触发 sync 来验证。5.3 更新缓存时把旧用户干离线了有一次我在更新缓存版本号后没有清理旧缓存只是把新缓存写进新的 CacheStorage。看起来没有 bug但磁盘占用涨得飞快老旧资源永远不会被清理最后用户在低存储设备上看到“存储空间不足”。正确的做法还是在 activate 里按白名单清理旧缓存所以我前面给的 activate 示例并不是可有可无的装饰。我也会建议给缓存加一个“最大条目数”或“最大容量”的约束比如定期计算缓存大小超过阈值就删除最早的一条。毕竟每一种 Cache Storage 都会计入浏览器的存储配额。5.4 开发工具里怎么验证这一切推荐一个非常实用的调试流程DevTools → Application → Service Workers勾选“Update on reload”和“Bypass for network”。在 Service Worker 面板里点“Sync”按钮能手动触发浏览器认为的网络恢复场景。把 Network 面板调成 offline提交一次表单看数据是否写入本地。切回 online等待几秒看 Network 面板是否出现自动发送的请求。如果你还想模拟“SW 被系统杀掉之后再恢复”可以直接在 Service Worker 面板点“Stop”然后重新激活看注册的 sync 是否还在。实际写业务的时候我通常用一个内部页面来查看当前队列长度、最近同步时间、失败次数这比 DevTools 方便多了。你可以在 sw 内部监听 message 事件把它做成一个可查询的“健康检查接口”。6. 上生产之前我对后台同步这件事的几个态度聊到最后分享几个我在实际项目里沉淀下来的原则。6.1 它是“最终一致”而不是“实时送达”后台同步不能保证“断网后立即自动上传”更不能保证“用户没看到结果就一定能发送成功”。它只能保证“在网络允许、浏览器认为合适的时机尽力重试”。如果你要做的是证券交易、实时聊天这类强时效场景后台同步不是合适的选择WebSocket 或本地持久连接才是。强实时功能可以单独设计离线数据同步则应该作为降级路径而不是主路径。6.2 把复杂度关在 SW 的盒子里在项目里我让页面代码只负责“写入待发送队列”和“注册 sync”重试、幂等、清理全部放在 SW 内部。页面 SW 之间通过postMessage通信页面不直接操作 IndexedDB 的细节。这样做的好处是你可以在不改变页面业务流程的前提下替换掉整个同步策略。比如从“网络恢复后自动同步”换成“用户点击按钮再同步”只需要改 SW 内部逻辑。6.3 先做普通上传再做后台同步很多人一上来就想把离线能力做得很完整结果在本地存储、缓存更新、请求重放三个环节同时出 bug排错成本极高。我的建议是分三步走先确保有网状态下普通上传稳定。再加本地持久化确保断网时数据写到 IndexedDB。最后再接 SW 和后台同步并补上幂等和重试。每走一步都验证上一阶段没问题能帮你避免同时排查多个变量。6.4 监控和退出策略后台同步是典型的“看着没问题线上偶尔漏数据”的功能。上线后至少要统计这几个指标sync 事件触发成功率、单次同步重试次数、落库的队列长度、最终放弃同步的数据条数。我还会给每条待发送数据定义一个“最大重试次数”或“过期时间”。超过以后就不再自动重发而是标记为失败等页面下一次打开时提示用户人工处理。这样系统不会为了一条坏掉的垃圾数据而消耗用户电量和流量。最后说一点个人体会生命周期和后台同步组合起来确实是 Web 端做离线体验的最佳搭档但它不是银弹。你需要对“网络恢复后什么时候触发”“失败后怎么办”“浏览器不让你跑怎么办”有完整预案。真正让用户觉得“这个站点怎么断网了还能用”的不是某一个 API而是这套把意外都考虑进去的系统设计。