Service Worker на TypeScript

Service Worker на TypeScript

В этой статье объясняются Service Worker на TypeScript.

Мы объясним работу Service Worker на TypeScript с практическими примерами.

YouTube Video

Service Worker на TypeScript

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

Настройка TypeScript

tsconfig.json (Включение типов WebWorker)

Рассмотрим пример включения типа WebWorker в tsconfig.json.

 1{
 2  "compilerOptions": {
 3    "target": "ES2020",
 4    "module": "ES2020",
 5    "lib": ["ES2020", "WebWorker"],
 6    "moduleResolution": "Bundler",
 7    "strict": true,
 8    "noEmitOnError": true,
 9    "outDir": "out",
10    "skipLibCheck": true
11  },
12  "include": ["sw.ts"]
13}
  • Добавив WebWorker в массив lib, вы сможете использовать типы вроде ServiceWorkerGlobalScope.
  • DOM и WebWorker имеют различные типы, поэтому обычно настройки tsconfig.json для браузера (основного приложения) и Service Worker разделяются.
  • Service Worker файлы в конечном итоге выводятся в путь, соответствующий области действия (обычно корень сайта /sw.js).
  • По соображениям безопасности Service Worker запускаются только через HTTPS или на localhost.

Код регистрации со стороны браузера

register-sw.ts

1// register-sw.ts
2async function registerServiceWorker() {
3  if (!('serviceWorker' in navigator)) return;
4
5  try {
6    const registration = await navigator.serviceWorker.register(
7      '/sw.js', { scope: '/' }
8    );
  • Этот процесс регистрирует Service Worker. scope относится к диапазону путей, которыми может управлять Service Worker. Например, если разместить /sw.js в корне сайта и установить scope в корневую директорию (/), вы сможете контролировать все ресурсы всего сайта. С другой стороны, если указать определённую директорию, например /app/, то будут контролироваться только содержимые этой директории.
1    // If there's a waiting worker, notify the user.
2    if (registration.waiting) {
3      promptUserToUpdate(registration);
4    }
  • waiting обозначает состояние, при котором новый Service Worker установлен и ожидает активации. На этом этапе существующие страницы всё ещё контролируются старым Service Worker, поэтому обычно пользователю предлагают подтвердить обновление, и после одобрения вызывают skipWaiting(), чтобы немедленно активировать новый Service Worker. Это позволяет применить последние изменения, не дожидаясь перезагрузки страницы.
 1    // When a new SW is installing, monitor its state changes
 2    registration.addEventListener('updatefound', () => {
 3      const newWorker = registration.installing;
 4      if (!newWorker) return;
 5      newWorker.addEventListener('statechange', () => {
 6        if (newWorker.state === 'installed' &&
 7            navigator.serviceWorker.controller) {
 8          // New content available, prompt the user
 9          promptUserToUpdate(registration);
10        }
11      });
12    });
  • updatefound срабатывает когда начинается установка нового Service Worker. Когда это событие происходит, новый воркер назначается в registration.installing, поэтому, отслеживая его statechange, можно определить, когда установка завершена (installed). Кроме того, если navigator.serviceWorker.controller существует, это означает, что старая версия Service Worker уже управляет страницей, поэтому появляется возможность уведомить пользователя о наличии новой версии.
1    // When the active worker changes (e.g., after skipWaiting), reload if desired
2    navigator.serviceWorker.addEventListener('controllerchange', () => {
3      // Optionally reload to let the new SW take over
4      window.location.reload();
5    });
6  } catch (err) {
7    console.error('Service Worker registration failed: ', err);
8  }
9}
  • Событие controllerchange срабатывает в тот момент, когда новый Service Worker начинает контролировать текущую страницу. Перезагрузка на этом этапе приведёт к немедленному применению новых стратегий кэширования и обработки. Однако автоматическая перезагрузка может ухудшить пользовательский опыт, поэтому желательно перезагружать только после получения согласия пользователя.
