想象你家小区门口有一家快递驿站。住户(浏览器页面)想买东西时,不会直接去找厂家(网络服务器),而是先到驿站报出需求。驿站站长(Service Worker)接过需求后,先看一眼仓库(Cache API)里有没有现货:有货就立刻提货,没货就当场联系厂家调货。仓库能存多少、先提货还是先订货、货架多久换新、没网时住户递来的单据怎么补发——这一整套“站长管理手册”,就是 PWA 离线体验的全部秘密。本篇文章将带你从零认识这位站长:他住在后台、能拦截所有请求,配有一排可编程的货架(Cache API),还有四套取货预案(缓存策略),以及一套让仓库永不过期的换新机制。学完你就能亲手搭建一个“断网也能用”的网页应用。
掌握 Service Worker 与 Cache API 是 PWA 开发的分水岭。对前端工程师而言,这意味着你具备了优化离线体验、实现秒开加载和弱网降级的核心技能,在性能优化岗位上极具竞争力。
目录
可视化演示
展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T
认识站长:Service Worker 初探
普通网页脚本像驿站里的“临时工”——网页一关,人就下班,什么也干不了。站长不一样,他是 24 小时值班的经理:即使住户关灯睡觉(页面关闭),他仍在后台值班;第二天住户醒来,他早已把要用的东西备好了。这套“页面关了人还在”的能力,正是 PWA 离线体验的第一块基石。
这个“值班经理”在技术上叫 Service Worker,简称 SW。它是一段独立于网页的 JavaScript 脚本,由浏览器在后台单独维护。住户(页面)可以用一种特殊方式“聘请”他:调用注册接口,把站长安排到站点名下。此后,住户的一切取件需求都会先经过站长之手。
站长上岗有三条硬规矩。第一,他必须住在 HTTPS(或本地调试的 localhost)环境里——因为能拦截所有快递,浏览器绝不允许不可信来源聘用他。第二,他与页面不在同一个线程:页面崩溃不影响他值班,他也碰不到 DOM,只能通过消息与页面通信。第三,他有明确的上班流程,即生命周期:下载脚本后依次经历注册、安装、激活,之后进入闲置状态,等待 fetch(拦快递)、sync(补发)等事件召唤。
站长正式上岗了,但此刻他手里还没有任何货。要让住户“断网也能取件”,下一步得给他配一间能存能取的仓库——这正是 Cache API 的用武之地。
- 注册(Register)页面调用
navigator.serviceWorker.register('/sw.js'),把站长简历交给浏览器,浏览器开始下载并解析脚本。 - 安装(Install)脚本校验通过后触发 install 事件,站长趁此时把第一批货物搬进仓库(预缓存关键资源)。
- 激活(Activate)安装完成后触发 activate 事件,站长正式接管页面请求;随后进入闲置状态,等待 fetch 或 sync 事件召唤。
Cache 仓库:缓存 API 基础
站长光能拦截快递还不够,得有个地方存货。驿站角落那排贴着标签的货架,就是 Cache API:它以“请求”为钥匙、以“响应”为货物,是浏览器专门提供给站长使用的存储空间。没有它,站长就算拦下请求也无处安放货物。
这套货架用起来很直觉:caches.open('仓库名') 打开(没有就新建)一间仓库;cache.add('/a.css') 表示“这箱货直接上架”——同时完成从网络取货和入库两步;cache.put(请求, 响应) 则是手动上架,适合存放动态拼装的响应;cache.match(请求) 是取货;caches.delete('仓库名') 是整间仓库拆除。站长把这五招记熟,仓库管理就入门了。
需要特别注意的是,Cache 仓库和 HTTP 缓存是两回事:HTTP 缓存由浏览器根据响应头自动管理,开发者只能眼巴巴看着;Cache API 则完全听站长指挥——存什么、存多久、什么时候清,全部由代码说了算。为了便于日后整仓换新,仓库名的命名有一套约定俗成的公式。
货架备好了,但站长面对茫茫货品,怎么快速找到住户要的那一件?这就要靠“请求匹配”这枚钥匙了。
- 前缀(站点名)用站点名做仓库前缀,避免与其他站点在同一个存储分区里互相混淆。
- 连接符(分隔)用
-v把仓库名和版本号隔开,方便后续按版本整仓删除。 - 版本号(递增)每次发布新版本时递增数字,形成
my-pwa-v1、my-pwa-v2的仓库序列。
请求匹配:钥匙与锁
每件包裹上都缠着一张快递单,上面写着住户的地址和商品编号——这个“单号”就是请求的 URL。仓库里每件货物的标签是一把锁,只有单号对得上才能开锁取货。货再多,只要钥匙配得好,站长就能一秒锁定目标。
在代码里,与“开锁”对应的方法是 cache.match(request)。站长拿着住户的请求去仓库里逐一比对:能找到相同 URL 的条目,就算命中(hit),直接取货;找不到,就算未命中(miss),只能转向网络。这一步是后续所有缓存策略的“决策入口”。
匹配还支持“模糊钥匙”:通过 ignoreSearch 等选项,可以忽略 URL 中的查询参数(比如 ?track=123),让不同参数的请求共享同一件货。cache.keys() 能列出仓库里全部钥匙,常用于盘点货物是否齐全。衡量站长找货效率的核心指标是命中率——它直接决定页面加载是“秒开”还是“转圈”。
有了仓库和钥匙,还缺一本“取货守则”:什么时候先取货、什么时候先订货?这就是缓存策略——站长手里的四套预案。
- 分子(命中数)在缓存中成功找到响应的请求数量,这些请求没有惊动网络。
- 分母(总请求数)站长经手的全部请求数量,包含命中和未命中两部分。
- 比例(百分比)两者相除后乘以 100%,得到命中率,用来对比不同缓存策略的效果。
缓存策略:四种决策
面对住户的每一个需求,站长心里有四套预案。第一套“缓存优先”:仓库有货就直接给,绝不惊动厂家;第二套“网络优先”:先给厂家打电话,打不通才回头用仓库货;第三套“旧缓存再验证”:先把仓库里的旧货递给住户,同时悄悄联系厂家补新货入库,下次就能拿新的;第四套“仅缓存/仅网络”:只适用于特殊商品,比如固定不变的 Logo 或必须实时的接口数据。
选哪套,取决于货物“多容易过期”。带指纹的静态资源(如 app.8f3c2a.js)几乎不变,适合缓存优先,秒开且省流量;HTML 页面和 API 数据变化快,适合网络优先或再验证,保证住户总能看到新鲜内容。现实中没人会全站只用一种策略,按资源类型混搭才是专业做法。
用代码表达,缓存优先策略就是一次条件判断:先查仓库,命中就返回;不命中才走网络,并把新货补进仓库。这构成 PWA 缓存逻辑的基本回路,其他策略都是在这条回路上调整判断顺序。
策略再好,仓库里的货也有过期的一天。站长还得定期对整个仓库“换新”——这就轮到版本更新登场了。
- 条件(策略判断)站长先看当前策略属于哪一类,决定优先查仓库还是优先找网络。
- 命中分支(取货)仓库有货且策略允许时,直接返回缓存响应,速度最快。
- 未命中分支(订货)未命中或策略要求新鲜时,请求网络,并把新响应写回仓库备用。
版本更新:仓库换新
驿站隔段时间就要盘点:旧货架规格过时了,站长就先在旁边搭一个全新的货架(新版本缓存),等新货架验收合格,再把旧货架整个拆掉扔进回收站。这样住户永远用新货,仓库也永远不会被陈年旧物塞满。
技术上的流程与之惊人相似:每次发布网站,开发者修改 sw.js 文件并递增缓存版本号。浏览器发现 sw.js 内容变了,就会下载新版脚本,让它进入安装阶段——此时旧站长仍掌管着页面,新旧站长之间有一段“交接期”。
新站长安装(install)时建立新版本仓库;激活(activate)时手持“白名单”巡检所有仓库,把不在名单里的旧仓库一键删除,释放磁盘空间。若想跳过交接期让新站长立刻接管,可以在安装事件里调用 skipWaiting(),并配合 clients.claim() 让新站长马上控制所有页面。
仓库换新解决了“货品过期”,但还没解决“住户断网时下的单子”。住户在没网时把一份订单塞进驿站信箱,站长要等网络恢复才能发出——这就是后台同步的拿手好戏。
- 白名单(名单)install 时把当前版本名加入允许列表,它代表“正在使用的仓库”。
- 判断(比对)activate 时用
caches.keys()枚举全部仓库名,逐一判断是否在白名单中。 - 删除(清仓)不在白名单的旧仓库整仓删除,释放磁盘空间,避免缓存无限膨胀。
后台同步:离线补发
深夜断电断网,住户没法联系厂家,只好把订单纸条塞进驿站门口的信箱——这个信箱就是“离线任务队列”。站长告诉住户:“放心,网络一恢复我马上发。”然后他继续值班,直到第二天网络恢复,一次性把积压的纸条全部打电话发出去。
技术上,这依赖 Background Sync API,分三步:第一步,页面在网络不可用时把要发送的请求登记进队列;第二步,调用 registration.sync.register('标签') 注册一个同步任务;第三步,浏览器监测到网络恢复后,触发 Service Worker 的 sync 事件,站长在事件回调里把队列中的请求逐个 fetch 出去。
补发并不保证一次成功。如果某个请求因网络仍不稳定而失败,队列会保留它,等待浏览器下一次触发 sync;多次失败后浏览器会停止重试,此时站长通常要给住户留一张“纸条”(通知),告知“有消息还没发出去”。这套机制让“断网时的用户操作不丢失”成为可能。
至此,站长能拦截、能存取、能换新、能补发,整个驿站运转起来了。但系统越复杂越要会“翻台账”——最后来聊调试方法与常见陷阱。
- 成功分支(发出)sync 事件触发后,站长把队列里的请求逐个 fetch 出去,全部成功则清空队列。
- 失败分支(保留)某个请求因网络失败或超时未发出,队列保留该请求,等待下一次 sync。
- 重试(循环)浏览器按策略在后续网络状态变化时再次触发 sync,直到队列清空为止。
调试避坑:巡检清单
好站长每天都要巡仓、翻台账。浏览器的开发者工具就是站长的台账本:打开 DevTools 的 Application 面板,能查看 Service Worker 当前状态、手动注销站长、强制更新脚本、逐条查看仓库里的缓存货物。学会翻台账,排障就成功了一半。
最实用的排障套路是三板斧:先注销站长(Unregister),再清空缓存仓库(Clear storage),最后刷新页面重新注册。大部分“改了代码却不生效”的玄学问题,都能靠这套流程解决。它相当于把驿站推倒重建,让一切回到最干净的初始状态。
常见坑有三个:第一,以为注册了站长就等于离线,其实还要在 install 里预缓存、在 fetch 里拦截返回;第二,全站都用缓存优先,导致页面永远显示旧内容,HTML 该用网络优先;第三,更新了 sw.js 却忘了换版本号或清旧缓存,新货永远进不了仓。调试时用 Network 面板看请求是否走 Service Worker,用 Cache Storage 面板核对货架内容,就能快速定位问题。
从值班经理的聘用、仓库的搭建,到取货守则、换新机制、离线补发,再到最后的巡检台账——站长把驿站打理得井井有条。这套体系正是 PWA 离线体验的全部秘密:让网页像原生 App 一样,在弱网甚至断网时依旧从容。
---
- 注销(Remove)在 DevTools 里 Unregister Service Worker,让系统回到“没有站长”的原始状态。
- 清仓(Clear)清除 Cache Storage 全部仓库,排除旧缓存对验证结果的干扰。
- 刷新(Reload)刷新页面重新注册,观察控制台与 Network 面板确认请求走向。
AI 应用场景
移动电商离线加购
阶段:端侧体验优化
解决的问题:传统网页在弱网或断网时,用户点击“加入购物车”会直接失败,操作无声丢失。利用后台同步机制,加购请求先写入离线队列,网络恢复后自动补发,彻底消除“断网丢单”的痛点。
具体案例:某电商 H5 在 3G 弱网环境下,用 Service Worker 拦截加购请求并存入 IndexedDB 队列,同时注册 sync 任务;用户断网时仍可正常加购,恢复联网后订单自动提交并弹出通知,加购成功率提升了约 20%。
新闻资讯秒开阅读
阶段:加载性能优化
解决的问题:资讯类页面首屏依赖网络,弱网下白屏时间长、跳出率高。采用“旧缓存再验证”策略,老用户打开即见上次内容,后台拉取新数据更新缓存,兼顾速度与新鲜度。
具体案例:某资讯 App 的 H5 首页对文章列表使用 Stale-While-Revalidate 策略,配合骨架屏,二次访问的中位加载时间从 2.8 秒降到 0.6 秒,弱网用户流失率明显下降。
大促活动页离线可用
阶段:静态资源加速
解决的问题:营销活动页集中了大量图片与脚本,网络一抖就白屏。将带 hash 指纹的静态资源用“缓存优先”策略预缓存进 Cache Storage,实现离线秒开。
具体案例:某品牌大促 H5 将 JS/CSS/图片在 Service Worker 安装阶段预缓存,活动期间用户即使断网也能完整浏览活动内容,二次进入页面时近乎零加载等待。
Python 演示
import time class CacheEntry: """仓库里的一件货: 响应数据加入库时间""" def __init__(self, data, ts): self.data = data self.ts = ts class CacheStore: """模拟 Cache API 的仓库, 以 URL 字符串为钥匙""" def __init__(self): self._items = {} def match(self, url): return self._items.get(url) def put(self, url, data): self._items[url] = CacheEntry(data, time.time()) class SimServiceWorker: """站长模拟器: 内置三种缓存策略与命中率统计""" def __init__(self): self._cache = CacheStore() self._latency = 0.05 self.hit_count = 0 self.net_count = 0 def _network(self, url): if url.startswith("/api/offline"): raise OSError("network down") time.sleep(self._latency) return "server:" + url def preload(self, url, data): """预缓存: 相当于 SW 安装阶段的 add 上架""" self._cache.put(url, data) def cache_first(self, url): """缓存优先: 仓库有货直接用, 缺货走网络并入库""" hit = self._cache.match(url) if hit is not None: self.hit_count += 1 return hit.data data = self._network(url) self._cache.put(url, data) self.net_count += 1 return data def network_first(self, url): """网络优先: 先走网络, 失败回退仓库""" try: data = self._network(url) self._cache.put(url, data) self.net_count += 1 return data except OSError: hit = self._cache.match(url) if hit is None: raise self.hit_count += 1 return hit.data def stale_while_revalidate(self, url): """旧缓存再验证: 先给旧货, 同时后台补新""" hit = self._cache.match(url) if hit is not None: self.hit_count += 1 fresh = self._network(url) self._cache.put(url, fresh) return hit.data data = self._network(url) self._cache.put(url, data) self.net_count += 1 return data def hit_rate(self): total = self.hit_count + self.net_count return self.hit_count / total if total else 0.0 def main(): sim = SimServiceWorker() urls = ["/img/logo.png", "/css/app.css", "/js/app.js"] for _ in range(5): for u in urls: sim.cache_first(u) total = sim.hit_count + sim.net_count print("cache_first: requests", total) print("cache_first: hit rate", round(sim.hit_rate(), 2)) sim2 = SimServiceWorker() sim2.preload("/api/offline", "cached-offline-data") print("network_first: fallback", sim2.network_first("/api/offline")) if __name__ == "__main__": main()
class CacheStore:模拟 Cache API 的仓库,内部用字典存储,URL 即钥匙def cache_first(self, url):缓存优先策略:先match找货,缺货才走网络hit = self._cache.match(url)拿 URL 钥匙在仓库找货,对应真实世界的cache.matchdata = self._network(url)模拟网络请求,/api/offline会抛异常模拟断网def network_first(self, url):网络优先策略:先上网,异常时回退仓库except OSError:网络优先的“保险丝”:一旦断网就转向缓存兜底def stale_while_revalidate(self, url):先返回旧货,同时后台拉新入库print("cache_first: hit rate", round(sim.hit_rate(), 2))用命中率给策略“体检”
常见误区
误区新手以为只要调用了 `register('/sw.js')`,页面就自动离线可用了,结果断网测试时页面照样白屏。
正解注册只是“聘请站长”,仓库还是空的。要离线必须走两步:install 事件中调用 cache.addAll 预缓存关键资源,fetch 事件中拦截请求并从缓存返回。好比站长到岗不等于店里备好了货,还得亲自进货、把货架装满。
为何常见:教程标题常写“三步实现 PWA 离线”,让人误以为注册即离线,忽略了预缓存和拦截这两个真正干活的步骤。
误区既然缓存快,那就让所有请求都先查缓存,一劳永逸。
正解HTML 页面和接口数据会过期,全站缓存优先会让用户永远看到旧内容。正确做法是“静态资源缓存优先、页面/API 网络优先或旧缓存再验证”。就像牛奶这类易变质商品,不能因为仓库有存货就一直给住户旧的。
为何常见:缓存优先的代码最短、最直观,容易一步到位地套用到全站,忽略了内容新鲜度这个关键变量。
误区修改代码后刷新浏览器,以为所有用户立刻用上新版本。
正解新 SW 要等所有受控页面关闭后才会默认接管;想立即生效需要在安装时调用 skipWaiting(),再用 clients.claim() 接管页面,同时清理旧版本缓存。就像新站长要等老站长站完最后一班岗,除非“强制交接”。
为何常见:开发者自测时 DevTools 里勾选了"Update on reload",以为所有用户都有这个待遇。
误区浏览器本来就管着缓存,何必再学一套新的。
正解HTTP 缓存由浏览器根据 Cache-Control 等响应头自动决策,开发者控制力很弱;Cache API 是 SW 手写的“私人货架”,存什么、何时清、取哪件完全由代码控制,两者机制完全不同。
为何常见:名字里都带“缓存”,且 DevTools 的 Storage 面板里两者挨着展示,容易让人误以为是一回事。
误区以为 Background Sync 会自动把断网时的请求原样补发出去,不用写任何发送逻辑。
正解sync 只负责在“网络恢复时触发事件”,真正把队列里的请求 fetch 出去、处理失败重试,都要开发者自己在事件回调里实现。信箱只是提醒站长“有活干了”,发不发还得站长亲手打电话。
为何常见:教程多强调“网络恢复自动触发”这个省心特性,却忽略了事件内部需要自己写完整的发送逻辑。
面试题
Q1. Service Worker 运行在哪里?
- A.网页的主线程中运行
- B.独立的后台线程运行← 正确答案
- C.服务器端的脚本运行
- D.浏览器插件环境运行
答案:B。Service Worker 是独立于页面的 Worker 线程,页面关闭后它仍可值守;若住在主线程,页面崩溃就会把它一起拖垮。类比驿站站长不在住户家客厅办公,而是在后台值班室里守着。
Q2. 注册 Service Worker 的硬性前提是什么?
- A.必须使用 HTTPS 协议← 正确答案
- B.必须使用 HTTP 协议
- C.必须购买商业证书
- D.必须部署在顶级域名
答案:A。SW 能拦截全站请求、能力太强,浏览器只允许 HTTPS(或 localhost 调试环境)下聘用它。类比驿站站长能代替住户签收一切快件,物业只肯把这么重要的岗位交给信用可靠的地址。
Q3. 下列哪个 Cache API 方法对应“取货”?
- A.caches.open 打开仓库
- B.cache.add 预缓存资源
- C.cache.match 匹配响应← 正确答案
- D.caches.delete 删除缓存
答案:C。match 拿请求当钥匙在仓库中找对应响应;open 是开门,add 是上架,delete 是拆仓。类比站长取货要靠开锁动作,match 就是那把开锁钥匙。
Q4. 哪种策略会让用户“立刻看到旧内容,同时后台悄悄更新”?
- A.缓存优先:有货先用
- B.网络优先:先上网络
- C.旧缓存再验证:边用边更新← 正确答案
- D.仅缓存:完全离线运行
答案:C。Stale-While-Revalidate 先返回缓存响应,再在后台发起网络请求更新缓存,下次访问即可拿到新内容。类比站长先把现有存货递给住户,同时悄悄补货,住户下次来就能拿到新鲜的。
Q5. 修改 sw.js 后,新版 Service Worker 默认何时接管页面?
- A.修改后立即接管页面
- B.全部页面关闭后接管← 正确答案
- C.十秒后自动接管页面
- D.需要重启浏览器才生效
答案:B。为避免新旧站长同时操作导致混乱,新 SW 默认要等所有受控页面关闭后才接管。想立即接管需调用 skipWaiting()。类比新站长要等老站长把最后一位住户的事办完才能交接班。
Q6. 清理过期缓存的动作通常放在哪个事件中?
- A.install 事件中清理
- B.activate 事件中清理← 正确答案
- C.fetch 事件中清理
- D.sync 事件中清理
答案:B。activate 在新站长接管前触发,此时旧站长已退场、新版本仓库已建好,最适合持白名单巡检删除旧仓库;install 阶段新仓库未就绪,fetch/sync 太频繁不适合全量清理。类比搬家时趁旧货架腾空、新货架就位,一次性清走旧家具。
Q7. 缓存命中率的正确计算方式是?
- A.命中数乘以请求总数
- B.命中数除以请求总数← 正确答案
- C.请求总数除以命中数
- D.命中数加上请求总数
答案:B。命中率 = 命中请求数 ÷ 总请求数 × 100%,衡量多少请求没上网。类比驿站台账里“本地提货次数”占总取件次数的比例,比例越高,说明对网络的依赖越小、加载越快。
Q8. 关于 Cache API 的描述,哪些是正确的?
- A.缓存键可以是请求对象← 正确答案
- B.缓存值存放响应对象← 正确答案
- C.只能缓存 POST 请求
- D.缓存会自动过期清除
答案:AB。Cache 以 Request 为键、Response 为值,天然适配“请求-响应”拦截;它主要用于缓存 GET 类资源;缓存不会自动过期,必须由代码在版本更新时主动删除。类比站长货架上的每件货都贴着“取件单号(请求)”,货物本体是响应。
Q9. 关于后台同步 Background Sync,下列说法正确的是?
- A.通过 sync 事件回调解发← 正确答案
- B.网络恢复时浏览器自动触发← 正确答案
- C.需要先注册一个同步任务← 正确答案
- D.只能同步 POST 请求
答案:ABC。完整流程是:页面用 sync.register 注册任务 → 浏览器监测网络恢复 → 触发 SW 的 sync 事件 → 开发者在回调中自己 fetch 队列请求。它不限定请求方法,GET/POST 都能通过队列管理。类比住户把纸条塞进信箱,网络恢复时信箱响铃提醒站长去处理。
Q10. 关于 Service Worker 生命周期,哪些说法正确?
- A.包含安装与激活阶段← 正确答案
- B.skipWaiting 可加速接管← 正确答案
- C.更新时旧版本控制旧页面← 正确答案
- D.注册之后永远不再更新
答案:ABC。生命周期含安装、激活、闲置;浏览器发现新 sw.js 会下载新版,但默认等旧版页面全部关闭后才接管,skipWaiting() 可立即接管;SW 会随文件变化更新,并非注册一次就一劳永逸。类比新站长入职前要等老站长站完最后一班岗。