Service Workers
Service Worker — скрипт, который браузер запускает в фоне, отдельно от страницы. Перехватывает сетевые запросы, управляет кэшем и позволяет работать офлайн. Основа Progressive Web Apps (PWA).
Зачем нужно
Service Worker превращает обычный сайт в приложение: работа без интернета, мгновенная загрузка из кэша, push-уведомления, фоновая синхронизация. Это программируемый прокси между браузером и сетью — полный контроль над кэшированием и сетевыми запросами.
Где используется
PWA (Progressive Web Apps), офлайн-приложения, кэширование ассетов и API-ответов, push-уведомления, фоновая синхронизация. Примеры: Twitter Lite, Starbucks, Pinterest, Google Docs (офлайн).
Предпосылки
Caching стратегии, Promises и async/await, Fetch API, HTTPS (обязательно для Service Workers)
Жизненный цикл Service Worker
1. REGISTER — страница регистрирует SW
│
▼
2. INSTALL — SW загружается, выполняется событие 'install'
│ (обычно: предварительное кэширование ассетов)
▼
3. WAITING — если старый SW ещё активен, новый ждёт
│
▼
4. ACTIVATE — старый SW удалён, новый активирован
│ (обычно: очистка старого кэша)
▼
5. FETCH — SW перехватывает сетевые запросы
│ (обычно: стратегии кэширования)
▼
6. IDLE / TERMINATED — браузер может остановить SW для экономии памяти
(перезапустится при следующем событии)
Регистрация
// main.js — регистрация Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/', // SW контролирует все страницы от корня
});
console.log('SW зарегистрирован:', registration.scope);
// Проверка обновлений
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('Найден новый SW, состояние:', newWorker.state);
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
// Показать пользователю "Доступно обновление"
showUpdateNotification;
}
});
});
} catch (error) {
console.error('SW ошибка регистрации:', error);
}
});
}
Событие install — предварительное кэширование
// sw.js
const CACHE_NAME = 'app-v1';
// Список ресурсов для предварительного кэширования
const PRECACHE_URLS = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.svg',
'/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;
})
);
});
Событие activate — очистка старого кэша
// sw.js
self.addEventListener('activate', (event) => {
console.log('SW: activate');
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME) // Удалить все кэши кроме текущего
.map(name => {
console.log('SW: удаляем старый кэш', name);
return caches.delete(name);
})
);
})
.then(() => {
// Взять контроль над всеми открытыми вкладками
return self.clients.claim;
})
);
});
Событие fetch — перехват запросов
// sw.js — базовый перехват
self.addEventListener('fetch', (event) => {
// Перехватываем только GET-запросы
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request)
.then(cachedResponse => {
if (cachedResponse) {
return cachedResponse; // Есть в кэше — отдаём
}
return fetch(event.request); // Нет в кэше — запрос в сеть
})
);
});
Стратегии кэширования
1. Cache First (Offline First)
Сначала кэш, потом сеть. Для статических ассетов, которые редко меняются.
// Идеально для: CSS, JS, шрифты, изображения
function cacheFirst(event) {
event.respondWith(
caches.match(event.request)
.then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
// Клонируем ответ (stream можно прочитать только раз)
const responseClone = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, responseClone);
});
return response;
});
})
);
}
Запрос → Кэш есть? → ДА → Ответ из кэша (мгновенно)
→ НЕТ → Сеть → Сохранить в кэш → Ответ
2. Network First
Сначала сеть, кэш как fallback. Для данных, где важна свежесть.
// Идеально для: API-ответы, HTML-страницы, динамический контент
function networkFirst(event) {
event.respondWith(
fetch(event.request)
.then(response => {
const responseClone = response.clone();
caches.open('dynamic').then(cache => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
// Сеть недоступна — отдаём из кэша
return caches.match(event.request)
.then(cached => cached || caches.match('/offline.html'));
})
);
}
Запрос → Сеть доступна? → ДА → Ответ из сети + обновить кэш
→ НЕТ → Кэш есть? → ДА → Ответ из кэша
→ НЕТ → Offline-страница
3. Stale While Revalidate
Мгновенный ответ из кэша + обновление в фоне. Баланс скорости и свежести.
// Идеально для: аватарки, каталоги, не критично свежие данные
function staleWhileRevalidate(event) {
event.respondWith(
caches.open('dynamic').then(cache => {
return cache.match(event.request).then(cached => {
const networkFetch = fetch(event.request).then(response => {
cache.put(event.request, response.clone());
return response;
});
// Возвращаем кэш (мгновенно), или ждём сеть
return cached || networkFetch;
});
})
);
}
Запрос → Кэш есть? → ДА → Ответ из кэша (мгновенно)
+ Фоновый запрос → Обновить кэш
→ НЕТ → Ждать ответ из сети → Сохранить в кэш
4. Cache Only
Только из кэша. Для предварительно закэшированных ресурсов.
function cacheOnly(event) {
event.respondWith(caches.match(event.request));
}
5. Network Only
Только сеть. Для запросов, которые нельзя кэшировать.
function networkOnly(event) {
event.respondWith(fetch(event.request));
}
Полный Service Worker с роутингом
// sw.js — продвинутый вариант
const CACHE_STATIC = 'static-v2';
const CACHE_DYNAMIC = 'dynamic-v1';
const CACHE_IMAGES = 'images-v1';
const PRECACHE = [
'/', '/index.html', '/offline.html',
'/css/app.css', '/js/app.js',
'/fonts/inter.woff2',
];
// ---- INSTALL ----
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_STATIC)
.then(cache => cache.addAll(PRECACHE))
.then( => self.skipWaiting)
);
});
// ---- ACTIVATE ----
self.addEventListener('activate', (event) => {
const allowed = [CACHE_STATIC, CACHE_DYNAMIC, CACHE_IMAGES];
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(k => !allowed.includes(k)).map(k => caches.delete(k))
)
).then( => self.clients.claim)
);
});
// ---- FETCH (роутинг по типу ресурса) ----
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== 'GET') return;
// HTML-страницы — Network First
if (request.headers.get('accept')?.includes('text/html')) {
event.respondWith(
fetch(request)
.then(res => {
const clone = res.clone();
caches.open(CACHE_DYNAMIC).then(c => c.put(request, clone));
return res;
})
.catch( => caches.match(request).then(c => c || caches.match('/offline.html')))
);
return;
}
// Изображения — Cache First + лимит размера кэша
if (request.destination === 'image') {
event.respondWith(
caches.match(request).then(cached => {
if (cached) return cached;
return fetch(request).then(res => {
const clone = res.clone();
caches.open(CACHE_IMAGES).then(async (cache) => {
cache.put(request, clone);
// Ограничиваем кэш изображений до 50 записей
const keys = await cache.keys();
if (keys.length > 50) {
await cache.delete(keys[0]); // Удаляем самый старый
}
});
return res;
});
})
);
return;
}
// API — Stale While Revalidate
if (url.pathname.startsWith('/api/')) {
event.respondWith(
caches.open(CACHE_DYNAMIC).then(cache =>
cache.match(request).then(cached => {
const fresh = fetch(request).then(res => {
cache.put(request, res.clone());
return res;
});
return cached || fresh;
})
)
);
return;
}
// Остальное (CSS, JS, шрифты) — Cache First
event.respondWith(
caches.match(request).then(cached => cached || fetch(request))
);
});
Cache API
// Основные операции с Cache API
// Открыть/создать кэш
const cache = await caches.open('my-cache');
// Добавить ресурс (fetch + put)
await cache.add('/styles.css');
await cache.addAll(['/app.js', '/logo.svg']);
// Положить ответ вручную
const response = await fetch('/api/data');
await cache.put('/api/data', response);
// Найти в кэше
const cached = await caches.match('/styles.css');
if (cached) {
const text = await cached.text();
}
// Удалить из кэша
await cache.delete('/old-file.js');
// Получить все ключи (URL) в кэше
const keys = await cache.keys();
console.log('Cached URLs:', keys.map(req => req.url));
// Список всех кэшей
const names = await caches.keys();
console.log('All caches:', names);
// Удалить кэш целиком
await caches.delete('old-cache-v1');
Офлайн-страница
<!-- offline.html -->
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Нет подключения</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: system-ui;
text-align: center;
background: #f5f5f5;
}
.offline {
padding: 2rem;
}
.offline h1 { font-size: 2rem; margin-bottom: 1rem; }
.offline p { color: #666; margin-bottom: 2rem; }
button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
border: none;
background: #007bff;
color: white;
border-radius: 4px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="offline">
<h1>Нет подключения к интернету</h1>
<p>Проверьте соединение и попробуйте снова</p>
<button onclick="window.location.reload">Повторить</button>
</div>
</body>
</html>
Web App Manifest (PWA)
// manifest.json — описание PWA
{
"name": "My Application",
"short_name": "MyApp",
"description": "Progressive Web App example",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#007bff",
"orientation": "portrait-primary",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
<!-- Подключение в HTML -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#007bff">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
Push-уведомления (концепт)
// Подписка на push-уведомления (на клиенте)
async function subscribePush() {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Отправить подписку на сервер
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' },
});
}
// Обработка push в Service Worker
self.addEventListener('push', (event) => {
const data = event.data?.json() || {};
event.waitUntil(
self.registration.showNotification(data.title || 'Уведомление', {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
data: { url: data.url },
})
);
});
// Клик по уведомлению
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url || '/')
);
});
Workbox — библиотека Google
// Workbox упрощает работу с Service Workers
// npm install workbox-cli
// workbox-config.js
module.exports = {
globDirectory: 'dist/',
globPatterns: ['**/*.{html,js,css,png,svg,woff2}'],
swDest: 'dist/sw.js',
runtimeCaching: [
{
urlPattern: /\.(?:png|jpg|jpeg|svg|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: { maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 },
},
},
{
urlPattern: /\/api\//,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api',
expiration: { maxEntries: 100, maxAgeSeconds: 24 * 60 * 60 },
},
},
{
urlPattern: /\.(?:js|css)$/,
handler: 'StaleWhileRevalidate',
options: { cacheName: 'static-resources' },
},
],
};
// sw.js с Workbox (webpack plugin)
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// Предварительное кэширование (из build)
precacheAndRoute(self.__WB_MANIFEST);
// Изображения — Cache First
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
})
);
// API — Network First
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api',
plugins: [
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 24 * 60 * 60 }),
],
})
);
Обновление Service Worker
// Стратегия "prompt user to update"
// В main.js:
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('/sw.js');
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Новый SW установлен, но старый ещё активен
if (confirm('Доступно обновление. Перезагрузить?')) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
}
}
});
});
// Перезагрузить при смене контроллера
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload;
});
}
// В sw.js:
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting;
}
});
Отладка в DevTools
Chrome DevTools → Application tab:
1. Service Workers — состояние, обновление, Unregister
2. Cache Storage — содержимое кэшей
3. Manifest — параметры PWA
Полезные действия:
- "Update on reload" — принудительное обновление SW при F5
- "Bypass for network" — отключить SW временно
- "Offline" — эмулировать офлайн для тестирования
Частые ошибки
1. Забыть clone при кэшировании response
// ПЛОХО: Response — это stream, его можно прочитать только раз
event.respondWith(
fetch(event.request).then(response => {
cache.put(event.request, response); // Съели stream
return response; // Пустой response!
})
);
// ХОРОШО: клонируем
event.respondWith(
fetch(event.request).then(response => {
cache.put(event.request, response.clone()); // Клон в кэш
return response; // Оригинал пользователю
})
);
2. Кэширование POST-запросов и ошибок
// ПЛОХО: кэшируем всё подряд
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(c => c || fetch(event.request))
);
});
// ХОРОШО: фильтруем
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return; // Только GET
if (!event.request.url.startsWith('http')) return; // Только HTTP(S)
event.respondWith(
fetch(event.request).then(response => {
if (!response.ok) return response; // НЕ кэшируем ошибки
// ...кэшируем только успешные ответы
})
);
});
3. Нет стратегии обновления
// ПЛОХО: кэш навсегда, нет версионирования
const CACHE = 'cache';
// ХОРОШО: версия в имени кэша + очистка старых
const CACHE = 'app-v3';
// При activate — удалить всё кроме 'app-v3'
4. SW не работает на HTTP
Service Workers требуют HTTPS (или localhost для разработки).
На HTTP — navigator.serviceWorker === undefined.
Практика
- Зарегистрируй Service Worker и предварительно закэшируй HTML, CSS и JS
- Реализуй Cache First для изображений и Network First для API
- Создай офлайн-страницу и покажи её при потере соединения
- Добавь manifest.json и проверь PWA-готовность в Lighthouse
- Настрой Workbox для автоматического управления кэшем в webpack/Vite-проекте
- Реализуй стратегию обновления: предложить пользователю перезагрузить при новой версии SW