PWA 实战:Service Worker 生命周期、缓存策略与离线体验
本文目录
Service Worker(下文简称 SW)是一个常驻在浏览器与网络之间的可编程代理:页面发起的请求先经过它的 fetch 处理,你可以决定命中缓存、放行网络还是返回一个离线页。能力越大坑越多——SW 一旦接管请求,"用户看到旧版本"和"缓存无限膨胀"都变成你要负责的事。这篇文章按运行机制展开:生命周期、注册与 scope、缓存策略、预缓存、更新通知、离线回退、消息通信、与 HTTP 缓存的分层,最后汇总常见坑。
范围说明:可安装 PWA 还需要 manifest(名称、图标、入口、显示模式)与 HTTPS,本文聚焦 SW 本身;AppCache 到 SW 的迁移史在站内旧笔记与现代离线缓存迁移有专门记录,不重复展开。
生命周期:install → waiting → activate
SW 的状态机是所有行为的骨架,先记准四个状态:
| 状态 | 含义 | 可控动作 |
|---|---|---|
| installing | 首次或更新版下载后安装中 | install 事件里预缓存资源 |
| installed / waiting | 安装完成,等旧版本让位 | 页面可提示"有更新";调 skipWaiting() 立即接管 |
| activating | 接管前的清理阶段 | activate 里删旧缓存、clients.claim() 立即控制 |
| activated | 正在控制页面 | fetch / message / push / sync 事件 |
// sw.js:一份带注释的骨架
const VERSION = 'v3';
const PRECACHE = `precache-${VERSION}`;
const RUNTIME = `runtime-${VERSION}`;
self.addEventListener('install', (event) => {
// waitUntil 会推迟"安装完成"的确认,装一半退出会整体作废
event.waitUntil(
caches.open(PRECACHE).then((cache) => cache.addAll(PRECACHE_URLS))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
// 1. 清理不属于当前版本的所有缓存
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => ![PRECACHE, RUNTIME].includes(k)).map((k) => caches.delete(k)))
// 2. 立即控制未受控页面,否则要等下一次导航才生效
).then(() => self.clients.claim())
);
});
两个决策点要说透:
- waiting 的默认行为是"等所有旧页面关闭"。这是为了稳定性——旧页面配套旧 SW,中途换引擎会出现"页面代码比 SW 新"的错位。代价是更新延迟:用户不关标签页,永远停在旧版。
skipWaiting()打破等待,立即由新版接管仍在运行的旧页面。适合资源版本化(新页面请求都带新哈希)的站点;如果新旧资源同名同路径(如/app.js固定地址),skipWaiting 后旧页面可能去请求被新缓存策略改写的内容,行为不可预期。要不要 skip,取决于你的资源管理方式,不是默认开的优化。
更新检测本身由浏览器触发:导航等时机检查 sw.js 是否有字节级差异,有则走 install。用户"关掉重开页面没更新"的第一排查点是这一步——不是每次刷新都必然触发更新检查。
注册时机与 scope
// 主页面:load 后注册,不抢首屏资源
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('/sw.js', {
scope: '/', // 控制范围,默认 = sw.js 所在目录
updateViaCache: 'none', // 更新检查时绕过 HTTP 缓存,包括 import 的子脚本
});
console.log('SW registered, scope:', reg.scope);
} catch (err) {
console.error('SW register failed:', err);
// 注册失败不影响页面功能——SW 必须是增强,不是依赖
}
});
}
规则与边界:
- scope 由 sw.js 的位置决定:放在
/sw.js就能控制整站,放在/static/sw.js就只能控制/static/下。想用子路径 SW 控制更大范围,需要服务端配合Service-Worker-Allowed响应头。 - 注册不要抢首屏:常见做法是
load后注册或requestIdleCallback里注册。SW 第一次安装没有离线收益,只会带来额外的下载与缓存开销。 updateViaCache:Chromium 默认('imports')更新检查会绕过主脚本的 HTTP 缓存,但被importScripts引入的子脚本仍受 HTTP 缓存影响——发现"改了子脚本死活不更新",把它设成'none'或给子脚本加版本号。- SW 只在安全上下文可用:HTTPS 或 localhost。生产环境 http 域名上注册不了,报错可能是这个原因。
缓存策略:按资源类型分而治之
不存在"最好的缓存策略",只有"对这类资源合适的策略":
| 策略 | 行为 | 适合 | 风险 |
|---|---|---|---|
| cache-first | 先查缓存,命中直接返回 | 带哈希的静态资源(app.3f2a.js) | 内容永久固化——所以只配版本化地址 |
| network-first | 先请求,失败回缓存 | API、个性化页面 | 离线时返回旧数据,要有过期提示 |
| stale-while-revalidate | 立刻回旧缓存,同时后台更新 | 非关键但常用的资源(字体、图标、列表页) | 首次访问等于 network-first |
| network-only | 永远走网络 | 支付、下单等强一致接口 | 离线直接失败(这是正确行为) |
| cache-only | 永远回缓存 | 离线回退页本身 | 内容永远不更新 |
上图是请求经过各层的示意(数值为示例,实际比例取决于缓存策略配置)。要点是:SW 在 HTTP 缓存之前拦截,所以"SW 缓存命中"的请求根本不会产生网络记录——这既是它的价值,也是调试时"Network 面板怎么没有这条请求"的原因。
三种主流策略的完整实现:
// 1. cache-first:静态资源
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME);
cache.put(request, response.clone()); // clone 后一份入缓存一份返回
}
return response;
}
// 2. network-first:API
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached; // 离线回退到旧数据
return new Response(JSON.stringify({ offline: true }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
}
// 3. stale-while-revalidate:常用非关键资源
async function staleWhileRevalidate(request) {
const cached = await caches.match(request);
const refresh = fetch(request)
.then((response) => {
if (response.ok) {
caches.open(RUNTIME).then((cache) => cache.put(request, response.clone()));
}
return response;
})
.catch(() => cached); // 网络失败且无缓存时返回 undefined,由上层兜底
return cached || refresh;
}
// fetch 路由:按 URL 特征分发策略
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== 'GET') return; // 写操作永远直连
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
if (/\.[0-9a-f]{8,}\.(js|css|woff2?)$/.test(url.pathname)) {
event.respondWith(cacheFirst(request)); // 带内容哈希的静态资源
return;
}
if (request.mode === 'navigate') {
event.respondWith(handleNavigation(request)); // 导航请求,见"离线回退页"
return;
}
event.respondWith(staleWhileRevalidate(request));
});
分发原则总结成一句话:"内容寻址的资源给 cache-first,随时会变的资源给 network-first,丢了也不致命的资源给 SWR,写操作永远直连"。资源没做哈希版本化前,先别上 cache-first——那等于把"更新"这件事焊死。
预缓存与运行时缓存
两类缓存的分工要分清:
| 类型 | 时机 | 内容 | 生命周期 |
|---|---|---|---|
| 预缓存(precache) | install 事件 | 应用壳:HTML 骨架、离线页、关键 CSS/JS | 跟随 SW 版本,activate 时整体换新 |
| 运行时缓存(runtime) | fetch 事件 | 用户真实访问过的资源 | 按策略写入,需主动清理 |
const PRECACHE_URLS = ['/', '/offline.html', '/app.css', '/app.js'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(PRECACHE).then((cache) => {
// addAll 是原子的:一条失败全部失败,install 就会中断——这是保护,不是 bug
return cache.addAll(PRECACHE_URLS);
})
);
});
预缓存的三条纪律:只放小而稳的东西(应用壳),预缓存列表控制在几十个资源以内;列表必须与构建产物同步生成(构建时输出 manifest,sw.js 里别手写 URL,改一次漏一次);缓存名带版本号(precache-v3),activate 里清理非当前版本——这是整个"发版换缓存"机制的地基。
更新通知与版本迁移
SW 更新默认静默,用户会一直用旧版。标准做法是检测到 waiting 后提示用户,确认后再接管:
// 页面端:轮询/事件检测 waiting,弹"发现新版本"
async function watchUpdate() {
const reg = await navigator.serviceWorker.getRegistration();
reg?.addEventListener('updatefound', () => {
const worker = reg.installing;
worker?.addEventListener('statechange', () => {
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateBar(worker); // 有新版本已就绪,controller 存在说明不是首次安装
}
});
});
}
async function applyUpdate(worker) {
worker.postMessage({ type: 'SKIP_WAITING' });
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload(); // 新 SW 接管后刷新,拿到新资源
}, { once: true });
}
// sw.js:响应页面的跳过等待指令
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
体验上两个建议:reload 由用户确认后触发(controllerchange + reload 是强跳转,别在用户填表填一半时发生);提示文案别写"清除缓存更新"——用户不该理解缓存概念,"新版本可用,点击更新"就够了。
真实项目通常直接用 Workbox(Google 的 SW 工具库)而不是手写这些:它提供了 precacheAndRoute、registerRoute、ExpirationPlugin(自动过期清理)等封装。理解了上面的机制再读 Workbox 文档会非常顺;反过来,只会用 Workbox 不懂生命周期,出问题只能干瞪眼。
离线回退页
导航请求(打开页面)失败时的最后防线,是一个预缓存好的静态页:
async function handleNavigation(request) {
try {
const response = await fetch(request);
return response;
} catch {
const offline = await caches.match('/offline.html');
return offline || new Response('网络不可用', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
}
offline.html 的设计要点:它是预缓存资源,所以自身引用的 CSS/图片也要进预缓存列表;页面内容明确说"当前离线"并保留重新加载按钮(navigator.onLine 变化时可以自动重试);别在离线页放需要网络的统计脚本之外的重资源。离线回退是兜底,别把它做成产品功能——核心写操作在离线时正确失败,比假成功好得多。
消息通信:页面与 SW 双向对话
// 页面 → SW:单发
navigator.serviceWorker.controller?.postMessage({ type: 'CACHE_PAGE', url: location.href });
// SW → 页面:用 clients 找到窗口
self.addEventListener('message', (event) => {
if (event.data?.type === 'CACHE_PAGE') {
event.waitUntil(cachePage(event.data.url));
// 回执:通过 event.source 或 clients 广播
self.clients.matchAll({ includeUncontrolled: true }).then((clients) => {
clients.forEach((client) => client.postMessage({ type: 'CACHE_PAGE_DONE' }));
});
}
});
// 页面接收
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'CACHE_PAGE_DONE') toast('本页已可离线访问');
});
需要"一问一答"时用 MessageChannel(SW 线程不能同步返回值),需要广播时 clients.matchAll() 遍历。通信协议和跨页通信一样要有类型契约:type 字段固定、payload 校验后再用,SW 里的异常不冒泡到页面,错了也是无声无息。
与 HTTP 缓存的分层
| 层 | 谁说了算 | 更新语义 | 适合 |
|---|---|---|---|
| 内存缓存 | 浏览器 | 页面生命周期 | 当次会话高频资源 |
| HTTP 缓存(disk) | 服务器(Cache-Control 响应头) | max-age 过期 + 协商缓存 | 所有静态资源的默认层 |
| SW 缓存(Cache API) | 你的 fetch 代码 | 显式写、显式删 | 可编程的离线与更新策略 |
三层的执行顺序是"SW 先拦截、命中则终"——fetch(request) 放行后才会进 HTTP 缓存。这带来两个必须记住的推论:
- HTTP 缓存的配置仍然重要:内容哈希资源配
Cache-Control: max-age=31536000, immutable(一年 + 永不协商),入口 HTML 配no-cache(每次协商)。SW 失效或未安装时,HTTP 缓存是唯一防线。 - SW 自己的更新受 HTTP 缓存影响:即使设置了绕过策略,浏览器对 sw.js 的更新检查有最长 24 小时的强制间隔。紧急修复发版后"用户死活不更新"是这一层机制在起作用,正确姿势是发布时改 sw.js 内容(哪怕是注释里的版本号)确保字节差异被检出。
常见坑
- 注销 SW 不等于清缓存:
unregister()只是不再拦截,caches里的数据原样留存,旧 SW 控制的标签页在下一次导航前仍由它服务。彻底退出要做三件事:注销、按版本名清缓存、发一个"退出 SW"的新版本把旧逻辑替换掉。 - 缓存膨胀:SWR 和 runtime 缓存只进不出,最终吃满配额。用
navigator.storage.estimate()监控用量,给 runtime 缓存设置条目上限与过期时间(Workbox 的ExpirationPlugin就是干这个的);跨源不透明响应也会占配额,且无法读取内容。 - 缓存了不该缓存的响应:接口返回 500、304 被当内容缓存、个性化数据被 cache-first 固化。写入前判断
response.ok和请求类型,列表页内容带时间戳展示"更新于"。 addAll原子失败:列表里一条 404,整个 install 失败且报错信息只有一条。上线前对预缓存清单跑一遍可用性检查。- 调试姿势:DevTools → Application → Service Workers 勾选 "Update on reload"(每次刷新重装 SW),"Bypass for network"(临时绕过 SW 排查是不是它的问题);"Offline" 模拟断网验证离线回退。
- Safari/WebKit 的存储回收更激进:长期不访问的站点,本地数据可能被系统清理,PWA 的关键状态要能从服务端重新拉取,不要把 SW 缓存当成可靠的本地数据库。
注意事项与边界
- SW 是增强不是依赖:注册失败、缓存失败都不应影响页面的基本可用性,降级路径要真实测试。
postMessage的数据走结构化克隆,不能传函数与 DOM 引用;大对象通信考虑先落 IndexedDB 再传键。- iOS 上 PWA 的能力矩阵(推送、后台、存储上限)与 Chromium 有差异且随版本变动,上线前用真实设备核对当前行为。
skipWaiting()与旧页面的兼容性取决于资源版本化程度:入口 HTML 无版本号时,接管瞬间新旧资源混用的窗口期要靠服务端保证向后兼容。- SW 文件本身不要被 CDN 缓存层长缓存,确保更新检查能拿到最新字节。
- 监控要有指标:SW 激活率、缓存写入量、离线请求失败率,都在应用埋点里可测,别等用户投诉才知道缓存没生效。
总结
Service Worker 的可靠使用建立在四个机制上:生命周期(install 预缓存、waiting 等待、activate 清理与接管,skipWaiting 与资源版本化程度匹配)、策略分发(内容寻址资源 cache-first、动态数据 network-first、可牺牲资源 SWR、写操作直连)、更新闭环(waiting 检测 → 用户确认 → skipWaiting → controllerchange 刷新)、分层协作(SW 拦截在 HTTP 缓存之前,两层的配置都要对,sw.js 自身的更新路径要留心)。把这四个机制跑通,离线体验就是水到渠成的副产品;跳过机制直接抄策略代码,缓存膨胀和"用户卡旧版"这两个经典事故只是时间问题。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。