本节摘要:Service Worker 是浏览器为 Web 应用注入的独立执行上下文——无 DOM、无窗口对象、生命周期与页面解耦的可编程网络代理。本节从注册调用的真实含义讲起,覆盖生命周期状态机、五类核心事件、四种缓存策略模式与四个生产环境暗礁。(素材映射:原 2.1 节全部内容。)
阅读完本节,你应当能够:
别急着读原理,先让东西跑起来。三步。第一步,在页面脚本里注册:
if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js', { scope: '/' }) .then(reg => console.log('注册成功', reg)) .catch(err => console.warn('注册失败', err)); }); }
第二步,在 Service Worker 脚本里预缓存核心资源并接管请求:
const CACHE = 'core-v1'; const ASSETS = ['/', '/index.html', '/styles.css', '/app.js']; self.addEventListener('install', event => { event.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS))); }); self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))) ) ); }); self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request).then(hit => hit || fetch(event.request)) ); });
第三步,打开开发者工具的网络面板,勾选离线,刷新页面——页面完整渲染,没有白屏。这个十几行的脚本就是 PWA 离线能力的全部秘密:安装时预缓存,激活时清理旧版本,请求时先查缓存。
现在回头看那段注册代码的含义。它并非"启动了一个进程",而是向浏览器提交了一份服务声明:从此刻起,该路径下所有受控页面的网络请求,默认交由这份脚本定义的逻辑仲裁。声明被接受后,浏览器在后台启动一个独立的脚本执行沙箱加载它——但此时它还没有任何控制权。真正的权力移交发生在下一次页面导航:用户刷新或跳转到同一作用域的新路由,且加载前已确认存在处于激活态的 Service Worker,控制权才正式交接。
初学者最常见的误解,是把 Service Worker 当成"更高级的联网状态监听器"。认知偏差恰恰是后续所有调试困境的根源。它的本质特征有四条:
理解它的生命周期要抛弃"启动-运行-终止"三段论。它是一套浏览器强制执行的状态机:安装中 → 等待 → 激活中 → 激活,外加一个隐式但重要的"冗余"终态。流转不由开发者的启停指令驱动,而由四重约束自动裁决:
这道强一致性防火墙的价值,用一个反例就能说明:如果允许新 Worker 在旧 Worker 仍控制页面时强行接管请求,同一张页面可能一半由旧缓存供给、一半按新策略处理——这种竞态正是传统 HTTP 缓存方案无力规避的软肋。
支撑状态机运转的是五类核心事件。安装事件是初始化缓存、预加载静态资源的黄金窗口,等待方法包裹的异步任务是进入等待态的前提。激活事件标志着正式取得控制权,是清理过期缓存、迁移数据结构的最后机会。请求拦截事件最具表现力:页面发起的每一个匹配作用域的请求都会触发它,你可以放行、返回缓存、甚至合成响应。消息事件是页面与 Worker 双向通信的信道,用结构化克隆传递对象。推送与通知点击事件让消息触达不再受限于页面是否开启。
取得激活身份后,Worker 便握有了应用的缓存主权。传统 HTTP 缓存是声明式的、全局的、被动的:服务器说缓存三百秒,浏览器机械执行。Service Worker 缓存是命令式的、局部的、主动的:同一个应用里,接口用"网络优先、超时降级",静态图用"缓存优先、永不验证",资讯用"先旧后新"——三种策略共存互不干扰。

