Service Worker

Service Worker是浏览器在后台独立线程运行的脚本,充当Web应用与网络之间的代理,可以实现离线缓存、推送通知、后台同步等功能。

Service Worker概述

什么是Service Worker

const serviceWorkerOverview = {
    definition: 'Service Worker是一种在浏览器后台运行的脚本,独立于Web页面',
    
    features: [
        '离线缓存 - 使Web应用可离线访问',
        '推送通知 - 接收服务器推送消息',
        '后台同步 - 在后台同步数据',
        '拦截请求 - 控制网络请求',
        '预缓存 - 预先缓存资源'
    ],
    
    lifecycle: [
        '注册 (Register)',
        '安装 (Install)',
        '激活 (Activate)',
        '空闲 (Idle)',
        '终止 (Terminated)'
    ],
    
    constraints: [
        '必须运行在HTTPS环境(localhost除外)',
        '无法直接操作DOM',
        '与主线程异步通信',
        '有独立的作用域'
    ]
};

注册Service Worker

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

生命周期

Service Worker脚本

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

缓存策略

缓存优先(Cache First)

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

网络优先(Network First)

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

网络缓存竞速(Stale While Revalidate)

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

Service Worker处理消息

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 体积大,不常变

🔔 推送通知最佳实践

  1. 只在用户同意后发送通知
  2. 通知内容要有价值,避免骚扰
  3. 提供取消订阅的选项
  4. 通知点击后要有明确的行为

下一步

下一章将探讨 IndexedDB,学习如何在浏览器中存储大量结构化数据。