Service Worker是浏览器在后台独立线程运行的脚本,充当Web应用与网络之间的代理,可以实现离线缓存、推送通知、后台同步等功能。
const serviceWorkerOverview = {
definition: 'Service Worker是一种在浏览器后台运行的脚本,独立于Web页面',
features: [
'离线缓存 - 使Web应用可离线访问',
'推送通知 - 接收服务器推送消息',
'后台同步 - 在后台同步数据',
'拦截请求 - 控制网络请求',
'预缓存 - 预先缓存资源'
],
lifecycle: [
'注册 (Register)',
'安装 (Install)',
'激活 (Activate)',
'空闲 (Idle)',
'终止 (Terminated)'
],
constraints: [
'必须运行在HTTPS环境(localhost除外)',
'无法直接操作DOM',
'与主线程异步通信',
'有独立的作用域'
]
};
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('Service Worker 注册成功:', registration.scope);
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
switch (newWorker.state) {
case 'installed':
if (navigator.serviceWorker.controller) {
console.log('新版本可用,请刷新页面');
} else {
console.log('内容已缓存,可离线访问');
}
break;
case 'redundant':
console.log('Service Worker 已废弃');
break;
}
});
});
} catch (error) {
console.error('Service Worker 注册失败:', error);
}
});
}
async function checkForUpdates() {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready;
await registration.update();
}
}
setInterval(checkForUpdates, 60 * 60 * 1000);
const CACHE_NAME = 'my-app-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/offline.html'
];
self.addEventListener('install', (event) => {
console.log('Service Worker 安装中...');
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('缓存静态资源');
return cache.addAll(STATIC_ASSETS);
})
.then(() => {
console.log('安装完成');
return self.skipWaiting();
})
);
});
self.addEventListener('activate', (event) => {
console.log('Service Worker 激活中...');
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(() => {
console.log('激活完成');
return self.clients.claim();
})
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request)
.then((response) => {
if (!response || response.status !== 200) {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => {
if (event.request.mode === 'navigate') {
return caches.match('/offline.html');
}
});
})
);
});
const VERSION = 'v2';
const CACHE_NAME = `app-${VERSION}`;
const PRECACHE_URLS = [
'/',
'/index.html',
'/styles.css',
'/app.js'
];
self.addEventListener('install', (event) => {
const precache = async () => {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(PRECACHE_URLS);
await self.skipWaiting();
};
event.waitUntil(precache());
});
self.addEventListener('activate', (event) => {
const cleanup = async () => {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames
.filter(name => !name.endsWith(VERSION))
.map(name => caches.delete(name))
);
await self.clients.claim();
const clients = await self.clients.matchAll();
clients.forEach(client => {
client.postMessage({
type: 'SW_UPDATED',
version: VERSION
});
});
};
event.waitUntil(cleanup());
});
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'SW_UPDATED') {
const shouldRefresh = confirm('发现新版本,是否刷新页面?');
if (shouldRefresh) {
window.location.reload();
}
}
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
}
self.addEventListener('fetch', (event) => {
if (event.request.destination === 'image') {
event.respondWith(cacheFirst(event.request));
}
});
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 (error) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
return new Response('离线状态,无法获取数据', {
status: 503,
statusText: 'Service Unavailable'
});
}
}
self.addEventListener('fetch', (event) => {
if (event.request.destination === 'document') {
event.respondWith(networkFirst(event.request));
}
});
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;
});
return cached || fetchPromise;
}
self.addEventListener('fetch', (event) => {
if (event.request.destination === 'style' ||
event.request.destination === 'script') {
event.respondWith(staleWhileRevalidate(event.request));
}
});
const CACHE_STRATEGIES = {
cacheFirst: {
use: '静态资源、不常变化的文件',
example: '图片、字体、CSS框架'
},
networkFirst: {
use: '需要最新数据的资源',
example: 'API请求、HTML页面'
},
staleWhileRevalidate: {
use: '需要快速响应但也要更新',
example: 'CSS、JavaScript文件'
},
networkOnly: {
use: '实时数据、不缓存',
example: '支付接口、实时数据'
},
cacheOnly: {
use: '离线优先、不请求网络',
example: '离线页面、预缓存内容'
}
};
function getStrategy(request) {
const url = new URL(request.url);
const path = url.pathname;
if (path.match(/\.(png|jpg|jpeg|svg|gif|webp|ico)$/)) {
return 'cacheFirst';
}
if (path.match(/\.(css|js)$/)) {
return 'staleWhileRevalidate';
}
if (path.startsWith('/api/')) {
return 'networkFirst';
}
if (request.mode === 'navigate') {
return 'networkFirst';
}
return 'staleWhileRevalidate';
}
self.addEventListener('fetch', (event) => {
const strategy = getStrategy(event.request);
switch (strategy) {
case 'cacheFirst':
event.respondWith(cacheFirst(event.request));
break;
case 'networkFirst':
event.respondWith(networkFirst(event.request));
break;
case 'staleWhileRevalidate':
event.respondWith(staleWhileRevalidate(event.request));
break;
default:
event.respondWith(fetch(event.request));
}
});
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
const publicKey = 'YOUR_PUBLIC_VAPID_KEY';
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey)
});
}
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
return subscription;
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function unsubscribeFromPush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
}
}
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {};
const options = {
body: data.body || '您有新消息',
icon: data.icon || '/icon-192.png',
badge: data.badge || '/badge-72.png',
image: data.image,
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
timestamp: Date.now()
},
actions: data.actions || [
{ action: 'open', title: '打开' },
{ action: 'close', title: '关闭' }
],
tag: data.tag || 'default',
renotify: data.renotify || false,
requireInteraction: data.requireInteraction || false
};
event.waitUntil(
self.registration.showNotification(data.title || '通知', options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data.url;
if (event.action === 'close') {
return;
}
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clients) => {
for (const client of clients) {
if (client.url === url && 'focus' in client) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(url);
}
})
);
});
self.addEventListener('notificationclose', (event) => {
console.log('通知已关闭:', event.notification);
});
async function registerSync() {
const registration = await navigator.serviceWorker.ready;
if ('sync' in registration) {
await registration.sync.register('sync-data');
console.log('同步任务已注册');
} else {
console.log('浏览器不支持后台同步');
}
}
document.getElementById('save-btn').addEventListener('click', async () => {
const data = { content: document.getElementById('content').value };
await saveToIndexedDB(data);
await registerSync();
});
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(syncData());
}
});
async function syncData() {
const pendingData = await getPendingDataFromIndexedDB();
for (const data of pendingData) {
try {
await fetch('/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
await removePendingData(data.id);
} catch (error) {
console.error('同步失败:', error);
throw error;
}
}
}
self.addEventListener('periodicsync', (event) => {
if (event.tag === 'daily-sync') {
event.waitUntil(dailySync());
}
});
async function dailySync() {
const response = await fetch('/api/daily-update');
const data = await response.json();
const cache = await caches.open('daily-cache');
await cache.put('/daily-data', new Response(JSON.stringify(data)));
}
async function sendMessage(message) {
const controller = navigator.serviceWorker.controller;
if (!controller) {
console.log('Service Worker 未激活');
return;
}
return new Promise((resolve) => {
const messageChannel = new MessageChannel();
messageChannel.port1.onmessage = (event) => {
resolve(event.data);
};
controller.postMessage(message, [messageChannel.port2]);
});
}
navigator.serviceWorker.addEventListener('message', (event) => {
console.log('收到Service Worker消息:', event.data);
});
async function clearCache() {
const result = await sendMessage({ type: 'CLEAR_CACHE' });
console.log(result);
}
self.addEventListener('message', (event) => {
const { type, data } = event.data;
switch (type) {
case 'CLEAR_CACHE':
event.waitUntil(
caches.keys().then((names) => {
return Promise.all(names.map(name => caches.delete(name)));
}).then(() => {
event.ports[0].postMessage({ success: true });
})
);
break;
case 'GET_CACHE_SIZE':
event.waitUntil(
getCacheSize().then((size) => {
event.ports[0].postMessage({ size });
})
);
break;
case 'SKIP_WAITING':
self.skipWaiting();
event.ports[0].postMessage({ success: true });
break;
}
});
async function getCacheSize() {
const names = await caches.keys();
let totalSize = 0;
for (const name of names) {
const cache = await caches.open(name);
const keys = await cache.keys();
for (const request of keys) {
const response = await cache.match(request);
const blob = await response.blob();
totalSize += blob.size;
}
}
return totalSize;
}
const OFFLINE_CACHE = 'offline-v1';
const OFFLINE_URLS = [
'/',
'/index.html',
'/offline.html',
'/styles.css',
'/app.js',
'/manifest.json'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(OFFLINE_CACHE)
.then((cache) => cache.addAll(OFFLINE_URLS))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys.filter((key) => key !== OFFLINE_CACHE)
.map((key) => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.catch(() => caches.match('/offline.html'))
);
return;
}
event.respondWith(
caches.match(event.request)
.then((cached) => {
const fetchPromise = fetch(event.request)
.then((response) => {
if (response.ok) {
caches.open(OFFLINE_CACHE)
.then((cache) => cache.put(event.request, response.clone()));
}
return response;
});
return cached || fetchPromise;
})
);
});
📦 缓存策略选择
资源类型 推荐策略 原因 静态资源 Cache First 不常变化,缓存命中快 HTML页面 Network First 需要最新内容 CSS/JS Stale While Revalidate 快速响应+后台更新 API请求 Network First 数据实时性重要 图片 Cache First 体积大,不常变
🔔 推送通知最佳实践
- 只在用户同意后发送通知
- 通知内容要有价值,避免骚扰
- 提供取消订阅的选项
- 通知点击后要有明确的行为
下一章将探讨 IndexedDB,学习如何在浏览器中存储大量结构化数据。