Девлог #19 — Перехід в офлайн: Service Worker, стратегії кешування & Lighthouse 100

3D Simulations тепер повністю працює в офлайн-режимі. Будь-яка симуляція, яку ви вже відвідували, завантажується миттєво з кешу — навіть без інтернету. Ось повна технічна історія: чому ми обрали певні стратегії кешування, як попередньо кешуємо пакети симуляцій і які кроки знадобилися, щоб набрати 100/100 балів у PWA-аудиті Lighthouse.

0 мс
час повторної навігації
(з кешу)
100
Оцінка Lighthouse
PWA
200+
сторінок симуляцій
можна кешувати заздалегідь

Навіщо офлайн-режим?

Фізичні симуляції — це обчислювально важкі сторінки: Three.js, власні WASM-модулі та воркери симуляцій часто важать 200–800 КБ на сторінку. На повільному чи нестабільному з'єднанні кожен візит обертається болісним початковим завантаженням. Кешування браузером через заголовки Cache-Control допомагає, але шар service worker дає нам повний контроль над тим, які ресурси кешуються, коли і наскільки застарілими їм дозволено бути.

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

Service worker — це скрипт, який браузер встановлює один раз, а потім запускає як постійний фоновий проксі. Його життєвий цикл має три ключові фази: install (попереднє кешування критичних ресурсів), activate (очищення старих кешів) та fetch (перехоплення мережевих запитів).

// sw.js — install: попереднє кешування оболонки та спільних ресурсів
const PRECACHE = 'shell-v4';
const PRECACHE_URLS = [
  '/',
  '/index.html',
  '/offline.html',
  '/shared/theme.css',
  '/shared/components.css',
  '/shared/components.js',
  '/manifest.json',
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(PRECACHE)
      .then(cache => cache.addAll(PRECACHE_URLS))
      .then(() => self.skipWaiting())  // активувати негайно
  );
});

Стратегії кешування за типом ресурсу

Не всі ресурси варто кешувати однаково. Ми використовуємо чотири стратегії:

Cache-First
Віддавати з кешу, якщо доступно; звертатися до мережі лише при промаху. Найкраще підходить для версійних ресурсів, вміст яких за тією ж URL-адресою ніколи не змінюється (бандл Three.js, WASM, шрифти).
Stale-While-Revalidate
Віддавати з кешу миттєво (нульова затримка), а потім у фоновому режимі отримувати свіжу копію з мережі для наступного разу. Використовується для HTML-сторінок і спільного CSS компонентів.
Network-First
Спочатку намагатися звернутися до мережі; при невдачі — резервний перехід до кешу. Використовується для головної сторінки блогу та сторінок категорій, де застарілий вміст міг би ввести користувачів в оману.
Network-Only
Ніколи не кешується. Використовується для RSS-стрічки, sitemap.xml та ендпоінтів аналітики, де застарілі дані не мають сенсу.
// sw.js — fetch: маршрутизація до потрібної стратегії
self.addEventListener('fetch', event => {
  const { request } = event;
  const url = new URL(request.url);

  // Пропускаємо не-GET запити та cross-origin
  if (request.method !== 'GET' || url.origin !== location.origin) return;

  // Версійні ресурси → cache-first
  if (/\.(wasm|js|css)$/.test(url.pathname) && /[?&]v=/.test(url.search)) {
    event.respondWith(cacheFirst(request));
    return;
  }

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

async function staleWhileRevalidate(request, cacheName) {
  const cache = await caches.open(cacheName);
  const cached = await cache.match(request);
  const fetchPromise = fetch(request).then(response => {
    cache.put(request, response.clone());
    return response;
  });
  return cached ?? fetchPromise;
}

async function cacheFirst(request) {
  const cached = await caches.match(request);
  if (cached) return cached;
  const response = await fetch(request);
  const cache = await caches.open('assets-v4');
  cache.put(request, response.clone());
  return response;
}

Кешування сторінок симуляцій під час виконання

Симуляції надто великі, щоб кешувати їх усі одразу під час встановлення — це зайняло б кілька сотень мегабайт кешу і затримало б встановлення service worker. Натомість ми попередньо кешуємо ресурси симуляції під час першого відвідування користувачем, щоб другий візит був миттєвим.

Спеціальний SIM_CACHE має політику максимального терміну зберігання: записи, старіші за 7 днів, видаляються під час наступної події activate за допомогою сховища метаданих із часовими мітками в IndexedDB.

Запрошення до встановлення

Коли виконуються критерії встановлюваності браузера (HTTPS + service worker + маніфест з іконками + display: standalone), спрацьовує подія beforeinstallprompt. Ми перехоплюємо її та показуємо стриманий банер встановлення у нашому футері:

let deferredPrompt;
window.addEventListener('beforeinstallprompt', e => {
  e.preventDefault();            // зупиняємо стандартну міні-інформаційну панель браузера
  deferredPrompt = e;
  showInstallBanner();
});

function showInstallBanner() {
  const banner = document.getElementById('install-banner');
  if (!banner) return;
  banner.hidden = false;
  banner.querySelector('button').addEventListener('click', async () => {
    banner.hidden = true;
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    console.log('Install outcome:', outcome);  // 'accepted' або 'dismissed'
    deferredPrompt = null;
  });
}

Оцінка Lighthouse PWA 100 — чекліст

Ліміти сховища кешу: браузери зазвичай надають до 60% доступного дискового простору для сховища походження (яке включає IndexedDB, CacheStorage та localStorage). На пристрої з 32 ГБ пам'яті та 15 ГБ вільного місця це ~9 ГБ. Ми залишаємось значно нижче цього ліміту, видаляючи ресурси симуляцій, старіші за 7 днів, і обмежуючи кешовані ресурси кожної симуляції приблизно 5 МБ.