想象你住在一栋老式公寓里。楼下的管家老王,是你和外界所有信件、包裹往来的唯一通道。你上班不在家时,他会把快递签收好放进储物间;小区断网时,他按经验先用囤好的日用品应对;你睡觉时,他默默把明天的补给申请发出去;遇到急事,他会主动给你打个电话汇报。 PWA(渐进式 Web 应用)里的 Service Worker,就是浏览器给你的网页请来的这样一位“数字管家”。他不是网页本身,也不是服务器,而是住在浏览器与网络之间的一个独立守门人。网页完成注册,他就开始上岗:安装、激活、拦截请求、存取缓存、推送消息、后台同步。他把网页从一个“被动等待浏览的文档”,变成了一个“主动服务用户的 App”。 理解了这位管家是怎么入职、怎么值班、怎么调度、怎么应对突发状况,你就掌握了 PWA 的全部精髓。
对 AI 从业者而言,Service Worker 是理解“边缘缓存”“端侧智能”的天然样板。掌握它,等于掌握了一套在任何前端工程里做离线优先、性能优化和推送触达的硬核技能。
目录
可视化演示
展示SW生命周期、缓存策略、推送通知与调试更新机制。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Service Worker生命周期 install事件预缓存核心资源进入installed状态 activate事件清理旧缓存后进入activated激活态 SW = install → activate → fetch 三阶段生命周期管理SW注册与激活流程
生命周期:管家的入职与退休
大楼不是随便一个人就能当管家的。老王入职那天,先在管理处登记培训,通过考核后才能正式上岗。此后他每天在收发室值班,直到新管家来接替。Service Worker 的生命周期完全复刻了这个过程:下载脚本、安装准备、激活接管、服务、更新、退休。
通过 navigator.serviceWorker.register('/sw.js') 注册后,浏览器在后台下载脚本。脚本解析成功,SW 进入 installing 状态并触发 install 事件,这是预缓存首页骨架资源的最佳时机:把固定不变的 HTML、CSS、Logo 提前放进缓存仓库。安装完成后,如果没有其他旧 SW 控制页面,它会直接进入 activating 并触发 activate 事件;activate 里通常做旧缓存清理,给新版本腾地方。
更新流程略微讲究。浏览器每次导航回到页面,都会重新对比 sw.js 的内容——一旦发现字节有差异,新 SW 进入 waiting 状态,安静等待旧 SW 控制的页面全部关闭后才接管。如果不想等,可以调用 self.skipWaiting() 让新管家立刻上岗,再用 clients.claim() 让已经打开的页面也接受新管家管理。
就像老王退休前,必须等所有住户家的事都安顿好(页面关闭)才能交接。如果整栋楼同意(skipWaiting),老王也可以提前退居幕后,把对讲机直接交给新管家。
- installing 与 activated一对关键的前后状态,install 负责准备资源,activate 负责接管控制权。
- waiting 状态新管家已就绪,但旧管家还在值班,需要等待旧 SW 控制的页面全部关闭。
- redundant 状态旧 SW 被取代后进入冗余状态,从此不再处理任何请求。
请求拦截:管家的收发室
老王最重要的岗位是收发室。所有住户的信件、包裹、外卖都先经他手,再由他决定是代收、转交、还是原路退回。SW 的 fetch 事件就是这样一个收发室:页面发出的每一个网络请求,SW 都能听到并处理。
在 SW 脚本里监听 fetch 事件:
event.respondWith() 是管家的决定权——告诉浏览器“这个请求我来处理”。可以返回缓存的副本,也可以转发给网络,甚至可以拼一个兜底页面。默认情况下,页面首次加载时不受 SW 控制,需要 clients.claim() 之后,已经打开的页面才会把请求交给管家。
SW 与页面之间还通过 message 事件双向通信。页面调用 navigator.serviceWorker.controller.postMessage(data) 发消息给管家;管家在 message 事件里接收,用 event.source.postMessage(result) 回信。这就好比住户按门口对讲机问老王:“今天的牛奶到了吗?”老王在收发室回一句:“到了,我放储物柜了。”
收发室的价值在于:页面不需要关心响应到底来自储物柜还是云端。管家把一切都包办好了,页面只负责收到一个结果。
- 请求对象 rSW 通过 fetch 事件收到页面发出的请求对象。
- 分支判断先用 cache.match 在仓库里找,找到就直接返回;找不到再转发给网络。
- respondWith 包装无论走哪条分支,最终都要用 respondWith 把响应“承诺”给页面。
self.addEventListener('fetch', event => { event.respondWith(caches.match(event.request) .then(res => res || fetch(event.request)));});
缓存仓库:管家的储物柜
老王有一个整整齐齐的储物柜,按“快递柜”的格式管理:打开柜门、放进去、取出来、清空。Cache Storage 就是给 SW 用的快递柜。
打开仓库用 caches.open('my-cache-v1'),它返回一个 Promise,resolve 出 Cache 对象。往里放东西用 cache.put(request, response),手动控制颗粒度;cache.addAll(['/index.html', '/style.css']) 则批量抓取并存储,适合 install 阶段预缓存。取东西用 cache.match(request),返回命中的 Response;caches.delete(name) 用于删除整个仓库,在版本升级时清理旧货。
需要注意:HTTP 响应是“一次性流”,读完之后不能重读。所以缓存时一般先 response.clone() 一份——一份给页面用,一份存进仓库,否则会报错。另外,缓存仓库有配额限制,超出后浏览器会整体清空仓库,所以重要资源要主动管理版本。
储物柜里的东西不是越多越好。放得太多会占满配额,放得太旧会拿到过期货物。管家需要定期整理,把不用的旧版本清掉,把重要的新货摆到最顺手的位置。
- 分子 N_hit命中的请求数,即管家直接从储物柜交货的次数。
- 分母 N_total总请求数,即管家经手的全部请求数量。
- 比值含义命中率越接近 1,说明多数请求绕开了网络,离线能力越强。
缓存策略:先查库存还是先问商家
对同一个请求,管家可以选择不同的处理风格:是直奔储物柜拿库存,还是先打电话问商家要最新款?这就是缓存策略。
Cache First(缓存优先):先查缓存,命中直接返回;未命中才请求网络并回填缓存。适合版本不变的静态资源——Logo、JS 文件、字体,速度快得惊人,代价是网络更新后缓存可能滞后。
Network First(网络优先):先请求网络,成功就返回并顺带更新缓存;失败(断网)再退回缓存。适合首页 HTML、文章接口这类“要新鲜但也能接受兜底”的资源。
Stale-While-Revalidate(SWR):立即返回缓存,同时在后台发网络请求,拿到新响应后替换缓存,供下次使用。体验最顺滑,只是第一次可能看到旧内容。
聪明的管家不会只用一套逻辑。首页 HTML 用 Network First 保证新鲜,静态图片用 Cache First 保证速度,API 数据用 SWR 保证均衡。策略是手段,用户体验才是目的。
- p 命中率请求命中缓存的经验概率,由资源更新频率决定。
- T_cache 与 T_miss命中时只需缓存读取时间;未命中时先付出一次查找失败的开销。
- 网络与回填未命中还要加上网络请求时间 T_network,以及把新响应写回缓存的时间 T_fill。
消息推送:管家主动打电话
老王不只是守门,他还会主动给你打电话:“你订的牛奶到了”“物业要停水了”。PWA 的 Push API 就是管家主动来电的能力。
Push 的关键是订阅关系。页面里调用 registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }),浏览器会生成一个订阅对象并交给服务器。服务器以后用 Web Push Protocol 往订阅里的 endpoint 发送加密消息,消息到达后由 SW 的 push 事件接收,再调用 self.registration.showNotification(title, { body }) 弹出系统通知。用户点击通知时,notificationclick 事件负责聚焦或打开对应页面。
整个过程有三方参与:应用服务器(寄信人)、浏览器推送服务(邮局)、SW(收信管家)。用户必须显式授权通知权限——这相当于住户同意管家记录自己的联系方式。
有了这串三元组,应用服务器就可以在用户完全关闭网页的情况下,依然把消息递到用户眼前。这就是 PWA 能像原生 App 一样“唤醒”用户的技术底座。
- endpoint浏览器推送服务分配的唯一地址,等价于管家在邮局登记的信箱号。
- p256dh 公钥用于内容加密的公钥,保证推送内容路上不被偷看。
- auth 密钥鉴权密钥,证明服务器确实订阅过,防止陌生人乱投递。
后台同步:断网也不误事
老王有个好习惯:断网时他会把待办事项写在小本子上,等网络恢复后逐一补办。Background Sync 就是小本子的数字版。
页面里调用 navigator.serviceWorker.ready.then(reg => reg.sync.register('upload-notes')),相当于在小本子上记一笔“待办事项”。浏览器立刻或稍后(网络可用时)触发 SW 的 sync 事件,你在事件里执行上传操作。如果任务失败(比如服务器 500),抛出异常即可,浏览器会按指数退避策略自动重试,不会无限猛敲服务器。
后台同步非常适合“离线表单提交”“日志上报”“消息补发”等场景。用户在地铁上写完一条评论,网络断了,评论先进离线队列;出地铁网络恢复,SW 自动补交,整个过程用户无感知。
小本子的妙处在于“不打扰”。用户不需要知道网络断过,也不需要手动重新提交。管家默默把活儿干完,这才是好服务的最高境界。
- 初始间隔 T0第一次重试前等待的基础时间,通常设为几秒。
- 指数递增每次重试间隔翻倍,1 次失败等 2 倍,2 次失败等 4 倍。
- 上限 T_max防止等待时间无限拉长,超过上限就固定等 T_max 再试。
调试技巧:给管家体检
再靠谱的管家也有需要体检的时候。Chrome DevTools 的 Application 面板——准确说是 Application 标签下的 Service Workers 区域——就是给管家做全面体检的地方。
打开 DevTools → Application → Service Workers,你能看到当前注册的 SW 脚本、作用域、运行状态和更新时间。左上角有 Offline 复选框,勾选后模拟断网,立刻验证离线缓存是否生效。旁边还有 “Update on reload“ 开关,打开后每次刷新页面都会重新扫描 sw.js 是否有变化,省去手动点击更新的麻烦。还有一个 ”Bypass for network“ 选项,勾选后所有请求直接走网络、绕过 SW,用来对比”有管家“和”没管家”的差异。
排查更新问题有个常见套路:改了 sw.js 但页面还是走旧逻辑,先确认是否勾了 Update on reload;再确认新 SW 是否卡在 waiting(在面板里点 Skip Waiting 按钮);最后确认 activate 事件里有没有清理旧缓存。
体检的意义在于把看不见的问题量化。一个优秀的开发者,会习惯性打开 Application 面板,观察每一条请求是管家(缓存)交付的还是云端交付的,然后据此调整策略。
- T_network 与 T_cache同一请求分别走纯网络和走缓存时的响应时间。
- 偏差 ΔT两者之差超过阈值 ε 时,说明本应命中缓存的请求去了网络。
- 排查方向优先检查 fetch 拦截是否注册、缓存名是否匹配、更新流程是否被 waiting 卡住。
AI 应用场景
离线优先的智能客服助手
阶段:数据采集与端侧缓存
解决的问题:传统 Web 客服页面每次提问都要联网,网络不稳定时用户被拒之门外。利用 SW 的 Cache First 策略,把 FAQ 知识库、常见问题模板缓存到本地;再利用 Background Sync,把用户在弱网环境下提交的咨询异步补发到服务端。
具体案例:某银行的 PWA 客服页面,用户在地铁隧道里打开页面,常见问题列表秒开;提交的投诉工单因断网先进离线队列,出隧道后自动上传,并推送一条“工单已收到”的通知。
新闻资讯 App 的推送触达
阶段:推荐结果触达与用户召回
解决的问题:算法推荐出文章后,传统 Web 站无法在用户关闭页面后触达用户。SW 的 Push API 让推荐系统可以把内容直接推送到系统通知栏,点击通知直达文章页,极大提升推荐系统的曝光和点击率。
具体案例:某资讯站点在用户授权通知后,服务端对“算法认为用户感兴趣”的突发新闻,通过 Web Push 发送标题摘要。SW 弹出通知,用户点击后打开预缓存的文章骨架,正文由 Network First 策略保证新鲜。
Python 演示
# -*- coding: utf-8 -*-"""模拟 Service Worker 的缓存策略派发 演示 cache_first 与 network_first 在离线场景下的差别。"""import randomimport time class MockCache: """模拟 Cache Storage 储物柜""" def __init__(self): self.store = {} def put(self, key, value, ts=None): self.store[key] = {"value": value, "ts": ts or time.time()} def match(self, key): return self.store.get(key) class ServiceWorker: """数字管家:拦截请求并按策略分发""" def __init__(self, strategy="cache_first"): self.cache = MockCache() self.strategy = strategy self.hit_count = 0 self.total_count = 0 def network(self, url): """模拟网络往返,10% 概率失败""" time.sleep(random.uniform(0.05, 0.1)) if random.random() < 0.1: raise ConnectionError("网络不可用") return {"url": url, "from": "network", "data": "content:" + url} def fetch(self, url): """管家拦截请求的入口""" self.total_count += 1 if self.strategy == "cache_first": cached = self.cache.match(url) if cached: self.hit_count += 1 return cached["value"] try: resp = self.network(url) except ConnectionError: raise RuntimeError("双重失败:无缓存且断网") self.cache.put(url, resp) return resp try: resp = self.network(url) self.cache.put(url, resp) return resp except ConnectionError: cached = self.cache.match(url) if cached: self.hit_count += 1 return cached["value"] raise RuntimeError("双重失败:断网且无缓存") def run_demo(): print("=== Cache First 策略 ===") sw_cf = ServiceWorker("cache_first") for i in range(4): try: r = sw_cf.fetch("/api/item/" + str(i)) print(r["from"], "->", r["url"]) except RuntimeError as e: print("错误:", e) print("命中:", sw_cf.hit_count, "/", sw_cf.total_count) print("\n=== Network First 策略(手动断网) ===") sw_nf = ServiceWorker("network_first") for i in range(4): try: r = sw_nf.fetch("/api/item/" + str(i)) print(r["from"], "->", r["url"]) except RuntimeError as e: print("错误:", e) sw_nf.network = lambda url: (_ for _ in ()).throw(ConnectionError("强行走断网")) try: r = sw_nf.fetch("/api/item/0") print(r["from"], "->", r["url"], "(离线兜底成功)") except RuntimeError as e: print("错误:", e) if __name__ == "__main__": run_demo()
self.store[key] = {"value": value, "ts": ts or time.time()}把响应放进模拟储物柜,带时间戳。cached = self.cache.match(url)管家先在储物柜里翻一遍,对应 caches.match。self.cache.put(url, resp)网络响应拿到后立刻回填缓存,供下次命中。raise RuntimeError("双重失败:无缓存且断网")兜底失败时的最终报错。sw_nf.network = lambda url: ...临时替换网络函数,模拟断网场景,验证 Network First 会退回缓存。
常见误区
误区很多初学者以为只要调用了 `navigator.serviceWorker.register('/sw.js')`,整个页面马上就能离线运行了。刷新一下发现断网还是打不开,就开始怀疑人生。
正解SW 安装需要时间,且首次注册后只对后续页面加载生效——当前页面还没有被 SW 控制。预缓存也必须在 install 事件里做,且 cache.addAll 里的资源必须全部成功缓存,否则整个安装会失败。正确做法是在页面加载完成后注册,并等待 controllerchange 事件确认已被控制。
为何常见:因为 register 返回的 Promise 让人误以为“注册成功 = 已经接管”,其实它只代表脚本下载成功。
误区有人以为 SW 既然叫"Service Worker",那跟 Web Worker 差不多,可以在里面 `document.getElementById` 改页面。
正解SW 运行在独立的 worker 线程中,没有 window、document API,也不能直接访问 DOM。它只能通过 fetch 事件拦截请求、通过 postMessage 与页面通信、通过 Cache API 管理存储。页面表现层的更新仍需页面 JS 配合。
为何常见:把 SW 和普通 Web Worker 混淆了,且“管家”的比喻容易让人以为他能进住户家里摆家具。
误区既然缓存能大幅提速,那所有资源都走 Cache First,岂不是最快?
正解POST 请求、带鉴权信息的 API、实时数据接口都不适合盲目缓存。Cache First 会对用户信息敏感接口返回陈旧数据,甚至把 A 用户的响应缓存后发给 B 用户。正确做法是按资源类型区分:静态资源 Cache First,主要接口 Network First,兜底用 SWR。
为何常见:因为缓存带来的速度提升太诱人,初学者忽略了“新鲜度”和“用户隔离”这两个维度。
误区开发者改了 sw.js,刷新页面,发现改动根本没生效,于是怀疑浏览器缓存。
正解浏览器会在导航时后台对比 sw.js,但新 SW 默认进入 waiting 状态,要等所有旧页面关闭后才接管。调试时需要勾选 DevTools 里的 Update on reload 并点击 Skip Waiting,生产环境则应调用 self.skipWaiting() 配合 clients.claim() 完成平滑升级。
为何常见:大家习惯了“刷新即最新”的 Web 心智模型,忽略了 SW 为了稳定而设计的双版本过渡机制。
面试题
Q1. navigator.serviceWorker.register('/sw.js') 返回的 Promise 成功时,意味着什么?
- A.SW 已经激活并接管所有页面
- B.SW 脚本解析并注册成功,等待安装← 正确答案
- C.通知权限已经被用户授权
- D.Service Worker 已经立即卸载
答案:B。register 成功只代表脚本下载、解析、注册完成,后续还有 installing→activated 的状态推进。就像管家登记完简历,还没开始上班。A 错在“已接管”,C 与注册无关,D 方向完全反了。
Q2. 以下哪个不是 Service Worker 的生命周期状态?
- A.installing
- B.activated
- C.waiting
- D.loading← 正确答案
答案:D。SW 的四种状态是 installing、activated、waiting、redundant。loading 是普通浏览器的页面加载状态,不是 SW 状态机的成员。比喻来说,管家处于“培训中、值班中、等待交接、退休”四种模式,没有“加载中”。
Q3. install 事件中预缓存多个静态资源,最合适的 API 是什么?
- A.caches.open 后手动 put
- B.cache.addAll(urls) 批量存入← 正确答案
- C.fetch 预请求所有资源
- D.localStorage.setItem 存储
答案:B。install 阶段预缓存的标准做法是用 caches.open 打开仓库后调用 cache.addAll,一次性抓取列表内资源。手动 put 虽然可行但代码冗长;fetch 只是发起请求不解决存储;localStorage 是同步 API,存不了 Response 对象。
Q4. 新 SW 进入 waiting 状态后,想让它立即接管页面,应该调用什么?
- A.self.skipWaiting()← 正确答案
- B.clients.claim()
- C.location.reload()
- D.registration.unregister()
答案:A。skipWaiting 让 waiting 的新 SW 跳过等待直接激活;clients.claim 是激活后让已打开页面受控,两者常搭配使用。location.reload 只刷新页面,unregister 是卸载注册,方向反了。
Q5. SW 的 fetch 事件中,用哪个方法决定最终返回给页面的内容?
- A.event.respondWith(promise)← 正确答案
- B.event.returnValue = response
- C.event.preventDefault()
- D.self.postMessage(response)
答案:A。respondWith 是 fetch 事件的核心方法,接收一个 Promise<Response>,让 SW 成为请求的中转人。preventDefault 只是取消默认行为;postMessage 是跨线程通信,不是返回响应;returnValue 是普通事件 API,不适用于 FetchEvent。
Q6. 关于 Cache Storage 中 Response 对象的“一次性流”,正确的处理是?
- A.缓存前先调用 response.clone() 复制一份← 正确答案
- B.缓存前先把响应转成字符串再存
- C.每次读取缓存的响应不受任何限制
- D.响应只能被读取一次,无法缓存展示并存
答案:A。Response 是流式数据,被消费(读取 body)后不能再读。缓存时若既想存仓库又想返回页面,必须 clone() 一份。字符串化会丢失响应头信息;C、D 都忽略了流的单次消费特性。
Q7. 使用 Push API 推送通知时,订阅对象包含的三项核心信息是?
- A.endpoint、p256dh 公钥、auth 密钥← 正确答案
- B.url、token、用户 ID
- C.设备型号、浏览器版本、IP 地址
- D.通知标题、正文、图标
答案:A。订阅对象本质是“邮局信箱 + 加密钥匙”——endpoint 是唯一地址,p256dh 用于内容加密,auth 用于鉴权。设备型号、IP 是隐私数据不会出现在订阅里;标题正文是发送时决定的。
Q8. 关于 Background Sync,下列说法正确的是?
- A.可以在断网时把提交任务加入队列← 正确答案
- B.网络恢复后 SW 会收到 sync 事件← 正确答案
- C.同步失败后不会自动重试
- D.适合离线表单提交和日志上报← 正确答案
答案:ABD。Background Sync 先把任务注册到队列,网络可用时触发 sync 事件,这正是“小本子补办”的机制。失败后抛出异常会按指数退避重试,所以 C 错。离线表单、日志上报都是典型场景。
Q9. 以下哪些情况会导致缓存策略失效或行为异常?
- A.缓存了 POST 请求的响应← 正确答案
- B.缓存同一份资源却不做用户隔离← 正确答案
- C.install 事件里预缓存了版本文件
- D.activate 阶段清理了旧版本缓存
答案:AB。POST 请求语义上不是幂等读取,缓存会导致数据错乱;不做用户隔离会把 A 用户的私有数据发给 B 用户。预缓存版本文件和清理旧缓存都是正确操作。缓存策略要按请求语义和用户维度精细设计。
Q10. 要让用户设备上的 SW 从旧版本升级到新版本,必须满足哪些条件?
- A.服务器上的 sw.js 内容与本地不一致← 正确答案
- B.旧 SW 停止控制所有页面或调用 skipWaiting← 正确答案
- C.用户清除浏览器全部缓存数据
- D.新 SW 的 install 和 activate 流程顺利完成← 正确答案
答案:ABD。版本升级需要三个前提:浏览器检测到 sw.js 字节变化、旧 SW 不再控制页面(或 skipWaiting 跳过等待)、新 SW 成功完成 install+activate。用户清缓存不是升级条件,反而会连 SW 注册一起清掉。