Service Worker

Service Worker - это скрипт, который браузер запускает в фоне, отдельно от веб-страницы. Он работает как прокси между приложением и сетью, перехватывая сетевые запросы, управляя кэшем и обеспечивая работу push-уведомлений и фоновой синхронизации.

Service Worker не имеет доступа к DOM. Он общается с основной страницей через postMessage и работает полностью асинхронно - все API внутри основаны на промисах.

Important

Service Worker работает только по HTTPS (за исключением localhost для разработки). Это требование безопасности, так как SW перехватывает сетевые запросы и может модифицировать ответы.

Жизненный цикл

Service Worker проходит три фазы: установка, активация и работа. Понимание жизненного цикла критично для корректного обновления кэша и предотвращения конфликтов между версиями.

Регистрация → Installing → Installed (waiting) → Activating → Activated → Fetch/Push/Sync

При первой регистрации SW скачивается, парсится и запускается событие install. В этот момент обычно кэшируют статические ресурсы. После установки SW переходит в состояние waiting - он ждёт, пока все вкладки со старым SW будут закрыты. Событие activate срабатывает, когда SW получает контроль - здесь обычно чистят устаревшие кэши.

// sw.js
const CACHE_NAME = 'app-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/offline.html',
];
 
// Установка: кэшируем статические ресурсы
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.addAll(STATIC_ASSETS);
    })
  );
});
 
// Активация: чистим старые кэши
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames
          .filter((name) => name !== CACHE_NAME)
          .map((name) => caches.delete(name))
      );
    })
  );
});

event.waitUntil() принимает промис и не даёт браузеру завершить фазу, пока промис не разрешится. Без него браузер может прервать установку до завершения кэширования.

Чтобы новый SW сразу получил контроль, не дожидаясь закрытия вкладок:

self.addEventListener('install', (event) => {
  self.skipWaiting(); // пропустить фазу ожидания
  event.waitUntil(/* ... */);
});
 
self.addEventListener('activate', (event) => {
  event.waitUntil(
    clients.claim() // захватить контроль над всеми вкладками
  );
});

Info

skipWaiting() + clients.claim() - агрессивное обновление. Используйте его осторожно: если формат кэша изменился, старые страницы могут получить несовместимые ответы из нового кэша. Безопаснее показать пользователю уведомление “Доступно обновление” и перезагрузить страницу.

Регистрация

// main.js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', {
        scope: '/', // по умолчанию - директория, где лежит sw.js
      });
 
      console.log('SW registered, scope:', registration.scope);
 
      // Отслеживание обновлений
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
 
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              // Есть старый SW - доступно обновление
              showUpdateBanner();
            } else {
              // Первая установка
              console.log('Content cached for offline use');
            }
          }
        });
      });
    } catch (error) {
      console.error('SW registration failed:', error);
    }
  });
}

Scope определяет, какие запросы SW будет перехватывать. По умолчанию scope равен директории, в которой находится файл sw.js. Файл /sw.js имеет scope /, а /scripts/sw.js имеет scope /scripts/. Расширить scope выше расположения файла нельзя без специального HTTP-заголовка Service-Worker-Allowed.

Коммуникация: страница → Service Worker

postMessage - основной способ двусторонней связи между страницей и SW.

Отправка сообщения в SW:

// Из страницы в Service Worker
navigator.serviceWorker.controller.postMessage({
  type: 'CACHE_URLS',
  payload: ['/api/data', '/api/config'],
});
 
// В sw.js
self.addEventListener('message', (event) => {
  if (event.data.type === 'CACHE_URLS') {
    event.waitUntil(
      caches.open(CACHE_NAME).then((cache) => {
        return cache.addAll(event.data.payload);
      })
    );
  }
});

Коммуникация: Service Worker → страница

SW может отправлять сообщения всем контролируемым клиентам или конкретному клиенту:

// sw.js: отправка сообщения всем вкладкам
self.clients.matchAll().then((clients) => {
  clients.forEach((client) => {
    client.postMessage({
      type: 'CACHE_UPDATED',
      payload: { url: '/api/data', timestamp: Date.now() },
    });
  });
});
 