1function promptUserToUpdate(reg: ServiceWorkerRegistration) {
2  // Show UI to user. If user accepts:
3  if (reg.waiting) {
4    reg.waiting.postMessage({ type: 'SKIP_WAITING' });
5  }
6}
7
8registerServiceWorker();
  • Передав postMessage({ type: 'SKIP_WAITING' }) от клиента в Service Worker и вызвав self.skipWaiting(), вы можете инициировать обновление.

Объявление области действия в sw.ts

Далее рассмотрим типичный пример Service Worker с кэшированием оболочки приложения.

При использовании Service Worker на TypeScript полезно явно указать правильный тип для self.

1// sw.ts
2export default null;
3declare const self: ServiceWorkerGlobalScope;
  • В TypeScript по умолчанию self рассматривается как any, поэтому без дополнительного указания типов вы не получите автодополнение или проверку типов для специфичных API Service Worker, таких как skipWaiting() или clients.
  • Указание типа ServiceWorkerGlobalScope включает автодополнение, предотвращает ошибки и обеспечивает более безопасную разработку, отделённую от обычных DOM-скриптов.

Базовый Service Worker (установка/активация/запросы)

В нем показано управление версиями кэша, предварительное кэширование при установке, удаление старых кэшей при активации и стратегии кэширования (cache-first — для статики, network-first — для API).

sw.ts (Минимальная настройка + скелет кэша)

 1const CACHE_NAME = 'app-shell-v1';
 2const STATIC_ASSETS = [
 3  '/',
 4  '/index.html',
 5  '/styles.css',
 6  '/main.js',
 7  '/fallback.png'
 8];
 9
10self.addEventListener('install', (event: ExtendableEvent) => {
11  // Pre-cache application shell
12  event.waitUntil(
13    caches.open(CACHE_NAME)
14      .then(cache => cache.addAll(STATIC_ASSETS))
15      // Activate immediately (optional: coordinate with client)
16      .then(() => self.skipWaiting())
17  );
18});
  • Во время события install статические ресурсы приложения (App Shell) предварительно кэшируются. С помощью вызова self.skipWaiting() новый Service Worker активируется немедленно, делая последний кэш доступным без ожидания следующего обращения.
 1self.addEventListener('activate', (event: ExtendableEvent) => {
 2  // Clean up old caches and take control of clients immediately
 3  event.waitUntil(
 4    caches.keys().then(keys =>
 5      Promise.all(keys
 6        .filter(key => key !== CACHE_NAME)
 7        .map(key => caches.delete(key)))
 8    ).then(() => self.clients.claim())
 9  );
10});
  • В событии activate старые версии кэшей удаляются, а Service Worker поддерживается в актуальном состоянии. Кроме того, вызвав self.clients.claim(), новый Service Worker может управлять всеми клиентами без необходимости обновления страницы.
 1self.addEventListener('fetch', (event: FetchEvent) => {
 2  const request = event.request;
 3  const url = new URL(request.url);
 4
 5  // Navigation requests (SPA) -> network-first with fallback to cached index.html
 6  if (request.mode === 'navigate') {
 7    event.respondWith(
 8      fetch(request).catch(() => caches.match('/index.html') as Promise<Response>)
 9    );
10    return;
11  }
12
13  // Simple API routing: network-first for /api/
14  if (url.pathname.startsWith('/api/')) {
15    event.respondWith(networkFirst(request));
16    return;
17  }
18
19  // Static assets: cache-first
20  event.respondWith(cacheFirst(request));
21});
  • В обработчике fetch можно перехватывать запросы и формировать ответ. Можно реализовать стратегии cache-first или network-first, которые полезны для поддержки офлайн-режима и улучшения производительности.
1self.addEventListener('message', (event: ExtendableMessageEvent) => {
2  const data = (event as any).data;
3  if (!data) return;
4
5  if (data.type === 'SKIP_WAITING') {
6    // Force the waiting service worker to become active
7    self.skipWaiting();
8  }
9});
  • Если получен SKIP_WAITING, вызов self.skipWaiting() позволяет немедленно активировать ожидающий Service Worker. В результате новая версия будет применена уже со следующего запроса, без необходимости перезагружать страницу.

