Урок
⏱️ ~45 хвилин 🎓 Середній рівень 🛠️ Service Workers · PWA · JavaScript

Offline-first застосунок із Service Workers

Service Worker — це фоновий JavaScript-файл, який перехоплює мережеві запити, даючи змогу віддавати вашу симуляцію з кешу, коли немає інтернет-з'єднання. Цей урок охоплює життєвий цикл SW, стратегії cache-first та stale-while-revalidate, Web App Manifest для встановлюваності на головний екран і практичне порівняння ванільного SW з Workbox.

Передумови

Життєвий цикл Service Worker

Розуміння життєвого циклу усуває проблему №1 із SW: чому мої зміни не відображаються?

Щоб примусово оновити SW під час розробки: DevTools → Application → Service Workers → «Update on reload» і «Bypass for network». Або змініть константу CACHE_VERSION, щоб запустити install/activate.

Реєстрація SW зі сторінки

// У вашому головному HTML- або JS-файлі
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const reg = await navigator.serviceWorker.register('/sw.js', {
        scope: '/', // SW керує всіма сторінками під /
      });
      console.log('SW registered, scope:', reg.scope);

      // Опціонально: слухаємо оновлення
      reg.addEventListener('updatefound', () => {
        const newSW = reg.installing;
        newSW.addEventListener('statechange', () => {
          if (newSW.state === 'installed' && navigator.serviceWorker.controller) {
            // Доступний новий вміст — показуємо користувачу запит "Оновити"
            console.log('New version available!');
          }
        });
      });
    } catch (err) {
      console.error('SW registration failed:', err);
    }
  });
}

Подія install — попереднє кешування ресурсів

Під час встановлення завантажте та збережіть кожен критичний ресурс. SW не активується, доки всі ресурси не закешуються успішно:

// sw.js
const CACHE_VERSION = 'v2'; // збільшіть це, щоб запустити оновлення
const CACHE_NAME = `sim-cache-${CACHE_VERSION}`;

const PRECACHE_ASSETS = [
  '/',
  '/index.html',
  '/offline.html',
  '/shared/theme.css',
  '/shared/components.css',
  '/shared/components.js',
  '/manifest.json',
  // Додайте HTML-файли своїх симуляцій:
  '/fluid/index.html',
  '/pathfinding/index.html',
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => {
      console.log('Precaching assets...');
      return cache.addAll(PRECACHE_ASSETS);
    })
  );
  self.skipWaiting(); // Не чекаємо — перебираємо контроль негайно
});

Подія fetch — стратегія cache-first

Cache-first: спершу пробуємо кеш. Якщо знайдено — повертаємо одразу. Якщо ні — завантажуємо з мережі та кешуємо відповідь на наступний раз. Найкраще для статичних ресурсів:

self.addEventListener('fetch', event => {
  // Обробляємо лише GET-запити
  if (event.request.method !== 'GET') return;

  event.respondWith(cacheFirst(event.request));
});

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached; // ← миттєво віддаємо з кешу

  try {
    const response = await fetch(request);
    if (response.ok) {
      // Кешуємо клон (відповідь можна спожити лише раз)
      const cache = await caches.open(CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    // Повністю офлайн і немає в кеші
    return caches.match('/offline.html');
  }
}

Cache-First

Віддаємо з кешу. Швидко, працює офлайн. Застаріле, доки SW не оновиться. Використовуйте для: JS, CSS, шрифтів.

Network-First

Пробуємо мережу. У разі невдачі повертаємось до кешу. Свіже онлайн, придатне офлайн. Використовуйте для: API, HTML.

Stale-While-Revalidate

Віддаємо кеш одразу, потім оновлюємо кеш у фоні. Завжди швидко, згодом свіже. Використовуйте для: аватарів, некритичних ресурсів.

Network-Only

Завжди завантажуємо. Ніколи не кешуємо. Використовуйте для: аналітики, платежів, усього, що має бути свіжим.

Activate — очищення старих кешів

Коли активується новий SW, видаляйте кеші попередніх версій, щоб звільнити сховище:

self.addEventListener('activate', event => {
  event.waitUntil(
    Promise.all([
      // Видаляємо всі кеші, крім поточної версії
      caches.keys().then(keys =>
        Promise.all(
          keys
            .filter(key => key !== CACHE_NAME)
            .map(key => {
              console.log('Deleting old cache:', key);
              return caches.delete(key);
            })
        )
      ),
      // Негайно перебираємо контроль над усіма відкритими вкладками
      self.clients.claim(),
    ])
  );
});

Стратегія 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;
  }).catch(() => null); // ігноруємо невдачу, якщо офлайн

  // Повертаємо кеш одразу або чекаємо на мережу, якщо кешу немає
  return cached ?? fetchPromise;
}

// Застосовуємо різні стратегії для різних типів запитів:
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // CSS/JS: cache-first (версіоновані, рідко змінюються)
  if (url.pathname.match(/\.(css|js)$/)) {
    event.respondWith(cacheFirst(event.request));
    return;
  }

  // HTML-сторінки: stale-while-revalidate
  if (event.request.headers.get('accept')?.includes('text/html')) {
    event.respondWith(staleWhileRevalidate(event.request));
    return;
  }

  // Усе інше: network-first
  event.respondWith(networkFirst(event.request));
});

Web App Manifest — робимо застосунок встановлюваним

За наявності коректного маніфесту та зареєстрованого SW Chrome/Edge показують запит «Встановити». Застосунок можна додати на головний екран, і він відкривається на весь екран без елементів інтерфейсу браузера:

// manifest.json
{
  "name": "3D Simulations",
  "short_name": "3DSims",
  "description": "Interactive physics and algorithm simulations",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0a0a10",
  "theme_color": "#22c55e",
  "orientation": "any",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/icon-512-maskable.png", "sizes": "512x512",
      "type": "image/png", "purpose": "maskable" }
  ],
  "screenshots": [
    { "src": "/screenshots/fluid.jpg", "sizes": "1280x720", "type": "image/jpeg",
      "form_factor": "wide" }
  ]
}
<!-- У <head> кожної сторінки: -->
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#22c55e" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />

Чекліст PWA: SW має працювати на HTTPS (або localhost), маніфест повинен мати коректні іконки, а start_url має бути в межах області дії SW. Для повного аудиту перевірте Lighthouse (DevTools → Lighthouse → Progressive Web App).

Продовжуйте навчання

🛠

Експериментуйте на Майданчику

Протестуйте свою симуляцію офлайн — пишіть і запускайте код Three.js просто в браузері.

Відкрити Майданчик → Переглянути симуляцію ↗