Service Worker 详解
【面试速答版】
Q1: Service Worker 是什么?它和 Web Worker 有什么区别?
Service Worker 是浏览器在后台独立于网页运行的脚本,本质是一个网络请求的代理服务器。它可以拦截、修改、缓存页面的所有网络请求,实现离线访问、消息推送、后台同步等功能。
| 维度 | Service Worker | Web Worker |
|---|
| 目的 | 拦截网络请求、管理缓存 | 执行 CPU 密集型计算 |
| 生命周期 | 独立于页面,安装后可长期存在 | 随页面关闭而销毁 |
| 访问 DOM | ❌ 不能 | ❌ 不能 |
| 访问网络 | ✅ 可以拦截请求 | ❌ 只能通过 fetch |
| 与页面通信 | postMessage | postMessage |
| 持久化 | 可安装、激活、更新 | 无持久化概念 |
| 使用 HTTPS | ✅ 必须 | ❌ 不需要 |
核心区别:Web Worker 是「计算线程」,Service Worker 是「网络代理」。
Q2: Service Worker 的生命周期有哪些阶段?
Service Worker 的生命周期完全独立于网页,包含以下阶段:
1
| 注册 → 安装(install) → 等待(waiting) → 激活(activate) → 运行(fetch/message/...) → 更新/终止
|
注册(Registration):调用 navigator.serviceWorker.register('/sw.js'),浏览器下载并解析脚本。
安装(install 事件):首次安装或检测到脚本有字节差异时触发。通常在此事件中预缓存静态资源。
等待(waiting):如果已有一个激活的 SW,新的 SW 会进入 waiting 状态,直到所有页面关闭或调用 skipWaiting()。
激活(activate 事件):SW 开始接管页面。通常在此事件中清理旧缓存。
运行(fetch/message 等事件):SW 处于激活状态后,拦截网络请求、接收消息。
更新:每次导航到页面时,浏览器会重新下载 SW 脚本。如果内容有变化,新 SW 进入 install → waiting → activate 循环。
终止:浏览器为了节省资源,会在 SW 空闲一段时间后终止它。下次有事件时重新启动。
Q3: Service Worker 常用的缓存策略有哪些?
| 策略 | 适用场景 | 行为 |
|---|
| Cache First | 静态资源(JS/CSS/图片) | 有缓存直接返回,无缓存则网络请求并缓存 |
| Network First | API 请求、最新内容 | 先网络请求,超时或失败则用缓存 |
| Stale While Revalidate | 非关键但需要新鲜度 | 立即返回缓存,同时后台更新缓存下次用 |
| Network Only | 表单提交、支付 | 始终走网络,不使用缓存 |
| Cache Only | 预缓存的应用 Shell | 只从缓存读取,从不请求网络 |
开发中最常用:Stale While Revalidate。用户获得即时响应(缓存 -> 屏幕),同时后台悄悄更新缓存,下次响应就是最新的。
【深入理解版】
1. Service Worker 要解决什么问题?
1.1 传统 Web 应用的离线困境
假设你正在做一个笔记应用,用户在地铁上打开网页时信号很差:
1 2 3 4 5 6 7
| 传统 Web 应用的加载流程: 1. 浏览器请求 HTML → ❌ 无网络 → 白屏 2. 浏览器请求 CSS → ❌ 无网络 → 白屏 3. 浏览器请求 JS → ❌ 无网络 → 白屏 4. 浏览器请求 API → ❌ 无网络 → 无数据
用户看见:白屏 + 「无法连接到互联网」
|
这暴露了传统 Web 模型的根本问题:网络是现代 Web 应用的唯一依赖。没有网络,一切归零。
有几种「伪离线」方案,但都不完美:
方案 1:浏览器 HTTP 缓存
1 2 3 4 5
| Cache-Control: public, max-age=3600
|
方案 2:Application Cache(AppCache)
1 2 3 4 5 6
| <html manifest="app.appcache">
</html>
|
方案 3:LocalStorage / IndexedDB
1 2 3 4
| localStorage.setItem('data', JSON.stringify(data))
|
Service Worker 的解决思路:在浏览器和网络之间插入一个可编程的代理层。
1 2 3 4 5 6 7 8 9 10 11 12 13
| 传统模式: 浏览器 ←→ 网络
Service Worker 模式: 浏览器 ←→ Service Worker ←→ 网络 ↓ 缓存(Cache Storage)
SW 可以决定: - 请求直接走缓存(不上网) - 先走缓存,后台更新网络结果 - 先走网络,失败时用缓存兜底 - 甚至完全不使用网络(完全离线应用)
|
1.2 Service Worker 不是什么
在深入之前,先澄清几个常见的误解:
Service Worker 不是:
- 不是页面的一部分:SW 运行在独立的上下文(
ServiceWorkerGlobalScope)中,不能访问 window、document、DOM - 不是持久进程:浏览器会在 SW 空闲时终止它,下次有事件时重新启动。不能假设 SW 会一直运行
- 不是 localStorage 替代品:SW 使用
Cache Storage API 缓存网络请求,用 IndexedDB 存结构化数据 - 不是所有浏览器都支持:IE 不支持、部分低版本移动浏览器不支持
一个 SW 可以控制多个页面,但每个域名下的 SW 是独立的。
2. 核心原理与执行过程
2.1 前提条件与注册
Service Worker 有三个硬性前提:
- HTTPS 必须(或 localhost 用于开发)
- 同源策略:SW 只能控制同源页面
- 浏览器支持:需要
navigator.serviceWorker 存在
1 2 3 4 5 6 7 8 9 10 11 12
| if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js', { scope: '/' }) .then(registration => { console.log('SW 注册成功, 状态:', registration.active ? '已激活' : '未激活') }) .catch(err => { console.error('SW 注册失败:', err) }) }
|
scope 的作用:
1 2 3 4 5 6 7 8 9
| navigator.serviceWorker.register('/app/sw.js')
navigator.serviceWorker.register('/app/sw.js', { scope: '/' })
|
注册后的内部流程:
1 2 3 4 5 6
| 1. 浏览器下载 /sw.js 2. 与已有的 SW 比较字节内容 ├── 首次注册 → 直接进入 install └── 已注册且内容无变化 → 跳过,使用已有 SW └── 已注册但内容变化 → 新的 SW 进入 install(等待激活) 3. 注册返回 ServiceWorkerRegistration 对象
|
2.2 install 事件:预缓存
install 事件在 SW 首次安装或更新时触发,且只触发一次。这是做预缓存的时机——在用户还没访问任何页面之前,先把关键资源缓存起来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| const CACHE_NAME = 'my-app-v1' const PRECACHE_URLS = [ '/', '/index.html', '/styles/main.css', '/scripts/app.js', '/images/logo.png', '/offline.html', ]
self.addEventListener('install', (event) => { console.log('SW: install 事件触发') event.waitUntil( caches.open(CACHE_NAME) .then(cache => { console.log('SW: 预缓存资源') return cache.addAll(PRECACHE_URLS) }) .then(() => { return self.skipWaiting() }) ) })
|
event.waitUntil() 的关键作用:它延长 install/activate 事件的存活时间,直到传入的 Promise 完成。如果 Promise reject,浏览器认为安装失败,该 SW 被丢弃。
self.skipWaiting() 的作用:让处于 waiting 状态的新 SW 立即激活,不等所有页面关闭。
2.3 activate 事件:清理旧缓存
activate 事件在新 SW 激活时触发。通常在这里清理旧版本的缓存,避免磁盘空间浪费。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| self.addEventListener('activate', (event) => { console.log('SW: activate 事件触发') const cacheWhitelist = [CACHE_NAME] event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => { if (!cacheWhitelist.includes(cacheName)) { console.log('SW: 删除旧缓存', cacheName) return caches.delete(cacheName) } }) ) }).then(() => { return self.clients.claim() }) ) })
|
self.clients.claim() 的作用:新 SW 激活后,默认只控制激活后打开的页面。已有的页面依然被旧 SW 控制(直到页面刷新)。claim() 让新 SW 立即控制所有页面。
为什么需要版本化缓存名:
1 2 3
| const CACHE_NAME = 'my-app-v1'
|
2.4 fetch 事件:请求拦截与缓存策略
fetch 事件是 Service Worker 的核心——每次页面发出网络请求时,都会先经过 SW。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| self.addEventListener('fetch', (event) => { console.log('SW: 拦截请求', event.request.url) event.respondWith( fromCache(event.request) .then(cachedResponse => { const fetchPromise = fromNetwork(event.request) .then(networkResponse => { updateCache(event.request, networkResponse.clone()) return networkResponse }) .catch(() => cachedResponse) return cachedResponse || fetchPromise }) ) })
function fromCache(request) { return caches.match(request).then(response => { return response || Promise.reject('no-match') }) }
function fromNetwork(request) { return fetch(request).then(response => { if (!response || response.status !== 200) { return response } return response }) }
function updateCache(request, response) { if (response && response.status === 200) { const clone = response.clone() caches.open(CACHE_NAME).then(cache => { cache.put(request, clone) }) } }
|
event.respondWith() 的关键作用:它告诉浏览器「这个请求由我来响应」。必须同步调用(在 event 回调内部),不能异步调用。
Response 只能消费一次:调用 caches.put() 或 response.text() 后,Response body 被消耗。如果需要在多处使用,必须先 .clone()。
2.5 完整的缓存策略实现
Cache First(缓存优先):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| self.addEventListener('fetch', (event) => { if (isStaticResource(event.request.url)) { event.respondWith( caches.match(event.request).then(cached => { return cached || fetch(event.request).then(response => { return cacheResponse(event.request, response) }) }) ) } })
function isStaticResource(url) { const extensions = ['.js', '.css', '.png', '.jpg', '.svg', '.woff2', '.ttf'] return extensions.some(ext => url.includes(ext)) }
|
Network First(网络优先):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| self.addEventListener('fetch', (event) => { if (event.request.url.includes('/api/')) { event.respondWith( fetch(event.request) .then(response => { return cacheResponse(event.request, response.clone()) }) .catch(() => { return caches.match(event.request).then(cached => { if (cached) return cached return caches.match('/offline.html') }) }) ) } })
|
Network Only(仅网络):
1 2 3 4 5 6 7 8
| self.addEventListener('fetch', (event) => { if (event.request.method === 'POST' || event.request.url.includes('/api/payment')) { event.respondWith(fetch(event.request)) } })
|
Cache Only(仅缓存):
1 2 3 4 5 6 7 8 9 10 11
| self.addEventListener('fetch', (event) => { if (event.request.url.includes('/shell/')) { event.respondWith( caches.match(event.request).then(cached => { return cached || new Response('', { status: 404 }) }) ) } })
|
2.6 message 事件:与页面通信
SW 和页面之间通过 postMessage 通信:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| self.addEventListener('message', (event) => { console.log('SW 收到来自页面的消息:', event.data) if (event.data.type === 'SKIP_WAITING') { self.skipWaiting() } if (event.data.type === 'CACHE_URLS') { event.waitUntil( caches.open(CACHE_NAME).then(cache => { return cache.addAll(event.data.urls) }) ) } event.source.postMessage({ type: 'ACK', received: event.data }) })
if (navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage({ type: 'CACHE_URLS', urls: ['/api/data', '/images/new.png'] }) }
navigator.serviceWorker.addEventListener('message', (event) => { console.log('页面收到来自 SW 的消息:', event.data) })
|
2.7 Service Worker 的更新机制
SW 的更新是自动检查、手动控制激活时机的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| let swRegistration
if ('serviceWorker' in navigator) { swRegistration = navigator.serviceWorker.register('/sw.js') swRegistration.addEventListener('updatefound', () => { const newSW = swRegistration.installing console.log('发现新版本的 SW') newSW.addEventListener('statechange', () => { if (newSW.state === 'installed' && swRegistration.active) { showUpdateNotification() } }) }) }
function applyUpdate() { if (swRegistration && swRegistration.waiting) { swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' }) } }
self.addEventListener('message', (event) => { if (event.data.type === 'SKIP_WAITING') { self.skipWaiting() } })
navigator.serviceWorker.addEventListener('controllerchange', () => { console.log('新 SW 已激活,刷新页面使用新版本') window.location.reload() })
|
更新检查的触发时机:
- 用户导航到 SW scope 内的页面(每次)
- 调用
registration.update() - 浏览器每 24 小时自动检查一次
3. 实际应用场景与代码示例
3.1 场景 1:全栈离线笔记应用
Step 1:注册 SW
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| async function registerSW() { if (!('serviceWorker' in navigator)) { console.log('当前浏览器不支持 Service Worker') return } try { const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' }) console.log('SW 注册成功:', registration.scope) registration.addEventListener('updatefound', () => { const newWorker = registration.installing newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && registration.active) { showUpdateBanner() } }) }) } catch (err) { console.error('SW 注册失败:', err) } }
registerSW()
|
Step 2:SW 缓存策略
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
| const CACHE_NAME = 'notes-app-v1' const STATIC_CACHE = 'notes-static-v1'
const PRECACHE_URLS = [ '/', '/index.html', '/styles/app.css', '/scripts/app.js', '/offline.html', '/icons/icon-192.png', ]
self.addEventListener('install', (event) => { event.waitUntil( (async () => { const cache = await caches.open(STATIC_CACHE) await cache.addAll(PRECACHE_URLS) await self.skipWaiting() })() ) })
self.addEventListener('activate', (event) => { event.waitUntil( (async () => { const cacheNames = await caches.keys() await Promise.all( cacheNames.map(name => { if (name !== STATIC_CACHE && name !== CACHE_NAME) { return caches.delete(name) } }) ) await self.clients.claim() })() ) })
self.addEventListener('fetch', (event) => { const { request } = event const url = new URL(request.url) if (isStaticAsset(url.pathname)) { event.respondWith(cacheFirst(request)) } else if (url.pathname.startsWith('/api/notes')) { event.respondWith(networkFirst(request)) } else if (request.method === 'POST' && url.pathname === '/api/notes/sync') { event.respondWith(networkWithQueue(request)) } else { event.respondWith(staleWhileRevalidate(request)) } })
async function cacheFirst(request) { const cached = await caches.match(request) if (cached) return cached try { const response = await fetch(request) if (response.ok) { const cache = await caches.open(STATIC_CACHE) cache.put(request, response.clone()) } return response } catch (err) { return caches.match('/offline.html') } }
async function networkFirst(request) { try { const response = await fetch(request) if (response.ok) { const cache = await caches.open(CACHE_NAME) cache.put(request, response.clone()) } return response } catch (err) { const cached = await caches.match(request) if (cached) return cached return new Response( JSON.stringify({ error: '离线模式,数据可能不是最新的' }), { status: 503, headers: { 'Content-Type': 'application/json' } } ) } }
async function staleWhileRevalidate(request) { const cache = await caches.open(CACHE_NAME) const cached = await cache.match(request) const fetchPromise = fetch(request).then(response => { if (response.ok) { cache.put(request, response.clone()) } return response }).catch(() => cached) return cached || fetchPromise }
async function networkWithQueue(request) { try { return await fetch(request) } catch (err) { await saveToSyncQueue(request.clone()) return new Response( JSON.stringify({ queued: true, message: '将在恢复连接后同步' }), { headers: { 'Content-Type': 'application/json' } } ) } }
async function saveToSyncQueue(request) { const db = await openDB('sync-queue', 1, { upgrade(db) { db.createObjectStore('requests', { autoIncrement: true }) } }) const body = await request.text() const tx = db.transaction('requests', 'readwrite') tx.store.add({ url: request.url, method: request.method, headers: [...request.headers.entries()], body }) await tx.done }
function isStaticAsset(path) { return /\.(js|css|png|jpg|svg|woff2?)$/.test(path) }
self.addEventListener('sync', (event) => { if (event.tag === 'sync-notes') { event.waitUntil(processSyncQueue()) } })
async function processSyncQueue() { const db = await openDB('sync-queue', 1) const tx = db.transaction('requests', 'readonly') const requests = await tx.store.getAll() for (const req of requests) { try { await fetch(req.url, { method: req.method, headers: new Headers(req.headers), body: req.body }) } catch (err) { console.error('同步失败,下次再试:', req.url) return } } const clearTx = db.transaction('requests', 'readwrite') await clearTx.store.clear() }
|
3.2 场景 2:SW 实现消息推送(Web Push)
Service Worker 也可以接收服务器推送的消息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| self.addEventListener('push', (event) => { console.log('收到推送消息:', event.data.text()) let data try { data = event.data.json() } catch { data = { title: '新消息', body: event.data.text() } } const options = { body: data.body || '有新内容', icon: '/icons/icon-192.png', badge: '/icons/badge.png', vibrate: [200, 100, 200], data: { url: data.url || '/' }, actions: [ { action: 'view', title: '查看' }, { action: 'close', title: '关闭' } ] } event.waitUntil( self.registration.showNotification(data.title, options) ) })
self.addEventListener('notificationclick', (event) => { event.notification.close() if (event.action === 'close') return event.waitUntil( clients.matchAll({ type: 'window' }).then(windowClients => { const url = event.notification.data.url for (const client of windowClients) { if (client.url === url && 'focus' in client) { return client.focus() } } if (clients.openWindow) { return clients.openWindow(url) } }) ) })
|
页面上请求推送权限:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| async function subscribeToPush() { if (!('Notification' in window)) { console.log('当前浏览器不支持推送通知') return } const permission = await Notification.requestPermission() if (permission !== 'granted') { console.log('用户拒绝了推送通知') return } const registration = await navigator.serviceWorker.ready const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array('你的VAPID公钥') }) console.log('推送订阅成功:', subscription) await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription) }) }
|
3.3 场景 3:SW 配合 Workbox 使用
Workbox 是 Google 推出的 SW 工具库,封装了常用的缓存策略和工具:
1
| npm install workbox-webpack-plugin --save-dev
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| const { GenerateSW } = require('workbox-webpack-plugin')
module.exports = { plugins: [ new GenerateSW({ clientsClaim: true, skipWaiting: true, runtimeCaching: [ { urlPattern: /\.(png|jpg|jpeg|svg|gif)$/, handler: 'CacheFirst', options: { cacheName: 'images', expiration: { maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60, }, }, }, { urlPattern: /\/api\//, handler: 'NetworkFirst', options: { cacheName: 'api-cache', networkTimeoutSeconds: 3, expiration: { maxEntries: 100, maxAgeSeconds: 5 * 60, }, }, }, { urlPattern: /.*/, handler: 'StaleWhileRevalidate', options: { cacheName: 'others', expiration: { maxEntries: 200, }, }, }, ], }), ], }
|
这样配置后,Webpack 构建时会自动生成 SW 脚本,并在 dist/service-worker.js 中注入预缓存和运行时策略。
手动使用 Workbox 库(非构建工具集成):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js')
if (workbox) { console.log('Workbox 加载成功') workbox.routing.registerRoute( /\.(?:js|css|html)$/, new workbox.strategies.StaleWhileRevalidate({ cacheName: 'static-resources', }) ) workbox.routing.registerRoute( /\.(?:png|jpg|jpeg|svg|gif|webp)$/, new workbox.strategies.CacheFirst({ cacheName: 'image-cache', plugins: [ new workbox.expiration.ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60, }), ], }) ) workbox.routing.registerRoute( /\/api\ new workbox.strategies.NetworkFirst({ cacheName: 'api-cache', networkTimeoutSeconds: 3, }) ) }
|
4. 常见误区 & 实际项目中的坑
4.1 误区:SW 注册后立即生效
错误理解:注册 SW 后,当前页面立即被 SW 控制。
1 2
| navigator.serviceWorker.register('/sw.js')
|
实际情况:
1 2 3 4 5 6 7 8 9 10
| navigator.serviceWorker.register('/sw.js').then(registration => { console.log('SW 注册成功') console.log(navigator.serviceWorker.controller) })
|
原因:SW 在 install 和 activate 完成后才接管页面。当前页面已经在没有 SW 的情况下加载完成,不会回退到 SW 获取资源。只有下次导航(刷新或新标签页)才会走 SW。
正确做法:如果希望当前页面也受 SW 控制,可以在 activate 中调用 self.clients.claim(),但当前页面的初始资源已经加载完了,后续的资源请求才会走 SW。
4.2 误区:SW 脚本不能有任何缓存头
错误理解:SW 脚本必须设置 Cache-Control: no-cache,否则浏览器不会更新 SW。
实际情况:浏览器始终会检查 SW 脚本的更新,即使有 HTTP 缓存。但行为取决于 Cache-Control 头:
Cache-Control 的影响:
- 如果设置了
Cache-Control: max-age=86400,浏览器在 24 小时内不会真正下载新 SW 脚本,直接以缓存中的脚本为准。 - 但这不代表 SW 不会更新——浏览器每 24 小时会强制检查一次。
推荐配置:
1 2 3 4 5 6
| location /sw.js { add_header Cache-Control "no-cache"; }
|
但注意:即使 Cache-Control 设置了 no-cache,SW 的更新也不是实时的。浏览器必须在下一次导航时才会检查更新。
4.3 坑:Cache Storage 不是无限空间
每个浏览器对 Cache Storage 的配额不同,且会和其他存储(IndexedDB、localStorage)共享配额:
1 2 3 4 5 6 7 8
| if ('storage' in navigator && 'estimate' in navigator.storage) { navigator.storage.estimate().then(estimate => { console.log('已使用:', estimate.usage, 'bytes') console.log('配额:', estimate.quota, 'bytes') console.log('使用率:', (estimate.usage / estimate.quota * 100).toFixed(1) + '%') }) }
|
缓存管理策略:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| async function addToCacheWithLimit(request, response, maxEntries = 50) { const cache = await caches.open(CACHE_NAME) const keys = await cache.keys() if (keys.length >= maxEntries) { await cache.delete(keys[0]) } await cache.put(request, response) }
async function isCacheFresh(request, maxAgeMs) { const response = await caches.match(request) if (!response) return false const cachedDate = new Date(response.headers.get('date') || 0).getTime() const now = Date.now() return (now - cachedDate) < maxAgeMs }
const MAX_CACHE_AGE = 7 * 24 * 60 * 60 * 1000
async function cleanOldCache() { const cache = await caches.open(CACHE_NAME) const requests = await cache.keys() const now = Date.now() for (const request of requests) { const response = await cache.match(request) const cachedDate = new Date(response.headers.get('date') || now).getTime() if (now - cachedDate > MAX_CACHE_AGE) { await cache.delete(request) } } }
|
4.4 坑:请求被 SW 拦截后出现 CORS 问题
当 SW 拦截了一个跨域请求(如 CDN 资源),缓存和返回时需要处理 CORS:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| self.addEventListener('fetch', (event) => { if (event.request.url.includes('cdn.example.com')) { event.respondWith( fetch(event.request).then(response => { console.log(response.type) return response }) ) } })
|
Opaque 响应:当请求设置了 mode: 'no-cors' 或资源服务器没有返回 CORS 头时,浏览器返回 opaque 响应。这个响应的 body 不可读,status 为 0。
解决方案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
self.addEventListener('fetch', (event) => { if (event.request.url.includes('cdn.example.com')) { event.respondWith( caches.match(event.request).then(cached => { if (cached) return cached return fetch(event.request, { mode: 'no-cors' }).then(response => { const cache = event.waitUntil( caches.open('cdn-cache').then(cache => cache.put(event.request, response)) ) return response.clone() }) }) ) } })
|
更安全的做法:只缓存可读的响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| async function safeCache(request, response) { if (response.type === 'opaque') { const cache = await caches.open('opaque-cache') cache.put(request, response) return response } const cache = await caches.open(CACHE_NAME) cache.put(request, response.clone()) return response }
|
4.5 坑:SW 中 IndexedDB 的操作需要处理版本升级
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
self.addEventListener('fetch', (event) => { const request = indexedDB.open('my-db', 1) })
function openDB() { return new Promise((resolve, reject) => { const request = indexedDB.open('my-db', 1) request.onupgradeneeded = (event) => { const db = event.target.result if (!db.objectStoreNames.contains('sync-queue')) { db.createObjectStore('sync-queue', { autoIncrement: true }) } } request.onsuccess = (event) => resolve(event.target.result) request.onerror = (event) => reject(event.target.error) }) }
|
5. 与相关知识的关联 & 对比
5.1 Service Worker vs Cache-Control(HTTP 缓存)
| 维度 | Service Worker | HTTP Cache-Control |
|---|
| 控制粒度 | 请求级别,可编程(JS) | 资源级别,配置文件 |
| 离线支持 | ✅ 完整离线 | ❌ 只能降低请求频率 |
| 更新及时性 | 需处理 skipWaiting + claim | 自动 |
| 存储容量 | 大(几十 MB 到 GB) | 小(浏览器自动管理) |
| 缓存策略 | 完全可编程 | 固定模式 |
| 透明性 | 开发者完全控制 | 浏览器控制 |
| 复杂度 | 高 | 低 |
两者关系:互补,不是替代。
5.2 Service Worker vs Web Worker vs Shared Worker
| 维度 | Service Worker | Dedicated Worker | Shared Worker |
|---|
| 实例数 | 每个域名一个 | 每个页面多个 | 多个同源页面共享一个 |
| 生命周期 | 独立于页面 | 随页面关闭销毁 | 随最后一个引用页面关闭 |
| 拦截网络 | ✅ 可以 | ❌ 不能 | ❌ 不能 |
| 访问 DOM | ❌ 不能 | ❌ 不能 | ❌ 不能 |
| 可用 API | 只能异步 API | 大部分 API | 大部分 API |
| 使用场景 | 离线、缓存、推送 | 计算密集型任务 | 跨页面共享状态 |
| 持久化 | ✅ 可安装/激活 | ❌ 不持久 | ❌ 不持久 |
5.3 Service Worker vs PWA
Service Worker 是 PWA 的核心技术之一,但不是全部。PWA 的三要素:
1 2 3
| PWA = Web App Manifest(可安装性) + Service Worker(离线 + 推送) + 其他增强能力(IndexedDB、支付、蓝牙等)
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| { "name": "笔记应用", "short_name": "笔记", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#007AFF", "icons": [ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } ] }
|
1 2 3 4
| PWA 的 3 个核心能力: 1. 离线可用(Service Worker + Cache Storage) 2. 可安装到桌面(manifest.json) 3. 消息推送(Service Worker push event + Web Push API)
|
6. 现代最佳实践(2025-2026)
6.1 使用 Workbox 而非手写 SW
手写 SW 管理缓存、版本、更新策略容易出错。Workbox 封装了绝大多数最佳实践:
1
| npm install workbox-webpack-plugin workbox-precaching workbox-routing workbox-strategies
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| const { InjectManifest } = require('workbox-webpack-plugin')
module.exports = { plugins: [ new InjectManifest({ swSrc: './src/sw.js', swDest: 'service-worker.js', maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, }) ] }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| import { precacheAndRoute } from 'workbox-precaching' import { registerRoute } from 'workbox-routing' import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies' import { ExpirationPlugin } from 'workbox-expiration'
precacheAndRoute(self.__WB_MANIFEST)
registerRoute( /\.(?:png|jpg|jpeg|svg|gif|webp)$/, new CacheFirst({ cacheName: 'images', plugins: [ new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60, }), ], }) )
registerRoute( /\/api\ new NetworkFirst({ cacheName: 'api', networkTimeoutSeconds: 3, plugins: [ new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 5 * 60, }), ], }) )
registerRoute( /\.(?:js|css|html)$/, new StaleWhileRevalidate({ cacheName: 'static', }) )
|
使用 Workbox 的好处:
- 自动生成预缓存清单(不需要手动维护 PRECACHE_URLS)
- 内置过期策略、缓存大小限制
- 自动处理 SW 更新逻辑
- TypeScript 支持
6.2 开发环境中的 SW 调试
1 2 3 4 5 6 7 8 9
| if ('serviceWorker' in navigator && location.hostname !== 'localhost') { navigator.serviceWorker.register('/sw.js') }
if ('serviceWorker' in navigator && process.env.NODE_ENV === 'production') { navigator.serviceWorker.register('/service-worker.js') }
|
浏览器 DevTools 调试:
1 2 3 4 5 6 7
| Chrome DevTools → Application → Service Workers 可以: - 查看 SW 状态 - 更新/卸载 SW - 模拟离线模式 - 查看缓存的 Cache Storage 内容 - 查看推送通知
|
6.3 优雅的更新提示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| class SWManager { constructor() { this.registration = null this.updateCallbacks = [] } async init() { if (!('serviceWorker' in navigator)) return this.registration = await navigator.serviceWorker.register('/sw.js') this.registration.addEventListener('updatefound', () => { const newWorker = this.registration.installing newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && this.registration.active) { this.updateCallbacks.forEach(cb => cb()) } }) }) } onUpdate(callback) { this.updateCallbacks.push(callback) } applyUpdate() { if (this.registration?.waiting) { this.registration.waiting.postMessage({ type: 'SKIP_WAITING' }) } } }
const swManager = new SWManager() swManager.init()
swManager.onUpdate(() => { const toast = document.createElement('div') toast.className = 'update-toast' toast.innerHTML = ` <span>新版本可用</span> <button onclick="updateApp()">立即更新</button> ` document.body.appendChild(toast) })
window.updateApp = () => { swManager.applyUpdate() }
navigator.serviceWorker.addEventListener('controllerchange', () => { window.location.reload() })
|
6.4 SW 的测试策略
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
|
const puppeteer = require('puppeteer')
async function testServiceWorker() { const browser = await puppeteer.launch() const page = await browser.newPage() await page.goto('http://localhost:3000') await page.evaluate(() => { return navigator.serviceWorker.ready }) console.log('SW 已激活') await page.setOfflineMode(true) await page.reload({ waitUntil: 'networkidle0' }) const title = await page.title() console.log('离线时页面标题:', title) const content = await page.evaluate(() => document.body.textContent) console.log('离线时页面内容长度:', content.length) await browser.close() }
|
6.5 安全性考虑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| self.addEventListener('message', (event) => { if (event.origin !== location.origin) { console.warn('收到来自不可信来源的消息:', event.origin) return } })
async function cacheSafely(request, response) { if (request.url.includes('/api/auth') || request.url.includes('/token')) { return response } if (response.status === 401 || response.status === 403) { return response } const cache = await caches.open(CACHE_NAME) cache.put(request, response.clone()) return response }
|
7. 常见疑问解答(自问自答)
Q1: Service Worker 能缓存流媒体(视频/音频)吗?
可以,但要谨慎。大文件的缓存策略和普通资源不同:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| self.addEventListener('fetch', (event) => { if (event.request.url.endsWith('.mp4')) { event.respondWith( handleRangeRequest(event.request) ) } })
async function handleRangeRequest(request) { const cached = await caches.match(request) if (cached) return cached return fetch(request) }
|
注意事项:
- 视频文件通常很大,容易耗尽缓存配额
- 移动端缓存的视频不会被系统媒体扫描器识别(不会出现在相册中)
- 建议只缓存短视频(< 10MB),大视频流式播放即可
Q2: SW 在浏览器关闭后还能运行吗?
不能。SW 的生命周期规则:
- 浏览器关闭 → SW 被终止
- 浏览器重新打开 → SW 不会自动启动
- 用户导航到受 SW 控制的页面 → SW 启动
- SW 收到推送事件 → SW 启动(即使浏览器关闭,但需要浏览器在后台运行)
所以不能依赖 SW 做「浏览器关闭后还在后台运行的任务」。如果需要这样的能力,使用:
- Background Sync:浏览器恢复网络时自动同步数据
- Periodic Background Sync:定期在后台同步(需要权限)
- Push Events:服务器推送唤醒 SW
Q3: 多个页面共用一个 SW 会有什么问题?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
fetch('/api/data', { headers: { 'X-Cache-Strategy': 'network-only' } })
self.addEventListener('fetch', (event) => { if (event.request.headers.get('X-Cache-Strategy') === 'network-only') { event.respondWith(fetch(event.request)) return } event.respondWith(staleWhileRevalidate(event.request)) })
|
实践建议:SW 的粒度通常是整个站点一个。如果应用的不同部分需要不同的缓存策略,通过 URL 模式或请求头区分。
Q4: SW 注册后,怎么确认它是否正常工作?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| if (navigator.serviceWorker.controller) { console.log('当前页面由 SW 控制') } else { console.log('当前页面未被 SW 控制') }
navigator.serviceWorker.getRegistration().then(registration => { console.log('SW 状态:', registration.active?.state) console.log('SW scope:', registration.scope) console.log('SW 脚本:', registration.active?.scriptURL) })
self.addEventListener('fetch', (event) => { console.log('[SW] 拦截请求:', event.request.url) event.respondWith(fetch(event.request)) })
|
Q5: 同一域名下可以注册多个 SW 吗?
可以,但每个 scope 只能有一个 SW。scope 由注册时的路径或选项决定:
1 2 3 4 5 6 7
| navigator.serviceWorker.register('/sw-dashboard.js', { scope: '/dashboard/' }) navigator.serviceWorker.register('/sw-admin.js', { scope: '/admin/' })
|
注意:scope 必须有包含关系,不能交叉重叠。如果两个 SW 的 scope 重叠,注册会失败。
实际项目中绝大多数情况只需要一个 SW(scope: ‘/‘)。多个 SW 会增加维护复杂度,且容易产生冲突。
Q6: 如何优雅地卸载 Service Worker?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| if ('serviceWorker' in navigator) { navigator.serviceWorker.getRegistration().then(registration => { if (registration) { registration.unregister().then(success => { if (success) { console.log('SW 已卸载') } }) } }) }
async function unregisterSW() { const keys = await caches.keys() await Promise.all(keys.map(key => caches.delete(key))) const registration = await navigator.serviceWorker.getRegistration() if (registration) { await registration.unregister() } window.location.reload() }
|
8. 推荐学习路径
1 2 3 4 5 6 7 8 9 10
| 1. 阅读本文【面试速答版】 → 建立整体认知 2. 在 Chrome DevTools 的 Application 面板中熟悉 SW 相关功能 3. 写一个最简单的 SW:只输出日志,不缓存任何东西 4. 实现 Cache First 策略缓存静态资源 5. 在 DevTools 中模拟离线,验证缓存是否生效 6. 实现 Network First 策略缓存 API 请求 7. 学习 SW 的更新机制:修改 SW 脚本,观察更新流程 8. 引入 Workbox 替代手写 SW 9. 学习 Background Sync(后台同步) 10. 学习 Web Push 通知
|
关联知识点索引:
- PWA 中 SW 的角色 → PWA 详解
- 浏览器的 HTTP 缓存机制 → 浏览器原理详解
- IndexedDB 存储 → 前端性能优化
- Web Worker 多线程 → JavaScript 目录下相关文档