Обзор практических стратегий кэширования

cache-first

Cache-first сначала проверяет кэш и возвращает ответ сразу, если он найден. Если нет — делает запрос в сеть и кладёт ответ в кэш. Это подходит для статических файлов.

 1async function cacheFirst(request: Request): Promise<Response> {
 2  const cache = await caches.open(CACHE_NAME);
 3  const cached = await cache.match(request);
 4  if (cached) {
 5    return cached;
 6  }
 7  const response = await fetch(request);
 8  if (response && response.ok) {
 9    cache.put(request, response.clone());
10  }
11  return response;
12}
  • Этот код демонстрирует реализацию cache-first. Если есть кэш — он возвращается; если нет — выполняется сетевой запрос и результат сохраняется в кэш. Это подходит для статических ресурсов, которые редко меняются (например, изображения или CSS).

network-first

Network-first сначала пытается получить данные из сети, а при ошибке возвращает данные из кэша. Это подходит для API, где важна актуальность данных.

 1async function networkFirst(request: Request): Promise<Response> {
 2  const cache = await caches.open(CACHE_NAME);
 3  try {
 4    const response = await fetch(request);
 5    if (response && response.ok) {
 6      cache.put(request, response.clone());
 7    }
 8    return response;
 9  } catch (err) {
10    const cached = await cache.match(request);
11    if (cached) return cached;
12    return new Response(JSON.stringify({ error: 'offline' }), {
13      status: 503,
14      headers: { 'Content-Type': 'application/json' }
15    });
16  }
17}
  • Этот код демонстрирует реализацию network-first. Если получен сетевой ответ — он сохраняется в кэш; если нет — возвращается кэшированная версия. Это подходит для ресурсов, где необходимы свежие данные, например, новости или ответы API.

stale-while-revalidate

stale-while-revalidate сначала возвращает данные из кэша и одновременно обновляет их из сети в фоновом режиме. Это балансирует скорость отклика и актуальность данных.

 1async function staleWhileRevalidate(request: Request, cacheName = CACHE_NAME): Promise<Response> {
 2  const cache = await caches.open(cacheName);
 3  const cachedResponse = await cache.match(request);
 4  const networkFetch = fetch(request).then(networkResponse => {
 5    if (networkResponse && networkResponse.ok) {
 6      cache.put(request, networkResponse.clone());
 7    }
 8    return networkResponse;
 9  }).catch(() => undefined);
10
11  // Return cached immediately if exists, otherwise wait network
12  return cachedResponse || (await networkFetch) || new Response('offline', { status: 503 });
13}
  • Этот код сразу возвращает кэш, если он есть, а новые данные подгружает в фоне и обновляет кэш. Это обеспечивает быстрый отклик пользователю и использовать обновлённый контент при следующем обращении; подходит для интерфейса и легких данных.

Оптимизация процесса обновления (уведомления и безопасная перезагрузка)

Обновления Service Worker не моментальны; новая версия останется в ожидании, пока не будут закрыты все открытые вкладки.

Здесь реализуется система оповещений клиента о готовности новой версии и безопасной перезагрузки по действию пользователя.

Уведомляйте клиента со стороны Service Worker, когда новая версия будет готова.

 1// In sw.ts: after 'activate' or when new version is ready, broadcast a message
 2async function notifyClientsUpdated() {
 3  const all = await self.clients.matchAll({ type: 'window' });
 4  for (const client of all) {
 5    client.postMessage({ type: 'SW_UPDATED' });
 6  }
 7}
 8
 9// e.g., call this at the end of 'activate'
