Service Worker离线缓存实战技巧与性能优化
先上效果,再讲原理
我最近给一个项目加离线访问功能,用户打开过一次页面后,哪怕断网也能继续用。这玩意儿说白了就是 Service Worker 的缓存能力。别一上来就看概念,先看怎么用。
最简单的场景:你有个静态站点,JS、CSS、HTML 都想缓存下来,下次直接走本地。核心代码其实就这几行。
// sw.js
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v1').then((cache) => {
return cache.addAll([
'/',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png'
])
})
)
})
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request)
})
)
})
上面这段代码,install 阶段预缓存资源,fetch 阶段优先从缓存拿。如果没命中,再去网络请求。就这么简单。
然后在主页面注册一下:
// main.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('SW registered: ', registration.scope)
})
.catch((error) => {
console.log('SW registration failed: ', error)
})
})
}
刷新页面,打开 DevTools -> Application -> Service Workers,能看到你的 SW 已激活。Network 请求里,那些被缓存的资源会标着 from disk cache 或 from service worker。
这个场景最好用:API 缓存 + 失败兜底
光缓存静态资源不够用。我们项目里有个数据列表页,每次都要 fetch API。网络差的时候用户体验很差。所以我加了个策略:API 请求失败时返回上次成功的缓存。
const CACHE_NAME = 'api-cache-v2'
self.addEventListener('fetch', (event) => {
const { request } = event
// 只处理 GET 请求和 API 路径
if (request.method !== 'GET' || !request.url.includes('/api/')) {
event.respondWith(fetch(request))
return
}
event.respondWith(
fetch(request.clone()).then((response) => {
// 克隆响应对象并缓存
const clonedResponse = response.clone()
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, clonedResponse)
})
return response
}).catch(() => {
// 网络失败,尝试读取缓存
return caches.match(request).then((cached) => {
if (cached) {
return cached
}
// 连缓存都没有,返回一个默认响应(比如空数组)
return new Response(JSON.stringify([]), {
headers: { 'Content-Type': 'application/json' }
})
})
})
)
})
这里注意下,我踩过好几次坑:fetch 后的 response 是流式的,只能读一次。所以要 clone() 一份去缓存,否则主流程拿不到数据。
另外,缓存命名建议带版本号,方便后续清理旧缓存。不然更新逻辑后,老缓存还在,容易出问题。
缓存更新?别指望自动
很多人以为改了 SW 文件,缓存就自动更新了。错。Service Worker 更新机制有点反直觉。
浏览器会在每次页面加载时检查 SW 文件是否有变化(基于字节差异),有变才会安装新版本。但旧的 Worker 还在运行,直到所有页面关闭。这时候新版本处于 waiting 状态。
解决办法是在注册时加上 updateViaCache 和监听状态:
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).then(registration => {
setInterval(() => {
registration.update() // 主动检查更新
}, 60000) // 每分钟一次
})
或者更狠一点,强制跳过 waiting 阶段:
// 在 sw.js 里
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim())
})
这样新版本一准备好就立刻接管页面。亲测有效,但要注意:如果老页面正在操作数据,突然切换可能有问题。所以建议只在纯展示类项目里这么干。
踩坑提醒:这三点一定注意
- HTTPS 才能用 SW:本地开发可以用 localhost 绕过,但部署到线上必须 HTTPS。HTTP 下
navigator.serviceWorker直接是 undefined。别问我怎么知道的,折腾了半天才发现测试服没配证书。 - 路径问题很恶心:register SW 时传的路径是 scope,默认是你当前 JS 文件所在目录。比如你在
/admin/js/app.js里 register(‘/sw.js’),那它的控制范围只是/admin/下的页面。想全局控制,记得写成register('/sw.js')并确保它在根目录。 - 调试要用 Hard Reload:普通刷新不会触发 install,得 Ctrl+Shift+R。DevTools 里勾上 “Update on reload” 也不靠谱,有时候还是卡住。最保险是手动 unregister 再刷新。
高级点:动态缓存 + 清理策略
上面都是固定缓存,实际项目中图片、文章内容太多,不能全塞进去。我后来改成动态缓存,只存最近访问的 N 个资源。
const MAX_CACHE_ENTRIES = 50
self.addEventListener('fetch', (event) => {
const { request } = event
if (request.url.includes('/articles/') || request.url.includes('/images/')) {
event.respondWith(
caches.open('dynamic-cache').then(async (cache) => {
const response = await fetch(request)
const clonedResponse = response.clone()
await cache.put(request, clonedResponse)
// 检查条目数,超了就删最老的
const keys = await cache.keys()
if (keys.length > MAX_CACHE_ENTRIES) {
cache.delete(keys[0]) // FIFO,简单粗暴
}
return response
}).catch(() => caches.match(request))
)
}
})
这种适合内容型站点,比如博客。用户看了 50 篇文章,缓存最近的,老的自动踢掉。内存不会爆炸,体验也够顺滑。
不过这个方案不是最优的,LRU 会更好,但实现复杂。目前这个够用,我就没改。
拓展玩法:预加载 + 后台同步
Service Worker 还能干更多事。比如用户提交了个表单,但当时没网。你可以把数据暂存 IndexedDB,等 SW 发现网络恢复后自动重发。
// 页面中
navigator.serviceWorker.ready.then((sw) => {
sw.sync.register('submit-form')
})
// sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'submit-form') {
event.waitUntil(
sendFormDataFromIndexedDB()
)
}
})
这个叫 Background Sync,兼容性还行,Android 上基本都能用。iOS Safari 不支持,得降级处理。
还有 Push Notifications、Periodic Sync(定期拉数据)、Offline Analytics(离线埋点)等等,都是基于 SW 的能力。这些我后面会单独写博客聊。
最后说两句
Service Worker 学起来有点门槛,主要是生命周期反人类,调试也不直观。但我用了两年,现在基本成了标配。特别是做 PWA 或者提升首屏速度,几乎是必选项。
改完之后仍有小问题:比如 iOS Safari 对 Cache Storage 的清理太激进,偶尔会丢缓存。但这无大碍,顶多重新拉一次数据。
以上是我个人对 Service Worker 的实战总结,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

暂无评论