Service Worker 跨域资源缓存详解
跨域资源缓存是 Service Worker 中的常见问题,由于浏览器的同源策略(Same-Origin Policy),跨域请求的缓存需要特殊处理。
跨域请求的基本概念
什么是跨域请求
javascript// 同源请求(Same-Origin) // 当前页面: https://example.com/page.html fetch('/api/data'); // ✅ 同源 fetch('https://example.com/api'); // ✅ 同源 // 跨域请求(Cross-Origin) fetch('https://api.other.com/data'); // ❌ 跨域(不同域名) fetch('https://cdn.example.com/file'); // ❌ 跨域(不同子域)
CORS 和 Opaque Response
javascript// CORS 请求 - 服务器允许跨域 fetch('https://api.example.com/data', { mode: 'cors' // 默认模式 }); // 响应包含完整的 headers 和 body // No-CORS 请求 - 服务器不允许跨域 fetch('https://cdn.example.com/image.jpg', { mode: 'no-cors' }); // 响应是 "opaque",无法读取内容,但可以缓存
跨域资源缓存方案
方案1:使用 CORS 模式(推荐)
javascript// sw.js // 服务器需要设置 CORS 头:Access-Control-Allow-Origin: * self.addEventListener('fetch', event => { const url = new URL(event.request.url); // 处理跨域 API 请求 if (url.hostname === 'api.example.com') { event.respondWith( caches.match(event.request).then(response => { if (response) { return response; } // 使用 cors 模式请求 return fetch(event.request, { mode: 'cors' }) .then(networkResponse => { // 检查响应是否成功 if (!networkResponse || networkResponse.status !== 200) { return networkResponse; } // 缓存响应 const responseToCache = networkResponse.clone(); caches.open('api-cache').then(cache => { cache.put(event.request, responseToCache); }); return networkResponse; }) .catch(error => { console.error('跨域请求失败:', error); // 返回离线降级内容 return new Response(JSON.stringify({ error: 'Network error', offline: true }), { headers: { 'Content-Type': 'application/json' } }); }); }) ); } });
方案2:使用 No-CORS 模式缓存 Opaque 响应
javascript// sw.js // 适用于 CDN 资源、图片等不需要读取内容的资源 self.addEventListener('fetch', event => { const url = new URL(event.request.url); // 处理 CDN 图片资源 if (url.hostname === 'cdn.example.com') { event.respondWith( caches.match(event.request).then(response => { if (response) { return response; } // 使用 no-cors 模式 return fetch(event.request, { mode: 'no-cors' }) .then(networkResponse => { // Opaque 响应可以缓存,但无法读取状态和内容 const responseToCache = networkResponse.clone(); caches.open('cdn-cache').then(cache => { cache.put(event.request, responseToCache); }); return networkResponse; }); }) ); } });
Opaque 响应的特点:
- 状态码始终为 0
- 无法读取响应头
- 无法读取响应体
- 占用缓存配额(约是实际大小的 7 倍)
方案3:使用 CORS 代理
javascript// 如果第三方 API 不支持 CORS,可以搭建代理服务器 // sw.js self.addEventListener('fetch', event => { const url = new URL(event.request.url); // 代理跨域请求 if (url.pathname.startsWith('/proxy/')) { const targetUrl = url.pathname.replace('/proxy/', ''); event.respondWith( caches.match(event.request).then(response => { if (response) { return response; } // 通过同域代理转发请求 return fetch(`/api/proxy?url=${encodeURIComponent(targetUrl)}`) .then(networkResponse => { const responseToCache = networkResponse.clone(); caches.open('proxy-cache').then(cache => { cache.put(event.request, responseToCache); }); return networkResponse; }); }) ); } });
方案4:预缓存跨域资源
javascript// sw.js const CROSS_ORIGIN_CACHE = 'cross-origin-v1'; // 需要预缓存的跨域资源 const crossOriginResources = [ 'https://fonts.googleapis.com/css?family=Roboto', 'https://fonts.gstatic.com/s/roboto/v20/font.woff2', 'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js' ]; self.addEventListener('install', event => { event.waitUntil( caches.open(CROSS_ORIGIN_CACHE).then(cache => { // 使用 addAll 预缓存(自动处理 CORS) return cache.addAll(crossOriginResources); }) ); }); self.addEventListener('fetch', event => { const url = new URL(event.request.url); // 匹配跨域资源 if (crossOriginResources.some(resource => event.request.url.includes(resource))) { event.respondWith( caches.match(event.request).then(response => { return response || fetch(event.request); }) ); } });
实际应用示例
示例1:缓存 Google Fonts
javascript// sw.js const FONT_CACHE = 'fonts-v1'; self.addEventListener('fetch', event => { const url = new URL(event.request.url); // Google Fonts CSS(CORS 支持) if (url.hostname === 'fonts.googleapis.com') { event.respondWith( caches.match(event.request).then(response => { return response || fetch(event.request).then(fetchResponse => { return caches.open(FONT_CACHE).then(cache => { cache.put(event.request, fetchResponse.clone()); return fetchResponse; }); }); }) ); } // Google Fonts 字体文件(Opaque 响应) if (url.hostname === 'fonts.gstatic.com') { event.respondWith( caches.match(event.request).then(response => { return response || fetch(event.request, { mode: 'no-cors' }) .then(fetchResponse => { return caches.open(FONT_CACHE).then(cache => { cache.put(event.request, fetchResponse.clone()); return fetchResponse; }); }); }) ); } });
示例2:缓存 CDN 资源
javascript// sw.js const CDN_CACHE = 'cdn-v1'; const CDN_HOSTS = [ 'cdn.jsdelivr.net', 'unpkg.com', 'cdnjs.cloudflare.com' ]; self.addEventListener('fetch', event => { const url = new URL(event.request.url); // 检查是否是 CDN 资源 if (CDN_HOSTS.includes(url.hostname)) { event.respondWith( caches.match(event.request).then(response => { if (response) { return response; } // CDN 资源使用 no-cors 模式 return fetch(event.request, { mode: 'no-cors' }) .then(fetchResponse => { // 只缓存成功的响应 if (fetchResponse.type === 'opaque' || fetchResponse.ok) { const responseToCache = fetchResponse.clone(); caches.open(CDN_CACHE).then(cache => { cache.put(event.request, responseToCache); }); } return fetchResponse; }) .catch(() => { // 离线时返回降级 return new Response('CDN resource unavailable offline'); }); }) ); } });
示例3:处理跨域图片
javascript// sw.js const IMAGE_CACHE = 'images-v1'; self.addEventListener('fetch', event => { const request = event.request; // 处理图片请求 if (request.destination === 'image') { event.respondWith( caches.match(request).then(response => { if (response) { return response; } // 判断是否是跨域请求 const url = new URL(request.url); const isCrossOrigin = url.origin !== location.origin; const fetchOptions = isCrossOrigin ? { mode: 'no-cors' } : {}; return fetch(request, fetchOptions).then(fetchResponse => { // 检查响应是否有效 if (!fetchResponse || fetchResponse.status === 0) { // Opaque 响应或失败 return fetchResponse; } const responseToCache = fetchResponse.clone(); caches.open(IMAGE_CACHE).then(cache => { cache.put(request, responseToCache); }); return fetchResponse; }).catch(() => { // 返回占位图 return caches.match('/images/placeholder.png'); }); }) ); } });
跨域缓存的注意事项
1. Opaque 响应的存储成本
javascript// Opaque 响应的存储成本约为实际大小的 7 倍 // 需要谨慎使用,避免超出存储配额 async function checkOpaqueResponseSize() { const cache = await caches.open('cdn-cache'); const requests = await cache.keys(); let totalSize = 0; for (const request of requests) { const response = await cache.match(request); if (response.type === 'opaque') { // Opaque 响应无法获取实际大小 // 需要预估或限制数量 totalSize += 1024 * 1024; // 假设每个 1MB } } console.log(`预估 Opaque 响应总大小: ${totalSize / 1024 / 1024} MB`); }
2. 缓存清理策略
javascript// 定期清理跨域缓存 async function cleanCrossOriginCache() { const cache = await caches.open('cdn-cache'); const requests = await cache.keys(); // 只保留最近访问的 50 个资源 if (requests.length > 50) { const toDelete = requests.slice(0, requests.length - 50); await Promise.all( toDelete.map(request => cache.delete(request)) ); } } // 在 activate 事件中执行清理 self.addEventListener('activate', event => { event.waitUntil(cleanCrossOriginCache()); });
3. 错误处理
javascript// 跨域请求的错误处理 async function fetchWithFallback(request, isCrossOrigin) { try { const fetchOptions = isCrossOrigin ? { mode: 'no-cors' } : {}; const response = await fetch(request, fetchOptions); // Opaque 响应 status 为 0,需要特殊处理 if (response.type === 'opaque' || response.ok) { return response; } throw new Error(`HTTP ${response.status}`); } catch (error) { console.error('Fetch failed:', error); // 返回缓存或降级响应 const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } // 返回默认响应 return new Response('Resource unavailable', { status: 503 }); } }
最佳实践
- 优先使用 CORS:如果服务器支持 CORS,优先使用 cors 模式
- 限制 Opaque 响应数量:Opaque 响应占用更多存储空间
- 合理设置缓存策略:跨域资源变化频繁时需要设置过期时间
- 提供降级方案:跨域资源加载失败时提供替代内容
- 监控存储使用:定期检查缓存大小,避免超出配额