10self.addEventListener('activate', (event) => {
11  event.waitUntil((async () => {
12    if ('navigationPreload' in self.registration) {
13      await self.registration.navigationPreload.enable();
14    }
15    // cache cleanup
16    const cacheNames = await caches.keys();
17    await Promise.all(
18      cacheNames.map((name) => {
19        if (name !== CACHE_NAME) {
20          return caches.delete(name);
21        }
22      })
23    );
24
25    await self.clients.claim();
26    await notifyClientsUpdated();
27  })());
28});
  • В этом коде функция notifyClientsUpdated вызывается в конце события activate для уведомления всех клиентов о готовности новой версии. clients.claim() — это метод, который немедленно переводит все открытые страницы (клиенты) под контроль только что активированного Service Worker. Обычно Service Worker начинает контролировать страницу только при следующей загрузке, однако используя clients.claim(), вы можете получить контроль немедленно, без перезагрузки.

Показать интерфейс обновления на клиенте и перезагрузить страницу по действию пользователя

1// in app startup
2navigator.serviceWorker.addEventListener('message', (e) => {
3  if (e.data?.type === 'SW_UPDATED') {
4    // Show a non-intrusive toast or banner: "New version available"
5    // When user clicks "Reload", call:
6    window.location.reload();
7  }
8});
  • Клиент получает SW_UPDATED через событие message и отображает уведомление о доступности обновления в интерфейсе. Когда пользователь решает перезагрузить, выполняется window.location.reload(), обновляя устаревшие HTML, CSS и другие ресурсы на странице до последних версий. Это гарантирует, что кэш и управление через Service Worker, переключённые с помощью clients.claim(), применяются ко всей странице.

Офлайн-резерв (Fallback)

Подготовьте /offline.html для критических навигаций и обеспечьте минимальный интерфейс, который дает смысл даже без изображений или шрифтов. Если вызов API не удается — покажите последнее кэшированное состояние (если оно есть) и попробуйте повторить запрос в фоне для улучшения UX.

Пример реализации

 1// sw.ts
 2const CACHE_NAME = 'app-cache-v1';
 3
 4// Cache offline.html during install
 5self.addEventListener('install', (event) => {
 6  event.waitUntil((async () => {
 7    const cache = await caches.open(CACHE_NAME);
 8    await cache.addAll(['/offline.html']);
 9  })());
10});
11
12// Handle fetch requests
13self.addEventListener('fetch', (event) => {
14  const request = event.request;
15
16  // Navigation requests (e.g., page transitions)
17  if (request.mode === 'navigate') {
18    event.respondWith((async () => {
19      try {
20        // Try to fetch from the network as usual
21        return await fetch(request);
22      } catch (err) {
23        // On failure, return offline fallback page
24        const cache = await caches.open(CACHE_NAME);
25        return await cache.match('/offline.html') as Response;
26      }
27    })());
28  }
29});
  • Добавьте /offline.html в предварительный кэш при событии install, чтобы вернуть хотя бы минимальную страницу при отсутствии сети.
  • В событии fetch вы можете отслеживать навигационные запросы с помощью request.mode === 'navigate' и адресно реагировать на переходы между страницами.
  • Возвращайте /offline.html при сбое сети — так страница покажется даже в офлайне.

Обмен сообщениями между клиентом и Service Worker

Поскольку Service Worker работает независимо от жизненного цикла страницы, двусторонняя отправка сообщений важна для уведомлений о состояниях и выполнения команд. Указание типов для сообщений помогает предотвратить ошибочную отправку, обеспечивает автодополнение кода и делает реализацию более надёжной.

Пример кода

  1. Определение типов сообщений
1type SwToClient =
2  | { type: 'SW_READY' }
3  | { type: 'SW_UPDATED' }
4  | { type: 'CACHE_CLEARED' }
5  | { type: 'PING'; ts: number };
6
7type ClientToSw =
8  | { type: 'CLEAR_CACHE' }
9  | { type: 'PING'; ts: number };
  • SwToClient — тип сообщений, которые отправляет Service Worker клиенту.
  • ClientToSw — тип сообщений, которые отправляет клиент Service Worker.
  • Это позволяет уточнить типы событий, которыми можно обмениваться при двусторонней коммуникации.
  1. Обработка на стороне Service Worker
 1self.addEventListener('message', (event) => {
 2  const data = event.data as ClientToSw;
 3  if (data?.type === 'CLEAR_CACHE') {
 4    event.waitUntil((async () => {
 5      const keys = await caches.keys();
 6      await Promise.all(keys.map((k) => caches.delete(k)));
 7      await broadcast({ type: 'CACHE_CLEARED' });
 8    })());
 9  } else if (data?.type === 'PING') {
10    event.source?.postMessage({ type: 'PING', ts: data.ts } as SwToClient);
11  }
12});
  • Service Worker получает сообщения от клиента и обрабатывает их по типу.
  • Для CLEAR_CACHE он очищает кэш и отправляет всем клиентам сообщение CACHE_CLEARED.
  • Для PING отвечает исходному клиенту сообщением PING с временной меткой.
  1. Оповещение всех клиентов из Service Worker
