Service Worker на практике: стратегия stale-while-revalidate (+ готовый гист)
Что делает stale-while-revalidate (SWR)
Идея простая:
-
Сразу отдать то, что уже лежит в кэше (stale).
-
Параллельно сходить в сеть за свежей версией (revalidate).
-
Бесшовно обновить кэш «в фоне», чтобы следующий визит был уже со свежими данными.
Пользователь видит быстрый отклик, а мы — постоянно «подтягиваем» актуальный контент.
Когда применять SWR
-
Статика: CSS/JS/шрифты/картинки (особенно CDN).
-
API, не критичное к абсолютной свежести: теги, рейтинги, рекомендации.
-
Производственные панели — с коротким таймаутом сети (если сеть долго молчит, вернём кэш и не «заморозим» UI).
Где не стоит: HTML-навигации. Для них лучше network-first c офлайн-фолбэком — иначе можно долго показывать устаревшие страницы.
Регистрация
<!-- register-sw.js --> <script src="/register-sw.js" defer></script>
// register-sw.js (фрагмент) if ('serviceWorker' in navigator) { window.addEventListener('load', async () => { const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' }); // Уведомим страницу, что доступно обновление SW reg.addEventListener('updatefound', () => { const sw = reg.installing; sw?.addEventListener('statechange', () => { if (sw.state === 'installed' && navigator.serviceWorker.controller) { window.dispatchEvent(new CustomEvent('sw.update.available')); } }); }); }); // По клику «Обновить» можно активировать новую версию: window.activateNewSW = async () => { const reg = await navigator.serviceWorker.getRegistration(); reg?.waiting?.postMessage('SKIP_WAITING'); }; }
Сам Service Worker
/* sw.js — базовая реализация SWR */ const VERSION = 'v1.0.0'; const STATIC_CACHE = `static-${VERSION}`; const RUNTIME_CACHE = `runtime-${VERSION}`; const PRECACHE = ['/', '/offline.html']; self.addEventListener('install', (e) => { self.skipWaiting(); e.waitUntil(caches.open(STATIC_CACHE).then((c) => c.addAll(PRECACHE))); }); self.addEventListener('activate', (e) => { e.waitUntil((async () => { const keep = new Set([STATIC_CACHE, RUNTIME_CACHE]); const keys = await caches.keys(); await Promise.all(keys.map((k) => keep.has(k) ? null : caches.delete(k))); await self.clients.claim(); })()); }); self.addEventListener('message', (e) => { if (e.data === 'SKIP_WAITING') self.skipWaiting(); }); self.addEventListener('fetch', (event) => { const req = event.request; if (req.method !== 'GET') return; const url = new URL(req.url); // HTML-навигации — network-first + офлайн-страница if (req.mode === 'navigate') { event.respondWith((async () => { try { const fresh = await fetch(req); (await caches.open(RUNTIME_CACHE)).put(req, fresh.clone()); return fresh; } catch { return (await caches.open(STATIC_CACHE)).match('/offline.html'); } })()); return; } // Статика и API — SWR const isAsset = ['style','script','image','font'].includes(req.destination) || url.pathname.match(/\.(css|js|mjs|woff2?|ttf|otf|png|jpe?g|webp|avif|svg)$/i); const isApi = url.origin === self.location.origin && url.pathname.startsWith('/api/'); if (isAsset || isApi) { event.respondWith(staleWhileRevalidate(req, RUNTIME_CACHE, { ignoreSearch: req.destination === 'image', networkTimeoutMs: isApi ? 2000 : undefined })); } }); async function staleWhileRevalidate(request, cacheName, opts = {}) { const cache = await caches.open(cacheName); const cachedPromise = cache.match(request, { ignoreSearch: !!opts.ignoreSearch }); const networkPromise = (async () => { try { let controller, signal; if (opts.networkTimeoutMs) { controller = new AbortController(); signal = controller.signal; setTimeout(() => controller.abort(), opts.networkTimeoutMs); } const res = await fetch(request, signal ? { signal } : undefined); if (res && (res.ok || res.type === 'opaque')) cache.put(request, res.clone()); return res; } catch { return null; } })(); const cached = await cachedPromise; if (cached) { networkPromise; return cached; } // мгновенно отдаём кэш const network = await networkPromise; // иначе ждём сеть return network || new Response('', { status: 504 }); }
Серверные заголовки для sw.js
# nginx-snippet.conf location = /sw.js { add_header Cache-Control "no-store, max-age=0, must-revalidate" always; }
Отладка и наблюдение
-
Chrome DevTools → Application → Service Workers: обновление, остановка, симуляция offline.
-
Network → Disable cache: проверка сетевого пути без влияния HTTP-кэша.
-
Application → Cache Storage: смотрим, что реально лежит в кэше SW.
-
Логи: временно добавьте
console.logв SW (видно в DevTools при открытой вкладке SW).
Частые тонкости и грабли
-
Opaque-ответы (
no-cors) кэшируются, но их нельзя читать и валидировать; решайте по политике безопасности проекта. -
Версионирование кэшей: меняйте
VERSIONпри релизе, чистите старые кэши вactivate. -
HTML и SWR — осторожно: для страниц лучше
network-first, чтобы пользователь не «застрял» на старой версии. -
Квоты хранилища: на мобильных браузерах место ограничено; для картинок добавляйте экспирацию (см. Workbox или IDB-плагин).
-
Правила кэширования: не кешируйте приватные ответы (личный кабинет) без явной необходимости.
Как добавить «срок годности» без Workbox
Workbox решает задачу элегантно (ExpirationPlugin), но если нужен ванильный SW, заведите мини-хранилище в IndexedDB (ключ = URL, значение = timestamp) и периодически удаляйте старые записи:
// Псевдокод: после cache.put(request, responseClone) await idb.set(request.url, Date.now()); // где-то в activate/fetch: пробегитесь по ключам и удалите просроченные
Это 30–40 строк с idb-keyval и подходит для простых правил (maxAgeSeconds, maxEntries).
Проверка эффекта: что даст SWR
-
Время до повторного отображения (повторные визиты) → резко падает.
-
Нагрузка на бэкенд/CDN → снижается за счёт попадания в кэш SW.
-
CWV: косвенно помогает LCP/INP на повторных сессиях (быстрее статика).
Полностью рабочий пример + Nginx-сниппет лежит в архиве:
Скачать zip
(файлы:sw.js,register-sw.js,offline.html,nginx-snippet.conf)
ссылка на оригинал статьи https://habr.com/ru/articles/938060/
Добавить комментарий