"先旧后新"是最具产品智慧的一种:用户立刻拿到可用内容(哪怕略旧),后台静默拉取最新版更新缓存,为下次访问铺路。搜索与社交信息流采用此模式后,最大内容绘制指标实测可提升四成以上。
策略不应硬编码成分支判断,而应抽象为可配置、可测试的策略工厂——把缓存逻辑从事件处理器中解耦,让策略本身可以被单元测试、可以做灰度实验、可以按路由动态注入。
**暗礁一:HTTPS 强制与本地开发陷阱。**页面必须通过 HTTPS 或 localhost 加载,否则注册接口干脆不存在,所有注册逻辑静默失效。解法是为开发域名签发本地可信证书,让开发环境与生产环境零差异;更稳妥的做法是在持续集成里加一道前置检查,发现部署目标是明文协议就直接阻断发布。
**暗礁二:缓存键的语义漂移。**匹配逻辑严格遵循完整地址(含查询串)与 Vary 响应头。若后端对某接口返回了按用户变化的 Vary 头,而缓存时没保留该头,后续请求将永远无法命中。更隐蔽的是请求默认不携带凭据,导致登录前后返回不同内容的接口共用同一缓存键。解法是显式构造带凭据的请求对象,并在写入缓存前规范化键。
**暗礁三:强制接管的滥用。**让新 Worker 立即控制所有已打开页面看似便捷,但页面上若有长连接或未妥善清理的监听器,强制接管可能导致连接中断、状态丢失。稳妥做法是通过消息事件优雅提示"新版本已就绪",引导用户自行刷新;只有确认页面已做好热更新准备时才主动接管。
**暗礁四:磁盘空间的无声吞噬。**缓存接口没有自动清理机制。缓存名不带版本号、激活时不删除旧缓存,都会让存储无限增长,最终触发浏览器的磁盘配额警告——Chrome 在占用约两成磁盘空间时即报警。必须建立缓存生命周期管理:每个缓存名附加版本号,激活事件中枚举全部缓存、删除所有非当前版本。
| 暗礁 | 症状 | 根源 | 解法 |
|---|---|---|---|
| 开发环境失效 | 注册接口不存在 | 非安全上下文 | 本地证书或 localhost |
| 缓存永不命中 | 反复回源 | Vary 头或凭据未对齐 | 规范化缓存键 |
| 更新后状态错乱 | 长连接断开 | 强制接管时机不当 | 消息提示加手动刷新 |
| 存储持续膨胀 | 配额警告 | 旧缓存不清理 | 版本化命名加激活期清理 |
⚠️ 常见坑:安装事件的原子性最容易被忽视——批量预缓存中任何一个资源四零四,整个安装失败,而页面上看不到任何报错,只在应用面板里留一行状态。排查"为什么离线不生效"时,第一时间去看安装事件有没有走完。
💡 关键直觉:Service Worker 之手既是权力也是责任。它把网络层变成白盒状态机的同时,也把缓存治理、版本管理、竞态防护这些原本属于运维的问题,变成了前端工程师的日常功课。
背下这张对照表,能省掉大量盲目排查的时间。**症状:改了缓存逻辑,用户端毫无变化。**根因链通常是:脚本内容未变(哈希相同被忽略)→ 新 Worker 处于等待态(旧页面还开着)→ 激活了但旧缓存没清(版本名没换或清理逻辑写错)。逐环验证:应用面板看 Worker 状态,存储面板看缓存列表里有哪些版本。**症状:离线时部分资源四零四。**根因:预缓存清单漏了资源,或安装当时某个资源下载失败导致整个安装中止。验证方法:在安装事件里逐个打点,或在面板里手动检查缓存条目数量。**症状:登录后才有的内容,游客也看到了。**根因:缓存键未区分凭据——缓存了带会话的响应又服务给了无会话的请求。**症状:存储莫名增长。**根因:每次构建都建新缓存名,激活清理只删了一个旧名,历史版本像地层一样堆积。
还有一个值得养成的习惯:给缓存操作留观测点。在写入与命中处各打一个带路由标签的埋点,你就有了缓存命中率曲线——它不仅用于排错,更是第四章性能优化与第五章质量门禁的数据底座。很多团队从第一天就埋这些点,半年后回看,收益巨大。
⚠️ 再提醒一次最小作用域原则:注册时能限定子路径就别用根路径。作用域越大,单份脚本要正确处理的请求类型就越多,出错的爆炸半径也越大。等架构稳定后再逐步扩大管辖范围,比一上来就全站接管稳妥得多。
有了离线能力,下一步是让应用获得"身份":下一节写应用清单,讲安装提示的触发机制与界面适配。