在开发 Web 应用时,某些体积大且不易变化的资源:
- UI 框架(React, Vue 完整版)
- 数据可视化库(D3.js, Three.js 等)
- WebAssembly 模块
我们可以使用Service Worker来缓存这些文件。
Service Worker工作流程
Service Worker 是运行在浏览器后台的 JavaScript 脚本,它独立于网页。
Service Worker 生命周期
1. 注册(Register)
↓
navigator.serviceWorker.register('/sw.js')
2. 安装(Install)
↓
install 事件触发
├── 可选:预缓存资源
└── self.skipWaiting() - 跳过等待
3. 激活(Activate)
↓
activate 事件触发
├── 清理旧缓存
└── self.clients.claim() - 立即接管页面
4. 工作(Fetch)
↓
fetch 事件触发
└── 拦截请求,返回缓存或网络资源
5. 更新(Update)
↓
检测到 sw.js 变化时重新安装
sw.js完整实现
// 缓存策略:Cache First (缓存优先,适合不经常变化的大文件)
const CACHE_NAME = 'cache-v1';
// 需要缓存的资源 列表(可以是域名或 URL 模式)
const CACHE_PATTERNS = [
'cdn.jsdelivr.net/npm/pyodide',
// 可以添加更多资源,例如:
// 'unpkg.com',
// 'cdnjs.cloudflare.com',
// 'cdn.bootcdn.net',
];
// 安装 Service Worker
self.addEventListener('install', (event) => {
console.log('[SW] Service Worker: Installing...');
// 跳过等待,立即激活
self.skipWaiting();
// 不预缓存,等首次使用时再缓存,避免首次加载过慢
event.waitUntil(Promise.resolve());
});
// 激活 Service Worker
self.addEventListener('activate', (event) => {
console.log('[SW] Service Worker: Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
// 清理旧缓存
if (cacheName !== CACHE_NAME) {
console.log('[SW] Clearing old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
}).then(() => {
console.log('[SW] Service Worker activated and ready!');
// 立即接管所有页面
return self.clients.claim();
})
);
});
// 拦截网络请求 - Cache First 策略(优先使用缓存)
self.addEventListener('fetch', (event) => {
const url = event.request.url;
const shouldCache = CACHE_PATTERNS.some(pattern => url.includes(pattern));
if (shouldCache) {
console.log('[SW] 🎯 拦截请求:', url.split('/').pop(), '| 类型:', event.request.destination || 'unknown');
event.respondWith(
(async () => {
try {
const cache = await caches.open(CACHE_NAME);
// 使用 ignoreSearch: true 来忽略 URL 参数差异
// 使用 ignoreVary: true 来忽略 Vary 头部差异
const cacheOptions = {
ignoreSearch: true,
ignoreVary: true
};
// 先查询缓存
let cachedResponse = await cache.match(event.request, cacheOptions);
// 如果还是找不到,尝试用 URL 字符串直接匹配
if (!cachedResponse) {
cachedResponse = await cache.match(url, cacheOptions);
}
if (cachedResponse) {
// 有缓存,立即返回
console.log('[SW] ✓ 从缓存返回:', url.split('/').pop());
return cachedResponse;
}
// 无缓存,从网络获取
console.log('[SW] ⬇ 从网络下载:', url.split('/').pop());
const response = await fetch(event.request);
// 检查响应是否有效
// 允许缓存:status 200-299 或 opaque response (status 0)
const shouldCache = response && (
(response.status >= 200 && response.status < 300) ||
response.type === 'opaque'
);
if (shouldCache) {
// 克隆响应并缓存
const responseToCache = response.clone();
try {
// 使用 URL 作为缓存键,更稳定
await cache.put(url, responseToCache);
console.log('[SW] ✓ 已缓存:', url.split('/').pop(),
'| 类型:', response.type,
'| 状态:', response.status);
// 验证是否真的缓存成功
const verify = await cache.match(url, cacheOptions);
if (verify) {
console.log('[SW] ✓ 缓存验证成功:', url.split('/').pop());
} else {
console.error('[SW] ✗ 缓存验证失败:', url.split('/').pop());
}
} catch (cacheError) {
console.error('[SW] ✗ 缓存失败:', url.split('/').pop(), cacheError);
}
} else {
console.warn('[SW] ⚠ 响应无效,未缓存:', url.split('/').pop(),
'| 状态:', response?.status,
'| 类型:', response?.type);
}
return response;
} catch (error) {
console.error('[SW] ✗ 请求失败:', error);
// 尝试返回缓存(离线情况)
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
console.log('[SW] ✓ 使用离线缓存:', url.split('/').pop());
return cachedResponse;
}
throw error;
}
})()
);
}
});
// 监听消息,支持手动清理缓存
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'CLEAR_CACHE') {
event.waitUntil(
caches.delete(CACHE_NAME).then(() => {
console.log('Service Worker: Cache cleared');
event.ports[0].postMessage({ success: true });
})
);
}
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});sw.js
在项目中使用sw.js
async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
console.warn('[Main] 当前浏览器不支持 Service Worker');
return false;
}
try {
// 先测试 sw.js 是否可访问
const swPath = '/sw.js';
try {
const testResponse = await fetch(swPath, { method: 'HEAD' });
if (!testResponse.ok) {
console.error('[Main] ✗ 无法访问 sw.js 文件,HTTP 状态:', testResponse.status);
console.error('[Main] 请确保 sw.js 在网站根目录');
return false;
}
} catch (fetchError) {
console.error('[Main] ✗ 无法访问 sw.js 文件:', fetchError.message);
console.error('[Main] 请在浏览器中访问', window.location.origin + swPath, '检查文件是否存在');
return false;
}
console.log('[Main] ⏳ 正在注册 Service Worker...');
const registration = await navigator.serviceWorker.register(swPath, {
scope: '/'
});
console.log('[Main] ✓ Service Worker 注册成功:', registration);
// 等待 Service Worker 激活
await navigator.serviceWorker.ready;
console.log('[Main] ✓ Service Worker 已就绪');
// 检查是否已经有 controller(页面是否被 Service Worker 接管)
if (navigator.serviceWorker.controller) {
this.serviceWorkerReady = true;
console.log('[Main] ✓ Service Worker 已接管页面,资源将从缓存加载');
} else {
// 首次访问,Service Worker 尚未接管页面
// 这是正常的,需要刷新页面后才能启用缓存
this.serviceWorkerReady = false;
console.log('[Main] ℹ Service Worker 已注册但未接管当前页面');
}
return true;
} catch (error) {
console.error('[Main] ✗ Service Worker 注册失败:', error);
return false;
}
}main.js
清理缓存
async function clearCache() {
try {
const registration = await navigator.serviceWorker.ready;
const messageChannel = new MessageChannel();
return new Promise((resolve) => {
messageChannel.port1.onmessage = (event) => {
if (event.data.success) {
console.log('✓ CDN 缓存已清理');
resolve(true);
}
};
registration.active.postMessage(
{ type: 'CLEAR_CACHE' },
[messageChannel.port2]
);
});
} catch (error) {
console.error('清理缓存失败:', error);
return false;
}
}
查看缓存的资源
async getCacheInfo() {
try {
const cache = await caches.open('cache-v1'); // 注意这里的要与sw.js的CACHE_NAME一致
const keys = await cache.keys();
console.log('=== Pyodide 缓存状态 ===');
console.log(`共缓存 ${keys.length} 个文件:`);
let totalSize = 0;
let opaqueCount = 0;
for (const request of keys) {
const response = await cache.match(request);
if (response) {
const blob = await response.blob();
const size = blob.size;
totalSize += size;
// 根据文件大小选择合适的单位
let sizeStr;
if (size === 0) {
// 对于 0 字节,尝试从 Content-Length 获取真实大小
const contentLength = response.headers.get('content-length');
if (contentLength) {
const realSize = parseInt(contentLength);
if (realSize > 0) {
if (realSize < 1024) {
sizeStr = `${realSize} 字节 (opaque)`;
} else if (realSize < 1024 * 1024) {
sizeStr = `${(realSize / 1024).toFixed(2)} KB (opaque)`;
} else {
sizeStr = `${(realSize / 1024 / 1024).toFixed(2)} MB (opaque)`;
}
} else {
sizeStr = '0 字节 ⚠️';
}
} else {
// 无法获取大小,可能是 opaque 响应(跨域请求的正常现象)
sizeStr = `已缓存 ✓ (opaque)`;
opaqueCount++;
}
} else if (size < 1024) {
sizeStr = `${size} 字节`; // 小于1KB显示字节
} else if (size < 1024 * 1024) {
sizeStr = `${(size / 1024).toFixed(2)} KB`; // 小于1MB显示KB
} else {
sizeStr = `${(size / 1024 / 1024).toFixed(2)} MB`; // 大于1MB显示MB
}
console.log(` ✓ ${request.url.split('/').pop()} (${sizeStr})`);
}
}
console.log(`总大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB${opaqueCount > 0 ? ` (不含 ${opaqueCount} 个 opaque 文件)` : ''}`);
console.log('========================');
return {
count: keys.length,
totalSize: totalSize,
files: keys.map(k => k.url)
};
} catch (error) {
console.error('获取缓存信息失败:', error);
return null;
}
}
示例
