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.

Практика

  1. Зарегистрируй Service Worker и предварительно закэшируй HTML, CSS и JS
  2. Реализуй Cache First для изображений и Network First для API
  3. Создай офлайн-страницу и покажи её при потере соединения
  4. Добавь manifest.json и проверь PWA-готовность в Lighthouse
  5. Настрой Workbox для автоматического управления кэшем в webpack/Vite-проекте
  6. Реализуй стратегию обновления: предложить пользователю перезагрузить при новой версии SW

Связанные темы

Ресурсы