Appearance
Service Worker离线缓存 - 完整离线策略指南
1. Service Worker基础
1.1 什么是Service Worker
Service Worker是运行在浏览器后台的脚本,独立于网页,提供离线缓存、推送通知、后台同步等功能。
typescript
const serviceWorkerConcepts = {
definition: '独立于主线程的JavaScript工作线程',
characteristics: [
'无法直接访问DOM',
'完全异步,不能使用同步API',
'必须在HTTPS环境下运行(localhost除外)',
'可以拦截和处理网络请求',
'具有自己的生命周期'
],
capabilities: [
'离线缓存',
'网络请求拦截',
'后台同步',
'推送通知',
'资源预取'
],
lifecycle: {
register: '注册',
install: '安装',
activate: '激活',
fetch: '拦截请求',
message: '消息通信'
}
};1.2 Service Worker生命周期
javascript
// Service Worker生命周期
self.addEventListener('install', (event) => {
console.log('Service Worker安装中...');
// 跳过等待,立即激活
self.skipWaiting();
event.waitUntil(
// 预缓存资源
caches.open('v1').then(cache => {
return cache.addAll([
'/',
'/index.html',
'/app.js',
'/app.css'
]);
})
);
});
self.addEventListener('activate', (event) => {
console.log('Service Worker激活中...');
event.waitUntil(
// 清理旧缓存
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== 'v1')
.map(name => caches.delete(name))
);
}).then(() => {
// 立即控制所有客户端
return self.clients.claim();
})
);
});
self.addEventListener('fetch', (event) => {
console.log('拦截请求:', event.request.url);
event.respondWith(
caches.match(event.request)
.then(response => {
return response || fetch(event.request);
})
);
});2. Service Worker注册
2.1 基础注册
javascript
// 注册Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker注册成功:', registration.scope);
})
.catch(error => {
console.error('Service Worker注册失败:', error);
});
});
}
// 指定作用域
navigator.serviceWorker.register('/sw.js', {
scope: '/app/'
}).then(registration => {
console.log('作用域:', registration.scope);
});
// 注册检查
navigator.serviceWorker.getRegistrations().then(registrations => {
console.log('已注册的Service Worker:', registrations);
});
// 注销Service Worker
navigator.serviceWorker.getRegistrations().then(registrations => {
for (const registration of registrations) {
registration.unregister();
}
});2.2 React中的注册
tsx
// registerServiceWorker.ts
export function register(config?: {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
}) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker) {
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// 新内容可用
console.log('新内容可用,请刷新页面');
if (config?.onUpdate) {
config.onUpdate(registration);
}
} else {
// 内容已缓存
console.log('内容已缓存,离线可用');
if (config?.onSuccess) {
config.onSuccess(registration);
}
}
}
};
}
};
})
.catch(error => {
console.error('Service Worker注册失败:', error);
});
});
}
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister();
})
.catch(error => {
console.error(error.message);
});
}
}
// App.tsx
import { useEffect } from 'react';
import * as serviceWorker from './serviceWorker';
function App() {
useEffect(() => {
serviceWorker.register({
onSuccess: () => {
console.log('应用已离线缓存');
},
onUpdate: (registration) => {
console.log('发现新版本');
// 提示用户更新
}
});
}, []);
return <div>App</div>;
}3. 缓存策略
3.1 Cache First(缓存优先)
javascript
// 优先使用缓存,缓存不存在时请求网络
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response; // 返回缓存
}
return fetch(event.request).then(response => {
// 克隆响应,因为响应流只能使用一次
const responseToCache = response.clone();
caches.open('v1').then(cache => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
});
// 适用场景: 静态资源(CSS、JS、图片)3.2 Network First(网络优先)
javascript
// 优先请求网络,失败时使用缓存
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then(response => {
// 更新缓存
const responseToCache = response.clone();
caches.open('v1').then(cache => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
// 网络失败,使用缓存
return caches.match(event.request);
})
);
});
// 适用场景: API数据、动态内容3.3 Stale While Revalidate(后台更新)
javascript
// 立即返回缓存,同时后台更新
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('v1').then(cache => {
return cache.match(event.request).then(cachedResponse => {
const fetchPromise = fetch(event.request).then(networkResponse => {
// 更新缓存
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// 立即返回缓存(如果有),同时后台更新
return cachedResponse || fetchPromise;
});
})
);
});
// 适用场景: 头像、背景图等不紧急更新的资源3.4 Network Only(仅网络)
javascript
// 始终请求网络
self.addEventListener('fetch', (event) => {
event.respondWith(fetch(event.request));
});
// 适用场景: 实时数据、支付接口3.5 Cache Only(仅缓存)
javascript
// 仅使用缓存
self.addEventListener('fetch', (event) => {
event.respondWith(caches.match(event.request));
});
// 适用场景: 已预缓存的静态资源3.6 组合策略
javascript
// 根据请求类型选择策略
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API请求 - 网络优先
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
}
// 静态资源 - 缓存优先
else if (request.destination === 'image' ||
request.destination === 'script' ||
request.destination === 'style') {
event.respondWith(cacheFirst(request));
}
// HTML - 网络优先
else if (request.destination === 'document') {
event.respondWith(networkFirst(request));
}
// 其他 - 后台更新
else {
event.respondWith(staleWhileRevalidate(request));
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
const cache = await caches.open('v1');
cache.put(request, response.clone());
return response;
}
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open('v1');
cache.put(request, response.clone());
return response;
} catch (error) {
const cached = await caches.match(request);
if (cached) return cached;
throw error;
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open('v1');
const cached = await cache.match(request);
const fetchPromise = fetch(request).then(response => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
}4. Workbox工具库
4.1 Workbox简介
bash
# 安装Workbox
npm install workbox-webpack-plugin --save-dev
# 或
npm install workbox-cli -g4.2 Workbox配置
javascript
// webpack.config.js
const WorkboxPlugin = require('workbox-webpack-plugin');
module.exports = {
plugins: [
new WorkboxPlugin.GenerateSW({
clientsClaim: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com/,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 5 * 60 // 5分钟
}
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30天
}
}
}
]
})
]
};
// Vite中使用
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
runtimeCaching: [
{
urlPattern: /^https:\/\/api\./,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache'
}
}
]
}
})
]
};4.3 Workbox策略使用
javascript
// sw.js - 使用Workbox
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.5.4/workbox-sw.js');
const { registerRoute } = workbox.routing;
const { CacheFirst, NetworkFirst, StaleWhileRevalidate } = workbox.strategies;
const { CacheableResponsePlugin } = workbox.cacheableResponse;
const { ExpirationPlugin } = workbox.expiration;
// 缓存图片
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new CacheableResponsePlugin({
statuses: [0, 200]
}),
new ExpirationPlugin({
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30天
})
]
})
);
// API请求
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api',
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 5 * 60 // 5分钟
})
]
})
);
// 静态资源
registerRoute(
({ request }) =>
request.destination === 'script' ||
request.destination === 'style',
new StaleWhileRevalidate({
cacheName: 'static-resources'
})
);5. 高级缓存技术
5.1 预缓存
javascript
// 安装时预缓存
const CACHE_NAME = 'v1';
const urlsToCache = [
'/',
'/index.html',
'/app.js',
'/app.css',
'/logo.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(urlsToCache))
);
});
// Workbox预缓存
workbox.precaching.precacheAndRoute([
{ url: '/index.html', revision: 'abcd1234' },
{ url: '/app.js', revision: 'efgh5678' },
{ url: '/app.css', revision: 'ijkl9012' }
]);5.2 缓存版本控制
javascript
const CACHE_VERSION = 'v2';
const CACHE_NAME = `app-${CACHE_VERSION}`;
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name.startsWith('app-') && name !== CACHE_NAME)
.map(name => caches.delete(name))
);
})
);
});5.3 缓存过期策略
javascript
// 手动实现缓存过期
async function cacheWithExpiration(request, maxAge) {
const cache = await caches.open('v1');
const cached = await cache.match(request);
if (cached) {
const cachedTime = new Date(cached.headers.get('sw-cached-time'));
const now = new Date();
if (now - cachedTime < maxAge) {
return cached;
}
}
const response = await fetch(request);
const clonedResponse = response.clone();
// 添加缓存时间戳
const headers = new Headers(clonedResponse.headers);
headers.set('sw-cached-time', new Date().toISOString());
const responseWithTime = new Response(clonedResponse.body, {
status: clonedResponse.status,
statusText: clonedResponse.statusText,
headers
});
cache.put(request, responseWithTime);
return response;
}
// Workbox过期插件
import { ExpirationPlugin } from 'workbox-expiration';
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
purgeOnQuotaError: true
})
]
});5.4 范围请求支持
javascript
// 支持视频等大文件的范围请求
self.addEventListener('fetch', (event) => {
if (event.request.headers.has('range')) {
event.respondWith(
caches.open('v1').then(cache => {
return cache.match(event.request).then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request).then(response => {
cache.put(event.request, response.clone());
return response;
});
});
})
);
}
});6. 离线页面处理
6.1 离线回退页面
javascript
const OFFLINE_URL = '/offline.html';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v1').then(cache => {
return cache.add(OFFLINE_URL);
})
);
});
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => {
return caches.match(OFFLINE_URL);
})
);
}
});6.2 离线内容展示
javascript
// 离线时展示缓存的内容列表
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.catch(async () => {
const cache = await caches.open('v1');
const cachedResponse = await cache.match(event.request);
if (cachedResponse) {
return cachedResponse;
}
// 返回离线页面
return cache.match('/offline.html');
})
);
}
});7. 通信机制
7.1 页面与Service Worker通信
javascript
// 页面发送消息
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'CACHE_URLS',
urls: ['/page1.html', '/page2.html']
});
}
// Service Worker接收消息
self.addEventListener('message', (event) => {
if (event.data.type === 'CACHE_URLS') {
event.waitUntil(
caches.open('v1').then(cache => {
return cache.addAll(event.data.urls);
})
);
}
// 发送响应
event.ports[0].postMessage({ success: true });
});
// 页面接收响应
navigator.serviceWorker.controller.postMessage(
{ type: 'GET_VERSION' },
[new MessageChannel().port2]
).then(response => {
console.log('版本:', response.version);
});7.2 广播更新
javascript
// Service Worker广播消息给所有客户端
self.addEventListener('activate', (event) => {
event.waitUntil(
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'SW_ACTIVATED',
version: 'v2'
});
});
})
);
});
// 页面接收广播
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'SW_ACTIVATED') {
console.log('Service Worker已激活,版本:', event.data.version);
// 提示用户刷新
}
});8. 调试与测试
8.1 Chrome DevTools
typescript
const debuggingTools = {
application: [
'Service Workers面板',
'查看已注册的SW',
'更新/注销SW',
'离线模式模拟',
'Update on reload'
],
cache: [
'Cache Storage查看',
'查看缓存内容',
'删除缓存',
'刷新缓存'
],
network: [
'查看SW拦截的请求',
'区分from SW的请求',
'模拟慢速网络',
'离线测试'
]
};
// 查看当前Service Worker状态
navigator.serviceWorker.getRegistration().then(registration => {
if (registration) {
console.log('状态:', registration.active?.state);
console.log('作用域:', registration.scope);
}
});8.2 测试用例
typescript
// sw.test.ts
describe('Service Worker', () => {
beforeEach(async () => {
// 清理缓存
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
});
it('should cache resources', async () => {
const cache = await caches.open('v1');
await cache.add('/test.html');
const cached = await cache.match('/test.html');
expect(cached).toBeDefined();
});
it('should serve from cache when offline', async () => {
const cache = await caches.open('v1');
await cache.put('/api/data', new Response(JSON.stringify({ data: 'cached' })));
// 模拟离线
const response = await caches.match('/api/data');
const data = await response?.json();
expect(data).toEqual({ data: 'cached' });
});
});9. 性能优化
9.1 缓存大小限制
javascript
// 检查存储配额
if ('storage' in navigator && 'estimate' in navigator.storage) {
navigator.storage.estimate().then(estimate => {
console.log('已使用:', estimate.usage);
console.log('配额:', estimate.quota);
console.log('使用率:', (estimate.usage / estimate.quota * 100).toFixed(2) + '%');
});
}
// 限制缓存大小
async function limitCacheSize(cacheName, maxItems) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxItems) {
await cache.delete(keys[0]);
return limitCacheSize(cacheName, maxItems);
}
}9.2 选择性缓存
javascript
// 只缓存成功的响应
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request).then(response => {
// 只缓存2xx响应
if (response.status === 200) {
const clone = response.clone();
caches.open('v1').then(cache => {
cache.put(event.request, clone);
});
}
return response;
})
);
});
// 排除某些请求
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// 不缓存API请求
if (url.pathname.startsWith('/api/auth')) {
event.respondWith(fetch(event.request));
return;
}
// 不缓存POST请求
if (event.request.method !== 'GET') {
event.respondWith(fetch(event.request));
return;
}
// 正常缓存策略
event.respondWith(cacheFirst(event.request));
});10. 最佳实践
typescript
const serviceWorkerBestPractices = {
strategy: [
'根据内容类型选择合适的缓存策略',
'静态资源使用Cache First',
'API数据使用Network First',
'图片使用Stale While Revalidate',
'实时数据不缓存'
],
caching: [
'合理设置缓存过期时间',
'限制缓存大小',
'版本化缓存',
'及时清理旧缓存',
'预缓存关键资源'
],
updates: [
'提供更新提示',
'支持跳过等待',
'优雅的更新流程',
'避免破坏性更新',
'版本回滚机制'
],
performance: [
'避免缓存大文件',
'使用压缩响应',
'选择性缓存',
'监控缓存性能',
'定期清理'
],
security: [
'仅在HTTPS下运行',
'验证缓存内容',
'避免缓存敏感信息',
'设置合适的作用域',
'定期更新'
]
};11. 总结
Service Worker离线缓存的核心要点:
- 生命周期: install, activate, fetch
- 缓存策略: Cache First, Network First, Stale While Revalidate
- Workbox: 简化Service Worker开发
- 版本管理: 缓存版本控制和清理
- 离线体验: 离线页面和内容展示
- 通信机制: 页面与SW双向通信
- 性能优化: 缓存大小限制和选择性缓存
通过正确实施Service Worker,可以提供流畅的离线体验。