astro bun Deno logo
Skip to content
Go back

使用Service Worker实现资源缓存加速

Edit page

在开发 Web 应用时,某些体积大且不易变化的资源:

我们可以使用Service Worker来缓存这些文件。

Service Worker工作流程

Service Worker 是运行在浏览器后台的 JavaScript 脚本,它独立于网页。

Service Worker

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;
    }
}

示例


Edit page
Share this post on:

Next Post
为Markdown文档添加 Python 在线运行功能
Code_You