Service Worker实战指南:离线缓存与性能优化技巧
我的写法,亲测靠谱
Service Worker(SW)这东西,用好了能让你的 PWA 飞起来,用不好……页面缓存乱成一锅粥,用户看到的永远是旧内容。我折腾过好几个项目,踩过不少坑,现在基本形成了一套“够用、稳定、不自找麻烦”的写法。
核心原则就一条:别把 SW 想得太复杂,也别太贪心。很多人一上来就想缓存所有资源,结果上线后发现 API 接口也被缓了,用户登录状态全乱了。我现在的做法是:只缓存静态资源(HTML、JS、CSS、图片),API 请求一律走网络,除非有特殊需求(比如离线表单提交)。
下面是我目前最常用的注册和安装逻辑:
// sw.js
const CACHE_NAME = 'v1-static-20240601';
const urlsToCache = [
'/',
'/static/js/app.js',
'/static/css/style.css',
'/offline.html'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', event => {
// 只处理同源请求
if (event.request.url.startsWith(self.location.origin)) {
// 如果是 API 请求,直接走网络
if (event.request.url.includes('/api/')) {
return;
}
// 静态资源尝试从缓存读取
event.respondWith(
caches.match(event.request)
.then(response => {
return response || fetch(event.request);
})
.catch(() => {
// 离线兜底页
return caches.match('/offline.html');
})
);
}
});
为什么这样写?几点理由:
skipWaiting()+clients.claim()能让新 SW 立刻接管页面,避免用户刷新两次才生效的问题(这个我踩过好几次坑,用户反馈“更新没生效”,其实是因为 SW 处于 waiting 状态)- 缓存名带时间戳或版本号,方便强制更新(比单纯 v1、v2 更直观)
- 明确排除
/api/路径,避免误缓存动态数据 - 离线兜底页必须存在,否则用户断网时看到的是浏览器默认错误页,体验极差
这几种错误写法,别再踩坑了
我在 Code Review 时经常看到以下几种写法,轻则缓存失效,重则整个站点出问题:
错误写法 1:无差别缓存所有 fetch
// ❌ 千万别这么干!
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(r => r || fetch(event.request))
);
});
这种写法会把所有请求都缓存,包括 POST 请求、带认证头的 API、甚至第三方统计脚本。后果就是:用户登出后还能看到之前登录的页面,或者重复提交表单。我曾经在一个电商项目里这么写,结果测试阶段发现购物车数据被缓存,用户清空后刷新又回来了,差点背锅。
错误写法 2:缓存策略太激进
有人为了“极致性能”,在 install 阶段缓存几百个资源。结果用户第一次访问时,SW 安装超时失败(浏览器对 install 有时间限制),或者占用大量带宽,导致主页面加载变慢。记住:SW 是增强体验,不是拖累首屏。
错误写法 3:忘记处理 CORS 和非 GET 请求
如果你的站点有跨域图片或字体,caches.match 可能因为 mode 不匹配而返回 null。更糟的是,如果缓存了 POST 请求,下次相同 URL 的 GET 请求可能命中 POST 的缓存(虽然概率低,但确实发生过)。我的建议是:只缓存 GET 请求,且明确指定 credentials: 'same-origin'。
错误写法 4:更新机制缺失
很多教程只教你怎么注册 SW,但没说怎么更新。结果就是:你改了 JS 文件,用户永远看不到新版本。一定要配合 skipWaiting + 页面检测(比如用 navigator.serviceWorker.getRegistration() 主动提示用户刷新)。
实际项目中的坑
除了代码层面,还有一些工程化细节容易忽略:
1. 开发环境别启用 SW
本地开发时如果启用了 SW,改完代码刷新看不到效果,疯狂怀疑人生。我一般在 webpack 或 Vite 的 devServer 里加判断:
// main.js
if ('serviceWorker' in navigator && process.env.NODE_ENV === 'production') {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
2. HTTPS 是硬性要求
本地 localhost 除外,线上必须 HTTPS。曾经有个项目部署到测试环境(HTTP),SW 根本不注册,折腾半天才发现是协议问题。
3. 缓存清理要谨慎
上面代码里 activate 事件会删除旧缓存,但如果用户同时打开多个标签页,可能某个标签页还在用旧缓存,这时候删掉会导致资源 404。稳妥做法是保留最近两个版本的缓存,或者用更复杂的命名策略。
4. 第三方资源别乱缓
比如 Google Fonts、CDN 的 JS 库。这些资源本身有强缓存头,你再套一层 SW 缓存反而可能干扰它们的更新机制。除非你确定需要离线使用,否则建议放行。
5. 测试离线场景
Chrome DevTools 的 Network Throttling 可以模拟 offline,但有时候不够真实。我习惯在手机上开飞行模式实测,因为有些 bug(比如缓存 key 包含 search 参数)只在特定环境下暴露。
一个偷懒但有效的方案
如果你项目不大,又不想折腾复杂的缓存策略,我推荐直接用 Workbox。它封装了常见的缓存策略(StaleWhileRevalidate、CacheFirst 等),而且能和构建工具集成,自动生成缓存列表。
比如用 Vite + Workbox:
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,png,jpg}'],
runtimeCaching: [{
urlPattern: /^https://jztheme.com/api/.*$/,
handler: 'NetworkOnly',
}]
}
})
]
}
这样连手写 SW 都省了,配置几行就行。不过要注意:Workbox 生成的 sw.js 会被注入 hash,所以缓存清理逻辑要适配它的命名规则(通常是 precache-v1-xxx)。
当然,如果你像我一样有控制欲,还是手写更安心。但 Workbox 对新手友好,能避开大部分坑。
最后提醒
Service Worker 不是银弹。它解决的是“弱网/离线”场景,不是性能优化的万能药。我见过团队为了用 SW 而用,结果增加了维护成本,收益却微乎其微。先问自己:用户真的需要离线访问吗?如果只是想提速,优化打包、用 CDN、开 HTTP/2 可能更直接。
以上是我踩坑后的总结,希望对你有帮助。有更好的方案欢迎评论区交流——毕竟 SW 这东西,每个项目都有不同的坑要填。

暂无评论