1async function broadcast(msg: SwToClient) {
2  const clients = await self.clients.matchAll({ includeUncontrolled: true });
3  for (const c of clients) c.postMessage(msg);
4}
  • Используйте clients.matchAll, чтобы получить все вкладки.
  • Отправляя postMessage каждому, вы можете рассылать сообщения.
  • Это может использоваться для уведомлений об обновлении (SW_UPDATED) и уведомлений об ошибках.
  1. Обработка на клиентской стороне
1navigator.serviceWorker.controller?.postMessage({
2  type: 'PING',
3  ts: Date.now()
4} as ClientToSw);
  • Отправив PING с клиента и получив ответ от Service Worker, вы можете проверить, что двусторонняя коммуникация работает правильно. Это облегчает проверку состояния соединения и обработку сообщений.
 1navigator.serviceWorker.addEventListener('message', (e) => {
 2  const msg = e.data as SwToClient;
 3  switch (msg.type) {
 4    case 'SW_READY':
 5      console.log('Service Worker is ready');
 6      // Example: hide loading spinner or enable offline UI
 7      break;
 8    case 'SW_UPDATED':
 9      console.log('A new version of the Service Worker is available');
10      // Example: show update notification or reload prompt
11      const shouldReload = confirm('A new version is available. Reload now?');
12      if (shouldReload) {
13        window.location.reload();
14      }
15      break;
16    case 'CACHE_CLEARED':
17      console.log('Cache cleared');
18      // Example: show confirmation message to user
19      alert('Cache has been successfully cleared.');
20      break;
21    case 'PING':
22      console.log(`Received PING response, ts=${msg.ts}`);
23      break;
24  }
25});
  • {^ i18n_speak クライアント側では Service Worker から送信されるメッセージを受信し、種類に応じて処理を分岐します。SW_READY は初期化完了、SW_UPDATED は新バージョン検出、CACHE_CLEARED はキャッシュ削除完了、PING は通信確認を示します。各メッセージに応じて、UI の更新やリロード、通知表示などを行います。^}

Преимущества типизированного обмена сообщениями

  • Типизация сообщений делает ясным, какие сообщения можно отправлять и получать, а автодополнение и проверка типов повышают безопасность.
  • postMessage обеспечивает один-к-одному обмен, а рассылка — один-ко-многим.
  • Можно просто реализовать важные функции: уведомления об обновлениях (SW_UPDATED), управление кэшем (CACHE_CLEARED), проверку работоспособности (PING).

Резюме

  • Использование TypeScript добавляет типобезопасность в вызовы Service Worker API и обмен сообщениями, значительно повышая эффективность разработки и удобство поддержки.
  • Понимание событий жизненного цикла (install, activate, fetch) и правильный выбор стратегии кэширования (например, cache-first или network-first) обеспечивают лучший пользовательский опыт.
  • Для работы важно понимать управление версиями кэша и процессы обновления (updatefound, waiting, SKIP_WAITING и др.).
  • Использование типизированных сообщений для взаимодействия между клиентом и Service Worker помогает предотвратить ошибки реализации и сделать систему легко расширяемой и поддерживаемой в долгосрочной перспективе.

Вы можете следовать этой статье, используя Visual Studio Code на нашем YouTube-канале. Пожалуйста, также посмотрите наш YouTube-канал.

YouTube Video