1、Service Worker 缓存是什么?
Service Worker 缓存
Service Worker 是浏览器在主线程之外 运行的一段 JavaScript 脚本,可以拦截网络请求、操作缓存,实现完全由前端控制的缓存策略。
它与 HTTP 缓存(强缓存/协商缓存)是两套独立体系,互不干扰。
核心能力
| 能力 | 说明 |
|---|---|
| 拦截请求 | 像代理服务器一样,捕获所有同域及子域的 fetch 请求 |
| 离线访问 | 没网时从缓存返回资源,实现 PWA 离线功能 |
| 精细控制 | 不依赖服务器响应头,前端自己决定缓存什么、怎么更新 |
| 后台同步 | 网络恢复后自动重发失败的请求 |
生命周期
scss
安装 (install) → 激活 (activate) → 空闲等待 → 拦截 fetch
↓ ↓
预缓存核心资源 清理旧缓存
(如 app shell)
两种缓存对象
| 对象 | 用途 | 特点 |
|---|---|---|
CacheStorage (caches) |
存储 HTTP 请求的 Request/Response 对 |
容量大(通常几十到几百 MB),可精确控制 |
IndexedDB |
存储结构化数据 | 适合存 API 返回的 JSON 数据 |
典型缓存策略(Cache Strategies)
javascript
// service-worker.js
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = ['/index.html', '/app.js', '/style.css'];
// 1. 安装时预缓存核心资源
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS); // 一次性写入缓存
})
);
self.skipWaiting(); // 立即激活
});
// 2. 激活时清理旧版本缓存
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((names) => {
return Promise.all(
names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n))
);
})
);
self.clients.claim(); // 立即控制页面
});
// 3. 拦截请求,决定缓存策略
self.addEventListener('fetch', (e) => {
const { request } = e;
// 策略1: Cache First(优先读缓存,没有则网络请求并缓存)
if (isStaticAsset(request)) {
e.respondWith(
caches.match(request).then((cached) => {
return cached || fetch(request).then((response) => {
return caches.open(CACHE_NAME).then((cache) => {
cache.put(request, response.clone());
return response;
});
});
})
);
}
// 策略2: Network First(优先网络,失败回退缓存)
if (isAPI(request)) {
e.respondWith(
fetch(request).catch(() => caches.match(request))
);
}
// 策略3: Stale While Revalidate(先返回缓存,同时后台更新)
if (isImage(request)) {
e.respondWith(
caches.match(request).then((cached) => {
const fetchPromise = fetch(request).then((response) => {
caches.open(CACHE_NAME).then((cache) => cache.put(request, response.clone()));
return response;
});
return cached || fetchPromise; // 有缓存先给缓存,同时后台更新
})
);
}
});
常见缓存策略对比
| 策略 | 行为 | 适用场景 |
|---|---|---|
| Cache First | 有缓存直接用,没有才网络请求 | 静态资源(JS/CSS/字体) |
| Network First | 先网络,失败回退缓存 | API 数据、实时性要求高的内容 |
| Cache Only | 只用缓存,不发网络 | 离线模式下的纯静态页面 |
| Network Only | 只用网络,不读缓存 | 实时性极强的数据(如股票行情) |
| Stale While Revalidate | 先给缓存,后台静默更新 | 新闻列表、图片,平衡速度与新鲜度 |
与 HTTP 缓存的关系
markdown
┌─────────────────────────────────────────┐
│ 浏览器发起请求 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Service Worker 拦截 fetch 事件 │
│ ├─ 命中 CacheStorage → 直接返回 │ ← 完全绕过 HTTP 缓存
│ └─ 未命中 → 继续发真实网络请求 │
│ ↓ │
│ ┌─────────────┐ │
│ │ HTTP 缓存层 │ ← 强缓存/协商缓存 │
│ │ (浏览器默认) │ │
│ └─────────────┘ │
└─────────────────────────────────────────┘
关键点:
- Service Worker 的
fetch拦截发生在 HTTP 缓存之前 - 如果 SW 从
CacheStorage返回了响应,HTTP 缓存层完全不会参与 - 只有 SW 决定继续发网络请求时,才会走到强缓存/协商缓存的逻辑
实际应用场景
| 场景 | 做法 |
|---|---|
| PWA 离线应用 | 预缓存 HTML/JS/CSS,无网也能打开 |
| 首屏加速 | 先返回缓存的骨架屏,再请求真实数据 |
| 弱网优化 | 网络差时自动降级到缓存版本 |
| 版本更新 | 修改 CACHE_NAME(如 app-v2),触发新 SW 安装,清理旧缓存 |
一句话总结
HTTP 缓存 是浏览器按服务器响应头自动管理的,被动 ;
Service Worker 缓存 是前端代码主动控制的,主动------你可以决定缓存什么、什么时候更新、没网时怎么降级,甚至完全绕过 HTTP 缓存。
2、实际生产环境中,service worker使用的多吗?什么情况下会使用呢?
实际生产环境中 Service Worker 的使用情况
总体现状:用得不多,但特定场景是标配
| 场景 | 使用频率 | 说明 |
|---|---|---|
| 普通管理后台 / 企业内网系统 | 很少用 | 用户在线使用,离线需求弱,接入成本高 |
| 电商 / 内容型 C 端站点 | 中等 | 主要用于首屏加速、弱网降级 |
| PWA 应用(如 Twitter Lite、饿了么 H5) | 必用 | 离线访问是核心能力 |
| 微信小程序 / 快应用 | 底层原理 | 框架内部使用,开发者无感知 |
为什么"用得不多"?
成本端:
- 调试困难 --- SW 有独立生命周期,更新不生效、缓存清理不干净是常见问题
- HTTPS 强制要求 --- 本地开发也要配证书
- 兼容性包袱 --- 虽然现代浏览器都支持,但老旧环境要兜底
- 更新机制反直觉 --- 用户可能一直用着旧版本 SW,新版本"装上了但不激活"
收益端:
- 很多场景 HTTP 缓存 + CDN 已经够用了
- 管理后台用户不会"离线使用",SW 价值有限
- 接入和维护成本 > 实际收益
什么情况下值得使用?
| 条件 | 典型场景 |
|---|---|
| 离线可用是刚需 | 现场作业 App(如外卖骑手端、巡检系统)、弱网环境(地铁、地下室) |
| 首屏速度是生死线 | 电商大促 H5、新闻资讯类,先给缓存再刷新 |
| 流量/带宽敏感 | 发展中国家、按流量计费网络,减少重复下载 |
| 需要后台能力 | 推送通知(Push API)、后台同步(Background Sync) |
| 构建成 PWA/桌面应用 | 用 Electron/Tauri 替代方案时,SW 是 PWA 的核心 |
实际案例
| 产品 | SW 用途 |
|---|---|
| Twitter Lite | 离线发推、弱网浏览、推送通知 |
| 饿了么 H5 | 离线查看订单、首屏秒开 |
| Vue/React 文档站 | 离线阅读文档(VitePress/Docusaurus 默认支持) |
| 网易云音乐 Web 版 | 缓存歌曲、离线播放 |
你的场景判断
| 项目类型 | 建议 |
|---|---|
| 普通管理后台 | 不需要 SW,HTTP 缓存 + CDN 足够 |
一句话
SW 是特定问题的特定解 ,不是前端标配。大部分项目用不上,但面试问到要答得出来;遇到离线、首屏、弱网场景时,它是唯一解。
3、举一个实际使用的例子
实际案例:外卖骑手端 H5(饿了么/美团类似场景)
这是 Service Worker 的经典落地场景,离线刚需 + 弱网高频。
业务背景
- 骑手在商场地下室、电梯、老旧小区,网络时断时续
- 必须能离线查看已分配订单 、提交配送完成
- 不能因没网就白跑一趟或无法履约
SW 实现的核心逻辑
javascript
// sw.js
const CACHE_NAME = 'rider-app-v2';
const PRECACHE = [
'/index.html',
'/static/js/app.js',
'/static/css/app.css',
'/offline.html' // 兜底离线页面
];
// 1. 安装时预缓存核心壳资源
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE))
);
self.skipWaiting();
});
// 2. 激活时清旧缓存
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(names =>
Promise.all(names.filter(n => n !== CACHE_NAME).map(n => caches.delete(n)))
)
);
self.clients.claim();
});
// 3. 请求拦截:不同资源不同策略
self.addEventListener('fetch', (e) => {
const { request } = e;
const url = new URL(request.url);
// 策略A:API 数据 → Network First,失败回退缓存
if (url.pathname.startsWith('/api/')) {
e.respondWith(
fetch(request)
.then(res => {
// 网络成功,更新缓存
const clone = res.clone();
caches.open(CACHE_NAME).then(cache => cache.put(request, clone));
return res;
})
.catch(() => {
// 网络失败,读缓存(如之前加载过的订单列表)
return caches.match(request).then(cached => {
if (cached) return cached;
// 连缓存都没有,返回离线兜底
return new Response(JSON.stringify({ offline: true, msg: '网络异常,数据可能不是最新' }), {
headers: { 'Content-Type': 'application/json' }
});
});
})
);
return;
}
// 策略B:静态资源 → Cache First
if (request.destination === 'script' || request.destination === 'style') {
e.respondWith(
caches.match(request).then(cached =>
cached || fetch(request).then(res => {
caches.open(CACHE_NAME).then(cache => cache.put(request, res.clone()));
return res;
})
)
);
return;
}
// 策略C:图片 → Stale While Revalidate
if (request.destination === 'image') {
e.respondWith(
caches.match(request).then(cached => {
const fetchPromise = fetch(request).then(res => {
caches.open(CACHE_NAME).then(cache => cache.put(request, res.clone()));
return res;
}).catch(() => cached); // 网络失败无所谓,有缓存就行
return cached || fetchPromise;
})
);
}
});
// 4. 后台同步:网络恢复后自动重发失败请求
self.addEventListener('sync', (e) => {
if (e.tag === 'sync-orders') {
e.waitUntil(syncPendingOrders());
}
});
async function syncPendingOrders() {
const db = await openDB('pending-db', 1);
const pending = await db.getAll('orders');
for (const req of pending) {
try {
await fetch('/api/order/complete', {
method: 'POST',
body: JSON.stringify(req.data),
headers: { 'Content-Type': 'application/json' }
});
await db.delete('orders', req.id); // 成功删除
} catch (e) {
// 继续保留,下次再试
}
}
}
前端页面里的配合
javascript
// 骑手点击"确认送达"
async function completeOrder(orderId) {
try {
await fetch('/api/order/complete', {
method: 'POST',
body: JSON.stringify({ orderId })
});
showToast('送达成功');
} catch (err) {
// 网络失败,注册后台同步
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const reg = await navigator.serviceWorker.ready;
// 先存 IndexedDB
await saveToPendingDB({ orderId, time: Date.now() });
await reg.sync.register('sync-orders');
showToast('网络异常,将在恢复后自动同步');
} else {
showToast('提交失败,请检查网络');
}
}
}
实际效果
| 场景 | 无 SW | 有 SW |
|---|---|---|
| 地下室打开订单页 | 白屏/报错 | 显示缓存的订单列表,提示"数据可能不是最新" |
| 电梯里点击"送达" | 提交失败,骑手焦虑 | 提示"已保存,联网后自动同步",骑手继续送下一单 |
| 弱网加载图片 | 长时间转圈 | 先显示模糊缓存图,清晰图后台加载 |
| App 更新 | 用户可能一直用旧版 | 下次打开时静默激活新版 |
另一个你更熟悉的例子:Vue/Vite 文档站
如果你用过 Vite 文档 或 Vue 文档,它们默认就是 PWA:
- 第一次访问后,断网也能看文档
- 用的是
vite-plugin-pwa,底层就是 Service Worker - 更新时右下角会出现"新版本可用,点击刷新"的提示
这就是 "离线阅读"场景 的轻量化应用,比你从零写 SW 简单得多。
总结
骑手端是 "生存刚需" 型 SW 应用------没它业务跑不通;
文档站是 "体验加分" 型 SW 应用------有它更好,没它也能活。
管理后台项目,如果要做技术展示,可以往 "文档站式" 方向靠:加个 PWA 插件,实现离线看页面、安装到桌面。
4、静态文件动态导入
'/index.html', '/static/js/app.js', '/static/css/app.css' 生产环境的文件名带 hash 写法:
实际做法是通过构建工具自动生成清单,SW 里动态读取。
正确做法:构建时注入缓存清单
方案一:Webpack/Vite 插件自动生成(推荐)
Vite 示例(vite-plugin-pwa):
javascript
// vite.config.js
import { VitePWA } from 'vite-plugin-pwa'
export default {
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
// 自动扫描构建产物,不用你写文件名
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
// 运行时缓存 API
runtimeCaching: [
{
urlPattern: /^\/api\/.*/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }
}
}
]
}
})
]
}
构建后自动生成 sw.js,内容大概这样:
javascript
// 自动生成的 sw.js,不是手写的
const assets = [
"/index.html",
"/assets/index-a3f2b1c.js", // 带 hash,构建时自动替换
"/assets/vendor-7d8e9f2.js",
"/assets/style-4b5c6d7.css"
];
// ...
Webpack 用 workbox-webpack-plugin,原理一样。
方案二:构建脚本生成清单文件
如果不方便用插件,自己写一个:
javascript
// scripts/generate-sw-manifest.js
const fs = require('fs');
const path = require('path');
const distDir = path.resolve(__dirname, '../dist');
const files = fs.readdirSync(distDir);
const assets = files
.filter(f => /\.(js|css|html)$/.test(f))
.map(f => `/${f}`);
const swTemplate = fs.readFileSync('./sw-template.js', 'utf8');
const swContent = swTemplate.replace(
'/* ASSETS_PLACEHOLDER */',
JSON.stringify(assets)
);
fs.writeFileSync('./dist/sw.js', swContent);
javascript
// sw-template.js
const PRECACHE = /* ASSETS_PLACEHOLDER */; // 构建时替换为 ["index.html","app.a3f2b1.js",...]
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open('app-v1').then(cache => cache.addAll(PRECACHE))
);
});
package.json 里加一步:
json
"build": "vite build && node scripts/generate-sw-manifest.js"
方案三:运行时从 HTML 解析(兜底)
如果构建产物变了但 SW 没更新,可以运行时动态发现:
javascript
// sw.js
self.addEventListener('install', async (e) => {
const cache = await caches.open('app-v1');
// 先缓存 HTML
await cache.add('/index.html');
// 从缓存的 HTML 里解析出引用的 js/css
const htmlRes = await cache.match('/index.html');
const htmlText = await htmlRes.text();
const assets = [];
// 正则匹配 <script src="/assets/xxx.js"> 和 <link href="/assets/xxx.css">
htmlText.replace(/src="(\/assets\/[^"]+\.js)"/g, (_, url) => assets.push(url));
htmlText.replace(/href="(\/assets\/[^"]+\.css)"/g, (_, url) => assets.push(url));
await cache.addAll(assets);
});
缺点:依赖 HTML 结构,不太稳定,只是兜底思路。
一句话
生产环境 SW,要么用
vite-plugin-pwa/workbox-webpack-plugin自动生成,要么自己写构建脚本注入清单。hash 文件名是构建工具管的,SW 只负责按清单执行缓存策略。
4、 SW 的数据是存储在哪里的?
Service Worker 本身不存储数据,它通过以下独立存储机制存数据:
三种存储方式
| 存储 | API | 存什么 | 容量 | 特点 |
|---|---|---|---|---|
| CacheStorage | caches |
HTTP 请求/响应对(Request/Response) |
通常几十~几百 MB | 专为 SW 设计,匹配请求 URL |
| IndexedDB | indexedDB |
结构化数据(对象、JSON) | 通常几百 MB~几 GB | 事务型数据库,支持索引 |
| localStorage | ❌ 不能用 | --- | --- | SW 是独立线程,无法访问主线程的 localStorage |
具体说明
1. CacheStorage(缓存静态资源)
javascript
// sw.js
const cache = await caches.open('app-v1');
// 存:把 HTTP 响应缓存下来
await cache.add('/api/user'); // 自动发请求,缓存响应
await cache.put(req, response.clone()); // 手动存入
// 取:按请求匹配
const matched = await cache.match('/api/user');
存的是 Response 对象,不是原始数据。适合缓存:
- HTML/JS/CSS 文件
- API 接口返回
- 图片资源
2. IndexedDB(存业务数据)
javascript
// sw.js 里可以用 IndexedDB
const db = await openDB('my-db', 1, {
upgrade(db) {
db.createObjectStore('orders', { keyPath: 'id' });
}
});
// 存
await db.put('orders', { id: 1, status: 'pending', data: {...} });
// 取
const order = await db.get('orders', 1);
适合场景:
- 离线时暂存用户操作(如待提交的表单)
- 大量结构化数据缓存
- 需要查询、索引的业务数据
3. 为什么 localStorage 不能用?
javascript
// sw.js 里这样写会报错
localStorage.setItem('key', 'value'); // ❌ ReferenceError: localStorage is not defined
原因 :Service Worker 运行在独立的 Worker 线程 ,与主页面线程隔离。localStorage 属于主线程的同步 API,SW 访问不到。
存储位置(物理层面)
这些数据最终存在浏览器的用户数据目录:
| 浏览器 | 路径(大致) |
|---|---|
| Chrome | %LocalAppData%\Google\Chrome\User Data\Default\ 下的 IndexedDB/Cache 目录 |
| Edge | 类似 Chrome(同内核) |
| Firefox | %AppData%\Mozilla\Firefox\Profiles\...\ |
开发者工具查看:
- F12 → Application 面板 → Cache Storage / IndexedDB
- 可以手动查看、删除
一句话总结
SW 的数据存在 CacheStorage (缓存 HTTP 响应)和 IndexedDB (存结构化业务数据)里,不能用 localStorage。两者都是浏览器独立的持久化存储,与主页面线程隔离。
5、const cache = await caches.open('app-v1'); 这里的caches,app-v1是什么?分别从哪来的?
caches 和 app-v1 分别是什么
caches --- 全局对象,浏览器内置
javascript
const cache = await caches.open('app-v1');
// ↑ 这个 caches 是浏览器提供的全局变量
| 属性 | 说明 |
|---|---|
| 来源 | 浏览器原生 API,不是你自己定义的,也不是 import 来的 |
| 全称 | CacheStorage 接口的实例 |
| 作用域 | 同源(same-origin)------ 每个域名有自己的独立 caches |
| 可用位置 | Service Worker 线程、主页面线程都能用 |
主页面里也能访问:
javascript
// 普通 JS 里也能操作,但通常交给 SW 管理
const cache = await caches.open('my-cache');
await cache.add('/some.png');
app-v1 --- 你自己起的缓存名称(字符串)
javascript
await caches.open('app-v1');
// ↑ 这个字符串完全由你定义
| 属性 | 说明 |
|---|---|
| 本质 | 缓存仓库的名字,类似数据库名 |
| 作用 | 隔离不同版本的缓存,方便管理和清理 |
| 自定义 | 爱叫什么叫什么,通常带版本号 |
常见命名方式:
javascript
const CACHE_NAME = 'app-v1'; // 简单版本号
const CACHE_NAME = 'app-20240619'; // 日期版本
const CACHE_NAME = 'app-' + __BUILD_HASH__; // 构建哈希(推荐)
完整流程
csharp
浏览器全局对象 caches
↓
caches.open('app-v1') ← 你起的名字
↓
返回一个 Cache 实例(这个"仓库"的操作句柄)
↓
cache.add('/index.html') // 往这个仓库里放东西
cache.match('/index.html') // 从这个仓库里取东西
为什么叫 app-v1 而不是固定名字?
为了更新。
javascript
// 旧版本
const OLD_CACHE = 'app-v1';
// 新版本上线,换名字
const NEW_CACHE = 'app-v2';
// 激活时清理旧缓存
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((names) => {
return Promise.all(
names
.filter((name) => name !== NEW_CACHE) // 只保留当前版本
.map((name) => caches.delete(name)) // 删掉 app-v1 等旧缓存
);
})
);
});
如果不换名字,新 SW 安装后,旧资源还在缓存里,用户永远拿不到更新。
一句话
| 东西 | 哪来的 | 谁控制 |
|---|---|---|
caches |
浏览器原生全局对象 | 浏览器提供 |
'app-v1' |
你自己写的字符串 | 你定义,通常带版本号用于更新 |