前端离线缓存实践:Service Worker 不是随手注册就完事
有个反常识的现象,第一次遇到时我盯着屏幕愣了半天:我们一个装了 Service Worker 的运营工具,明明已经发布了新版本,一部分用户却死活停在旧的 JS 上。客服让他们刷新,刷了也没用;清缓存倒是能好,但你不能让每个用户都去开发者工具里清缓存。
同一个页面、同一次发布,为什么有人拿到新版本有人拿不到?答案藏在 Service Worker 的生命周期里——它接管页面这件事,压根不是“发布即生效”的。想把离线缓存做对,得先把这套生命周期的行为吃透,否则注册得越顺手,后面被反噬得越狠。
新 SW 为什么不会立刻生效
Service Worker 从下载到真正控制页面,要走这么几步:
1install -> waiting -> activate -> controlling
关键就卡在 waiting 这一步。新版本的 SW 下载、install 完之后,不会立刻取代正在控制页面的旧 SW。只要还有一个标签页开着、被旧 SW 控制着,新 SW 就一直停在 waiting 里等。等到所有旧页面都关掉,新 SW 才 activate、才接管。
这就解释了开头那个现象:那些用户开着标签页一直不关,旧 SW 就一直控着页面,新版本永远轮不上。
想跳过这个等待,可以在 install 阶段调 skipWaiting():
1self.addEventListener('install', () => { 2 self.skipWaiting() 3})
再配合 clients.claim(),让新激活的 SW 立刻接管已经打开的页面:
1self.addEventListener('activate', (event) => { 2 event.waitUntil(self.clients.claim()) 3})
但这一步要小心。强制跳过等待意味着页面可能在“运行中”被换掉底层资源——如果 HTML 已经是新版本、JS 还停在旧的(或者反过来),页面就会资源不匹配,报一堆莫名其妙的错。所以 skipWaiting 不是随手加的开关,它的前提是你的资源版本策略本身是自洽的。
不同资源不能用同一套缓存策略
“所有请求都缓存”是最危险的想法。资源的性质不一样,该走的策略完全不同:
带 hash 的静态资源适合 Cache First。文件名里带内容 hash,文件名变了就代表内容变了,缓存永远不会脏:
1/assets/app.a1b2c3.js
HTML 不能长期 Cache First。入口 HTML 一旦被缓存住旧的,它引用的所有资源路径都可能跟着旧,新版本再也进不来。HTML 应该走 Network First——优先拿网络的最新版,拿不到再退回缓存兜底。
接口数据要分级看。订单状态、余额、权限这类强实时数据,缓存了当真会出大问题;头像、配置这类弱实时数据可以走 Stale While Revalidate(先给缓存、后台悄悄更新);支付、鉴权这类敏感请求干脆 Network Only,不缓存。
我习惯在脑子里按这个光谱排一下每类请求落在哪:Cache First(hash 静态资源)、Network First(HTML、关键接口)、Stale While Revalidate(弱实时数据)、Network Only(支付权限)、Cache Only(预缓存资源)。排错了位置,就是线上事故。
这些策略手写 fetch 事件也能实现。Cache First 大致长这样,先查缓存、命中就直接返,没有再走网络并顺手写回缓存:
1async function cacheFirst(request) { 2 const cached = await caches.match(request) 3 if (cached) return cached 4 5 const response = await fetch(request) 6 const cache = await caches.open('assets-v3') 7 cache.put(request, response.clone()) 8 return response 9}
Stale While Revalidate 则是命中缓存立刻返回,同时在后台悄悄拉新版本写回,下次访问就是新的:
1async function staleWhileRevalidate(request) { 2 const cache = await caches.open('data-v3') 3 const cached = await cache.match(request) 4 5 const fetching = fetch(request).then((response) => { 6 cache.put(request, response.clone()) 7 return response 8 }) 9 10 return cached || fetching 11}
真上项目我更倾向用 Workbox 这类成熟库来托底——它把这几种策略、缓存过期、版本清理都封好了,还处理了一堆边界情况(比如 opaque 响应、range 请求),比自己在 fetch 里手撸路由匹配少踩很多坑。自己写一遍的价值主要在于搞懂原理,生产里没必要什么都从零来。
预缓存要克制,Cache Storage 不是永久数据库
预缓存能让关键资源离线可用,但绝不能把整个站点都塞进去。适合预缓存的是应用壳 HTML、核心 JS/CSS、logo 基础图标、离线兜底页这类“少而稳”的东西;不适合的是大量历史页面、用户私有数据、大图和视频、频繁变化的接口响应。
缓存塞太多不只是浪费存储,还会触发浏览器的清理机制。移动端存储更紧张,浏览器会按自己的配额策略把你的缓存清掉,你根本没法阻止。所以别把 Cache Storage 当持久数据库用。
预缓存本身在 install 阶段做,把那批“少而稳”的资源一次性塞进去,event.waitUntil 保证它们缓存完之前 SW 不会进入 installed 状态:
1const PRECACHE = 'precache-v3' 2const PRECACHE_URLS = [ 3 '/', 4 '/offline.html', 5 '/assets/app.a1b2c3.js', 6 '/assets/app.a1b2c3.css', 7 '/logo.svg', 8] 9 10self.addEventListener('install', (event) => { 11 event.waitUntil( 12 caches.open(PRECACHE).then((cache) => cache.addAll(PRECACHE_URLS)), 13 ) 14})
这里有个坑:cache.addAll 是原子的,列表里只要有一个 URL 拉不到(比如打错了路径、或某个资源 404),整个 install 就失败,SW 装不上。所以预缓存列表最好由构建工具生成、跟真实产物对齐,别手写硬编码,不然某次改了文件名忘了同步,SW 直接装不上,比不缓存还糟。
想知道自己占了多少、还剩多少,可以用 StorageManager.estimate() 心里有个数:
1const { usage, quota } = await navigator.storage.estimate() 2console.log(`已用 ${usage} / 配额 ${quota}`)
如果确实有关键数据不想被清,可以申请持久化存储,但浏览器不一定批:
1const persisted = await navigator.storage.persist()
它返回 true 才代表拿到了持久化许可。拿不到就得接受“缓存随时可能没”的前提去设计。
离线兜底页要说清状态
这个页面别只甩一句“出错了”。用户断网时真正需要知道的是:现在是网络不可用(而不是页面 bug)、哪些内容还能看、哪些操作提交不了、能不能重试。这四件事说清楚,用户才不会一遍遍点。
Service Worker 在导航请求失败时返回离线页:
1self.addEventListener('fetch', (event) => { 2 if (event.request.mode !== 'navigate') return 3 4 event.respondWith( 5 fetch(event.request).catch(() => caches.match('/offline.html')) 6 ) 7})
逻辑很短,但有个前提:/offline.html 自己必须已经被预缓存过,否则离线时连这张页面都拿不到,等于白留了一手。这也是为什么前面预缓存列表里我一定把 /offline.html 放进去——它是离线体验的最后一道地板,漏了它,用户断网时看到的就是浏览器自带的那个恐龙页,什么都传达不了。
更新提示比静默替换友好
回到开头那个“用户卡在旧版本”的问题。解法不是无脑 skipWaiting,而是把主动权交给用户。当检测到有新 SW 在 waiting 时,页面弹个提示:
1发现新版本,刷新后生效
用户点了之后,页面通知 waiting 里的 SW 跳过等待:
1registration.waiting?.postMessage({ type: 'SKIP_WAITING' })
SW 收到消息再执行 skipWaiting:
1self.addEventListener('message', (event) => { 2 if (event.data?.type === 'SKIP_WAITING') { 3 self.skipWaiting() 4 } 5})
然后页面监听 controllerchange,等新 SW 接管后刷新:
1navigator.serviceWorker.addEventListener('controllerchange', () => { 2 window.location.reload() 3})
这套流程比“悄悄替换 + 强制刷新”可控得多。尤其用户正在填一个长表单时,突然给他刷新页面、数据全没了,比停在旧版本更糟。让他自己选什么时候更新,是对用户的基本尊重。
还有个容易踩的时序问题:controllerchange 在你调用 skipWaiting 后会触发,但如果页面一加载 SW 就已经在控制它(比如硬刷新过),首次注册也可能触发一次 controllerchange。不加判断的话,用户第一次进页面就被莫名刷新一下。稳妥的做法是加一个标志位,只在“用户主动点了更新”之后才允许这次 reload:
1let refreshing = false 2navigator.serviceWorker.addEventListener('controllerchange', () => { 3 if (refreshing) return 4 refreshing = true 5 window.location.reload() 6})
这个小细节不处理,更新体验就会时不时冒出“页面自己刷新了”的诡异感。
缓存版本要能清理旧资源
缓存不是只进不出的。每次发版会产生新的缓存条目,旧版本的 JS/CSS 如果不清,Cache Storage 会越堆越大,最后被浏览器整个清掉——连该留的一起遭殃。
常见做法是给缓存名字带上版本号,activate 阶段把不属于当前版本的缓存全删掉:
1const CACHE_VERSION = 'v3' 2const CURRENT_CACHES = [`assets-${CACHE_VERSION}`, `data-${CACHE_VERSION}`] 3 4self.addEventListener('activate', (event) => { 5 event.waitUntil( 6 caches.keys().then((names) => { 7 return Promise.all( 8 names 9 .filter((name) => !CURRENT_CACHES.includes(name)) 10 .map((name) => caches.delete(name)), 11 ) 12 }), 13 ) 14})
这一步很容易被忘掉。你只顾着写新缓存,旧的没人删,几个版本下来存储就爆了。把清理逻辑放在 activate 里的好处是:它天然发生在新 SW 接管、旧 SW 彻底退场之后,这时候删旧缓存最安全,不会误删还在被旧页面用的东西。版本号我一般跟构建产物挂钩,发版自动变,不靠手改。
API 错误要分清网络失败和业务失败
离线场景下,错误处理必须更细,不能所有失败都甩一句“网络异常”:
1async function saveDraft(data) { 2 try { 3 await request('/api/draft', { method: 'POST', body: data }) 4 return { ok: true } 5 } catch (error) { 6 if (!navigator.onLine) { 7 return { ok: false, reason: 'offline' } 8 } 9 10 if (error.code === 'VALIDATION_ERROR') { 11 return { ok: false, reason: 'validation', fields: error.fields } 12 } 13 14 return { ok: false, reason: 'unknown' } 15 } 16}
网络失败提示稍后重试、或存本地草稿;业务失败要引导用户改字段;未知失败要上报。三种失败给用户的动作完全不同,混成一句话用户就懵了。
有个细节值得提:navigator.onLine 只能判断“网卡有没有连上”,连着 Wi-Fi 但断了外网它照样返回 true,所以它只能当粗判据,真正确认还得看请求本身有没有失败。想更实时地感知在线状态变化,可以监听 online/offline 事件,但同样别把它们当成“接口一定通”的保证——网络恢复不等于目标服务就绪。
如果要往前走一步支持“离线提交、联网后自动重放”,那复杂度就上一个台阶了:得设计本地队列、重放时机、冲突处理、幂等保证。浏览器有个 Background Sync API 能在网络恢复时唤起 SW 做同步,但它的兼容性和后台执行时机都不算稳,我目前只敢在内部工具里试,对外产品还是保守地做“存草稿 + 手动重试”。别轻易对用户承诺“离线也能完整使用”,那是个很深的坑。
上线前得能看见 SW 的状态
Service Worker 最麻烦的地方是它跑在用户那一侧,出了问题你在服务端日志里什么都看不到。所以上线前我一定会确认几件事能被观测到:现在有多少用户被哪个版本的 SW 控制着、缓存命中率大概是多少、有没有大批用户卡在某个旧版本一直不更新。
具体做法是让页面在注册成功后,把当前 SW 的版本号连同一次埋点一起上报:
1navigator.serviceWorker.ready.then((registration) => { 2 const version = registration.active?.scriptURL 3 report('sw_active', { version }) 4})
有了这个数据,一旦发版后发现某个旧版本的活跃占比迟迟降不下来,就知道是更新链路出了问题,而不是等用户投诉才后知后觉。开发调试时,Chrome DevTools 的 Application → Service Workers 面板能直接看到 install/waiting/active 各处于什么状态,配合 “Update on reload” 勾选,能省掉反复关标签页触发更新的麻烦。这个面板基本是我调 SW 时一直开着的。
收尾
开头那个卡在旧版本的问题,最后是靠“更新提示 + 用户点了再 skipWaiting”解决的——用户不再需要客服教他清缓存,看到提示点一下就更新了。
Service Worker 本质是一层横在浏览器和服务器之间的代理。代理一旦写错,问题就发生在这个夹层里,排查比普通前端 bug 麻烦得多——你既看不到纯前端的报错,也不是后端的锅。所以我对它的态度是:能带来明确收益再上,策略尽量简单,强实时数据不乱缓存,版本更新一定要可控。注册那行代码是最不重要的一步,后面这套版本和数据策略才是真正决定它是帮手还是麻烦的东西。