服务端阅读 05月27日 16:10
Service Worker 生命周期有哪些阶段,如何实现离线缓存?
Service Worker 是浏览器在后台独立运行的脚本,充当页面与网络之间的代理。它最大的价值在于:即使页面关闭也能拦截请求、管理缓存,从而实现离线访问、推送通知和后台同步。本文从注册到激活,逐阶段讲清它的生命周期,并给出离线缓存的完整实现和五种缓存策略的适用场景。注册 Service Worker注册是生命周期的起点。在主线程中调用 navigator.serviceWorker.register(),浏览器会下载并解析 Service Worker 脚本:if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/service-worker.js', { scope: '/' }) .then(registration => { console.log('注册成功,作用域:', registration.scope); }) .catch(error => { console.log('注册失败:', error); });}scope 参数决定了 Service Worker 能拦截哪些页面请求,默认值是脚本所在目录。注册成功后,浏览器会在后台启动安装流程。注意:Service Worker 必须在 HTTPS 环境下运行(localhost 除外),这是浏览器强制的安全要求。生命周期的六个阶段Service Worker 的生命周期独立于网页,从注册到废弃共经历六个状态:Parsed(已解析)——浏览器下载并解析脚本,尚未安装Installing(安装中)——执行 install 事件回调,通常用于预缓存资源Installed / Waiting(已安装,等待激活)——安装成功,等待旧版本释放控制权Activating(激活中)——执行 activate 事件回调,通常用于清理旧缓存Activated(已激活)——完全就绪,可以拦截 fetch 请求Redundant(废弃)——被新版本替换或安装失败,不再生效Install 阶段:预缓存关键资源install 事件在注册后首次触发,且只触发一次。这是预缓存核心静态资源的最佳时机:const CACHE_NAME = 'app-cache-v1';const PRECACHE_URLS = [ '/', '/styles/main.css', '/script/main.js', '/images/logo.png'];self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME) .then(cache => cache.addAll(PRECACHE_URLS)) .then(() => self.skipWaiting()) );});event.waitUntil() 接收一个 Promise,浏览器会等到它 resolve 后才认为安装完成。如果 Promise reject,安装失败,Service Worker 进入 Redundant 状态。self.skipWaiting() 的作用是跳过 Waiting 阶段,让新的 Service Worker 立即激活。这在需要快速上线的场景很有用,但要注意:如果旧页面还在运行,新旧缓存可能冲突。Activate 阶段:清理旧缓存activate 事件在新 Service Worker 取得控制权后触发。主要用途是删除上一版遗留的缓存:self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames .filter(name => name !== CACHE_NAME) .map(name => { console.log('删除旧缓存:', name); return caches.delete(name); }) ); }).then(() => self.clients.claim()) );});self.clients.claim() 让新的 Service Worker 立即控制所有页面,而不需要页面刷新。配合 skipWaiting() 使用可以实现"注册即生效"。Waiting 阶段的更新机制当页面已经有一个活跃的 Service Worker 时,浏览器检测到脚本文件变化(逐字节比较)后会启动新的安装。但新版本安装成功后不会立即激活,而是进入 Waiting 状态,直到:所有使用旧版本的页面标签页被关闭或调用 skipWaiting() 强制跳过等待这意味着:如果用户长时间不关闭标签页,新版本可能一直处于等待状态。实践中可以通过 controllerchange 事件提示用户刷新:navigator.serviceWorker.addEventListener('controllerchange', () => { console.log('Service Worker 已更新,页面将刷新'); window.location.reload();});Fetch 事件:拦截与缓存请求Service Worker 激活后,所有匹配 scope 的网络请求都会触发 fetch 事件。最基础的拦截逻辑——缓存命中就返回,否则走网络并缓存响应:self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request) .then(cachedResponse => { if (cachedResponse) { return cachedResponse; } return fetch(event.request).then(networkResponse => { if (!networkResponse || networkResponse.status !== 200 || networkResponse.type !== 'basic') { return networkResponse; } const responseToCache = networkResponse.clone(); caches.open(CACHE_NAME).then(cache => { cache.put(event.request, responseToCache); }); return networkResponse; }); }) );});event.respondWith() 必须在 fetch 事件中调用,它接收一个 Promise,浏览器会用 resolve 的 Response 替代原始网络响应。注意 response.clone() 的使用:Response 对象是流式的,只能读取一次,必须克隆一份再存入缓存。五种缓存策略及适用场景不同的资源类型需要不同的缓存策略,以下是五种常用模式的代码和适用场景:Cache First(缓存优先)优先读缓存,缓存未命中才走网络。适合不常变化的静态资源(字体、图片、CSS 框架):self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request) .then(response => response || fetch(event.request)) );});Network First(网络优先)优先走网络,网络失败再读缓存。适合需要最新数据但也要离线可用的页面(新闻列表、用户信息):self.addEventListener('fetch', event => { event.respondWith( fetch(event.request) .then(response => { const clone = response.clone(); caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); return response; }) .catch(() => caches.match(event.request)) );});Stale While Revalidate(先用缓存,后台更新)立即返回缓存(如果有的话),同时发起网络请求更新缓存。用户拿到的是可能过时的数据,但响应最快。适合对实时性要求不高但追求速度的场景(文章内容、配置信息):self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request).then(cachedResponse => { const fetchPromise = fetch(event.request).then(networkResponse => { caches.open(CACHE_NAME).then(cache => { cache.put(event.request, networkResponse.clone()); }); return networkResponse; }); return cachedResponse || fetchPromise; }) );});Cache Only(仅缓存)只从缓存读取,不发起网络请求。适合预缓存的离线页面(App Shell):self.addEventListener('fetch', event => { event.respondWith(caches.match(event.request));});Network Only(仅网络)只走网络,不使用缓存。适合非 GET 请求或实时性要求极高的接口(支付、登录):self.addEventListener('fetch', event => { event.respondWith(fetch(event.request));});推送通知Service Worker 可以在页面关闭后接收推送消息,这是 PWA 的核心能力之一。订阅推送主线程中注册并订阅推送服务:navigator.serviceWorker.register('/service-worker.js').then(registration => { return registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array('YOUR_PUBLIC_KEY') });}).then(subscription => { // 将 subscription 发送到后端保存 console.log('推送订阅成功:', subscription);});applicationServerKey 是 VAPID 公钥,用于服务器向推送服务认证身份。处理推送事件Service Worker 中监听 push 事件并显示通知:self.addEventListener('push', event => { const data = event.data ? event.data.json() : { title: '新消息', body: '' }; event.waitUntil( self.registration.showNotification(data.title, { body: data.body, icon: '/images/icon.png', badge: '/images/badge.png', vibrate: [100, 50, 100] }) );});self.addEventListener('notificationclick', event => { event.notification.close(); event.waitUntil(clients.openWindow('/'));});后台同步Background Sync API 让用户在网络恢复时自动重试失败的请求,即使页面已经关闭:// 主线程:注册同步任务navigator.serviceWorker.ready.then(registration => { registration.sync.register('sync-messages');});// service-worker.js:处理同步self.addEventListener('sync', event => { if (event.tag === 'sync-messages') { event.waitUntil(syncMessages()); }});async function syncMessages() { const messages = getPendingMessages(); await fetch('/api/sync-messages', { method: 'POST', body: JSON.stringify(messages) });}调试与更新Chrome DevTools 的 Application 面板可以查看 Service Worker 状态:打开 DevTools -> Application -> Service Workers,能看到当前注册的 Service Worker 及其状态,支持手动更新、注销和跳过等待。手动触发更新检测:navigator.serviceWorker.ready.then(registration => { registration.update();});浏览器也会定期检查 Service Worker 脚本更新(默认 24 小时),但实际项目中通常需要在代码变更后尽快让用户获取新版本。实践要点缓存版本管理:每次发布修改 CACHE_NAME 的版本号,确保 activate 阶段能清理旧缓存渐进增强:所有功能都要做特性检测(if ('serviceWorker' in navigator)),不支持时优雅降级响应克隆:Response 是流式对象,缓存前必须 clone(),否则原始响应被消费后无法再读取HTTPS 强制:生产环境必须部署 HTTPS,开发时 localhost 可用scope 限制:Service Worker 只能拦截 scope 范围内的请求,默认是脚本所在目录及其子路径更新策略:合理选择 skipWaiting + clients.claim 的组合,避免新旧缓存冲突导致页面异常