☰
Service Worker生命周期与后台同步:离线表单提交实战
2026/10/13 4:18:02 网站建设 项目流程

这段时间我在做一个移动端的数据采集项目,最头疼的不是界面,而是用户在地铁、山区、电梯里那种“断断续续的网络”环境。表单填了半天,一点提交,界面直接转圈,再刷新什么都没了。当时第一反应是上 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 一直 rejected,install 会失败,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。关键决策有三个:

  1. 是否skipWaiting():跳过等待,旧 SW 立刻淘汰;不调用,则要等旧页面全部关闭。
  2. 是否clients.claim():让新 SW 立刻控制已打开的页面;不调用,则要等下一次刷新。
  3. 旧缓存怎么处理:在 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 一个非常典型的表单提交场景

最简单的实现是用户点提交后,先发送到服务器,成功就提示成功,失败就提示失败。但断网时,请求会直接打挂,用户要么重试,要么离开,数据丢失。

后台同步的思路完全不一样:

  1. 用户点提交时,把请求参数或整个请求体保存到 IndexedDB / Cache Storage。
  2. 通过registration.sync.register('tag')注册一个同步任务。
  3. 浏览器检测到网络恢复后,唤醒 SW,触发sync事件。
  4. 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 可能还在 installing,sync接口未必可用。

注册 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 不让用户丢数据的兜底方案

后台同步做完了,不代表就没有丢数据的风险。我的生产环境中,后台同步通常和这几种策略搭配:

  1. 页面加载兜底:打开页面时检查本地是否有残留队列,有就尝试补发。
  2. 手动重试入口:在设置页提供“立即同步”按钮,调用registration.sync.register(tag)触发一次新同步。
  3. 用户反馈:如果同步多次失败,给用户一个明确提示,而不是让数据静默躺在本地。

配套的 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 或 waiting,sync 不会注册成功。第二步,确认注册时页面是否是受控的。你在 DevTools 里如果看到controller一直是 null,那后台同步注册的是个“僵尸注册”,不会有后续。第三步,确认浏览器版本和平台限制。电脑端 Chromium 生态对后台同步支持比较积极,移动端的第三方浏览器则不一定。

还要注意一个限制:sync事件的触发依赖浏览器对“有网络”的判断。你把 Wi-Fi 断开又立刻打开,它可能不会马上触发,因为系统网络栈还没完全恢复。给它几分钟,或者干脆在 DevTools 的 Application 面板里手动触发 sync 来验证。

5.3 更新缓存时把旧用户干离线了

有一次我在更新缓存版本号后,没有清理旧缓存,只是把新缓存写进新的 CacheStorage。看起来没有 bug,但磁盘占用涨得飞快,老旧资源永远不会被清理,最后用户在低存储设备上看到“存储空间不足”。

正确的做法还是在 activate 里按白名单清理旧缓存,所以我前面给的 activate 示例并不是可有可无的装饰。

我也会建议给缓存加一个“最大条目数”或“最大容量”的约束,比如定期计算缓存大小,超过阈值就删除最早的一条。毕竟每一种 Cache Storage 都会计入浏览器的存储配额。

5.4 开发工具里怎么验证这一切

推荐一个非常实用的调试流程:

  1. DevTools → Application → Service Workers,勾选“Update on reload”和“Bypass for network”。
  2. 在 Service Worker 面板里点“Sync”按钮,能手动触发浏览器认为的网络恢复场景。
  3. 把 Network 面板调成 offline,提交一次表单,看数据是否写入本地。
  4. 切回 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,排错成本极高。

我的建议是分三步走:

  1. 先确保有网状态下普通上传稳定。
  2. 再加本地持久化,确保断网时数据写到 IndexedDB。
  3. 最后再接 SW 和后台同步,并补上幂等和重试。

每走一步都验证上一阶段没问题,能帮你避免同时排查多个变量。

6.4 监控和退出策略

后台同步是典型的“看着没问题,线上偶尔漏数据”的功能。上线后至少要统计这几个指标:sync 事件触发成功率、单次同步重试次数、落库的队列长度、最终放弃同步的数据条数。

我还会给每条待发送数据定义一个“最大重试次数”或“过期时间”。超过以后就不再自动重发,而是标记为失败,等页面下一次打开时提示用户人工处理。这样系统不会为了一条坏掉的垃圾数据而消耗用户电量和流量。

最后说一点个人体会:生命周期和后台同步组合起来,确实是 Web 端做离线体验的最佳搭档,但它不是银弹。你需要对“网络恢复后什么时候触发”“失败后怎么办”“浏览器不让你跑怎么办”有完整预案。真正让用户觉得“这个站点怎么断网了还能用”的,不是某一个 API,而是这套把意外都考虑进去的系统设计。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询