// sw.js: ответить конкретному отправителю
self.addEventListener('message', (event) => {
  // event.source - клиент, отправивший сообщение
  event.source.postMessage({
    type: 'RESPONSE',
    payload: 'received',
  });
});

Приём сообщений на странице:

navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data.type === 'CACHE_UPDATED') {
    console.log('Cache updated:', event.data.payload);
    refreshUI();
  }
});

MessageChannel для двусторонней связи

MessageChannel создаёт пару связанных портов для прямого обмена сообщениями:

// Страница: создаём канал и отправляем один порт в SW
function sendMessageToSW(message) {
  return new Promise((resolve) => {
    const channel = new MessageChannel();
 
    channel.port1.onmessage = (event) => {
      resolve(event.data);
    };
 
    navigator.serviceWorker.controller.postMessage(message, [channel.port2]);
  });
}
 
// Использование
const response = await sendMessageToSW({ type: 'GET_CACHE_SIZE' });
console.log('Cache size:', response.size);
 
// sw.js: ответ через переданный порт
self.addEventListener('message', (event) => {
  if (event.data.type === 'GET_CACHE_SIZE') {
    caches.open(CACHE_NAME).then((cache) => {
      cache.keys().then((keys) => {
        event.ports[0].postMessage({ size: keys.length });
      });
    });
  }
});

BroadcastChannel для межвкладочной связи

BroadcastChannel позволяет передавать сообщения между всеми контекстами (вкладки, iframe, SW) на одном origin:

// В sw.js
const channel = new BroadcastChannel('app-updates');
channel.postMessage({ type: 'NEW_VERSION', version: '2.0.0' });
 
// На странице
const channel = new BroadcastChannel('app-updates');
channel.addEventListener('message', (event) => {
  if (event.data.type === 'NEW_VERSION') {
    showUpdateNotification(event.data.version);
  }
});

Cache API и стратегии кэширования

Cache API позволяет хранить пары Request/Response. Основные методы:

// Открыть/создать кэш
const cache = await caches.open('my-cache');
 
// Добавить ресурс (fetch + put)
await cache.add('/api/data');
await cache.addAll(['/style.css', '/app.js']);
 
// Положить конкретный ответ
await cache.put('/api/data', new Response(JSON.stringify(data)));
 
// Найти в кэше
const response = await cache.match('/api/data');
const response = await caches.match('/api/data'); // искать во всех кэшах
 
// Удалить
await cache.delete('/api/data');
 
// Получить все ключи
const keys = await cache.keys();

Cache First (Cache Falling Back to Network)

Сначала ищем в кэше. Если нет - идём в сеть и кэшируем ответ. Подходит для статических ресурсов, которые редко меняются: шрифты, изображения, библиотеки.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      if (cached) return cached;
 
      return fetch(event.request).then((response) => {
        // Клонируем, потому что response можно прочитать только один раз
        const clone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, clone);
        });
        return response;
      });
    })
  );
});

Network First (Network Falling Back to Cache)

Сначала пробуем сеть. Если сеть недоступна - отдаём из кэша. Подходит для контента, который часто обновляется: API-ответы, HTML-страницы.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const clone = response.clone();
        caches.open(CACHE_NAME).then((cache) => {
          cache.put(event.request, clone);
        });
        return response;
      })
      .catch(() => {
        return caches.match(event.request);
      })
  );
});

Stale While Revalidate

Отдаёт из кэша сразу (быстрый ответ), одновременно обновляя кэш из сети. Баланс между скоростью и актуальностью. Подходит для ресурсов, где допустима краткосрочная неактуальность: аватарки, списки, контент который обновляется не критично часто.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).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;
      });
    })
  );
});

Cache Only и Network Only

// Cache Only: только кэш, без сети
// Для ресурсов, закэшированных при установке
event.respondWith(caches.match(event.request));
 
// Network Only: только сеть, без кэша
// Для запросов, которые всегда должны быть свежими (аналитика, POST)
event.respondWith(fetch(event.request));

Комбинированная стратегия

На практике разные типы ресурсов требуют разных стратегий:

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);
 
  if (request.destination === 'image') {
    // Изображения: Cache First
    event.respondWith(cacheFirst(request));
  } else if (url.pathname.startsWith('/api/')) {
    // API: Network First
    event.respondWith(networkFirst(request));
  } else if (request.destination === 'style' || request.destination === 'script') {
    // Статика: Stale While Revalidate
    event.respondWith(staleWhileRevalidate(request));
  } else {
    // HTML: Network First с offline fallback
    event.respondWith(
      networkFirst(request).then(
        (response) => response || caches.match('/offline.html')
      )
    );
  }
});

Возможности Service Worker

Background Sync

Позволяет отложить действие до появления стабильного соединения. Если пользователь отправил форму offline, запрос выполнится автоматически при восстановлении сети.

// Страница: регистрируем sync
async function submitForm(data) {
  // Сохраняем данные в IndexedDB
  await saveToIndexedDB('pending-submissions', data);
 
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('submit-form');
}
 
// sw.js: обрабатываем sync
self.addEventListener('sync', (event) => {
  if (event.tag === 'submit-form') {
    event.waitUntil(processPendingSubmissions());
  }
});
 
async function processPendingSubmissions() {
  const pending = await getFromIndexedDB('pending-submissions');
 
  for (const data of pending) {
    await fetch('/api/submit', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    await removeFromIndexedDB('pending-submissions', data.id);
  }
}

Periodic Background Sync

Позволяет периодически обновлять данные в фоне. Браузер сам решает, когда запускать синхронизацию, учитывая батарею, тип соединения и частоту использования сайта.

// Регистрация периодической синхронизации
const registration = await navigator.serviceWorker.ready;
const status = await navigator.permissions.query({ name: 'periodic-background-sync' });
 
if (status.state === 'granted') {
  await registration.periodicSync.register('update-content', {
    minInterval: 24 * 60 * 60 * 1000, // минимум раз в сутки
  });
}
 
// sw.js
self.addEventListener('periodicsync', (event) => {
  if (event.tag === 'update-content') {
    event.waitUntil(updateCachedContent());
  }
});

Info

Periodic Background Sync доступен только в Chromium-браузерах и требует, чтобы сайт был установлен как PWA или часто посещался. Браузер может регулировать частоту синхронизации.

Позволяет делать сетевой запрос параллельно с загрузкой SW, вместо последовательного ожидания. Устраняет задержку при первом запросе после пробуждения SW.

self.addEventListener('activate', (event) => {
  event.waitUntil(
    self.registration.navigationPreload.enable()
  );
});
 
self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      (async () => {
        try {
          // preloadResponse доступен пока SW просыпается
          const preloadResponse = await event.preloadResponse;
          if (preloadResponse) return preloadResponse;
 
          return await fetch(event.request);
        } catch {
          return caches.match('/offline.html');
        }
      })()
    );
  }
});

Обновление Service Worker

При каждом посещении сайта браузер проверяет sw.js на изменения (побайтовое сравнение). Если файл изменился, запускается установка нового SW. Можно ускорить проверку:

// Проверка обновлений каждый час
const registration = await navigator.serviceWorker.ready;
setInterval(() => {
  registration.update();
}, 60 * 60 * 1000);

Паттерн уведомления пользователя об обновлении:

let refreshing = false;
 
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (!refreshing) {
    refreshing = true;
    window.location.reload();
  }
});
 
// При обнаружении нового SW в состоянии waiting
function showUpdateBanner(registration) {
  const banner = document.getElementById('update-banner');
  banner.style.display = 'block';
 
  banner.querySelector('button').addEventListener('click', () => {
    registration.waiting.postMessage({ type: 'SKIP_WAITING' });
  });
}
 
// sw.js
self.addEventListener('message', (event) => {
  if (event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

Ограничения

  • Нет доступа к DOM - общение только через postMessage
  • Работает только по HTTPS (кроме localhost)
  • Асинхронный - нет синхронных API (localStorage недоступен, используйте IndexedDB или Cache API)
  • Scope ограничен - SW на /app/sw.js не может перехватывать запросы к /other/
  • Время жизни непредсказуемо - браузер может остановить SW в любой момент для экономии ресурсов. Не храните состояние в переменных, используйте IndexedDB
  • Первый визит без SW - при первом посещении SW только устанавливается, перехват запросов начинается со второго визита (если не использовать clients.claim())