Классические одностраничные приложения (SPA) обладают критической уязвимостью: они полностью зависят от непрерывного интернет-соединения. При входе в лифт, подземный переход, экранированный склад или при нестабильном 3G-соединении («Lie-Fi») пользователь сталкивается с белым экраном, бесконечными спиннерами, упавшими сетевыми запросами и потерей несохраненных форм.
Архитектура Progressive Web Apps (PWA) переводит веб-приложение в парадигму Offline-First. В этой модели локальное хранилище и кэш устройства считаются первичным источником истины, а удаленная сеть — каналом асинхронной синхронизации.
Ключевым звеном PWA выступает Service Worker. Он перехватывает все исходящие HTTP-запросы приложения, мгновенно отдает закэшированный каркас интерфейса за константное время O(1) и гарантирует выполнение фоновых мутаций с помощью Background Sync API.
Service Worker работает в изолированном потоке браузера, не блокирует Main Thread, не имеет прямого доступа к DOM и продолжает функционировать даже после закрытия всех вкладок приложения. Перехватывая сетевой стек браузера, он реализует детерминированные стратегии кэширования (Cache-First, Stale-While-Revalidate, Network-First), обеспечивая мгновенный запуск UI (Time to Interactive менее 50 мс) независимо от состояния сети.
Ментально Service Worker можно представить как локальный пограничный прокси-сервер (Local Reverse Proxy / Gateway), установленный прямо на устройстве пользователя между JavaScript-кодом React и физической сетевой картой:
┌──────────────────────────────────────────────────────────────┐
│ REACT 19 SPA (Main Thread) │
│ fetch('/api/catalog') │ fetch('/assets/bundle.js') │
└──────────────────────────────┬───────────────────────────────┘
│ Перехват сетевого стека
▼ (Событие self.onfetch)
┌──────────────────────────────────────────────────────────────┐
│ SERVICE WORKER (Programmable Network Proxy) │
│ │
│ ┌─────────────────────────┐ ┌────────────────────────┐ │
│ │ Workbox Router │ │ Background Sync Queue │ │
│ │ - Cache-First (Static) │ │ (Отложенные мутации │ │
│ │ - Stale-While-Rev (API)│ │ в IndexedDB) │ │
│ └────────────┬────────────┘ └───────────┬────────────┘ │
└───────────────┼──────────────────────────────┼───────────────┘
│ │
┌───────┴────────┐ │
▼ ▼ ▼
┌───────────────┐ ┌──────────────┐ ┌──────────────┐
│ Cache Storage │ │ Интернет │ │ Remote API │
│ (Диск: 0–5 мс)│ │ (Remote CDN) │ │ (При онлайн) │
└───────────────┘ └──────────────┘ └──────────────┘В отличие от стандартного браузерного HTTP-кэша, подчиняющегося исключительно серверным заголовкам Cache-Control, Cache Storage полностью подконтролен коду Service Worker.
Service Worker является строго управляемым конечным автоматом состояний (State Machine):
scope).install. В этот момент скачивается и закладывается в дисковый кэш минимальный каркас приложения (App Shell: HTML, критические JS/CSS-чанки, шрифты).activate. Воркер производит ротацию кэша: удаляет устаревшие версии хранилища от предыдущих деплоев и перехватывает управление активными вкладками.fetch, при восстановлении сети — sync, при серверных нотификациях — push.React-клиент вызывает navigator.serviceWorker.register('/sw.js'). Браузер инициирует загрузку файла и вычисляет его Scope.
Событие self.oninstall скачивает App Shell. Вызов self.skipWaiting() форсирует переход воркера в активное состояние в обход ожидания.
Событие self.onactivate удаляет ключи старых версий кэшей (v1 -> v2). Метод self.clients.claim() захватывает текущие открытые страницы.
Слушатель self.onfetch маршрутизирует запросы по стратегиям Workbox, а self.onsync сбрасывает отложенные мутации при появлении сети.
Новая версия приложения была задеплоена на прод, и файл sw.js обновился. Пользователь открыл три вкладки приложения. Что произойдет с новой версией Service Worker, если в событии install НЕ был вызван метод self.skipWaiting()?
Сотрудники логистических центров и курьеры работают в бетонных ангарах, подвалах и грузовых лифтах, где сигнал сотовой сети периодически исчезает. Штрихкоды и накладные сканируются локально в IndexedDB, а Service Worker через Background Sync API гарантированно отправляет накопленные мутации на сервер, как только устройство ловит Wi-Fi.
В условиях медленного мобильного интернета (3G / нестабильный 4G) каждая секунда ожидания страницы снижает конверсию на 7–10%. Прекэширование критического CSS, шрифтов и каркаса каталога с отдачей из Cache Storage за 0–5 мс создает ощущение нативного приложения, а корзина сохраняется даже при обрыве связи.
Пользователи авиарейсов и поездов метро могут заранее закэшировать статьи, подкасты и музыкальные треки в Cache Storage. Приложение определяет статус сети через реактивный хук и переключает интерфейс в автономный режим со списком доступных оффлайн-материалов.
Корпоративные порталы, использующие стратегию Stale-While-Revalidate для статики и справочников, запускаются мгновенно даже при высокой задержке корпоративного VPN-шлюза (latency более 300 мс).
Библиотека Google Workbox является индустриальным стандартом для декларативного описания стратегий маршрутизации сетевых запросов. Существует пять базовых оффлайн-стратегий:
1. Cache-First (Fastest):
Request ──► [Cache Storage] ──(Hit)──► Return (0-5ms)
│
(Miss)
▼
[Network] ──► Save to Cache ──► Return
2. Stale-While-Revalidate (Balanced):
Request ──► [Cache Storage] ──(Hit)──► Return immediately
│
(Параллельный фоновый запрос)
▼
[Network] ──► Update Cache in background
3. Network-First (Freshness with Fallback):
Request ──► [Network (timeout: 3s)] ──(Success)──► Update Cache ──► Return
│
(Timeout / Offline)
▼
[Cache Storage] ──(Hit)──► Return stale dataКаждый сетевой запрос (CSS, JS, fetch API) перехватывается слушателем self.onfetch. Проверяются метод и протокол.
Запросы к статической CDN направляются в Cache-First, запросы к /api/* — в Stale-While-Revalidate с плагином ExpirationPlugin.
При падении сети и промахе мимо кэша для HTML-навигации возвращается заранее подготовленная страница /offline.html.
Ошибочные POST/PUT запросы перехватываются BackgroundSyncPlugin и складываются в очередь IndexedDB до появления связи.
Вы разрабатываете PWA мобильного интернет-магазина. Карточки каталога товаров должны мгновенно отображаться при открытии приложения (0–5 мс), но цены и остатки товаров на складе должны регулярно актуализироваться в фоне при наличии интернета. Какая стратегия Workbox является единственно правильной?
Построим законченную промышленную PWA-инфраструктуру для React 19:
public/sw.js: Полнофункциональный Service Worker с Workbox, стратегиями кэширования, Background Sync и обработкой обновления SKIP_WAITING.src/hooks/useServiceWorkerUpdate.ts: Реактивный хук управления обновлением Service Worker и перехватом смены контроллера.src/hooks/useOnlineStatus.ts: Хук статуса сети на базе useSyncExternalStore без ошибок гидратации SSR.src/components/pwa/PwaOfflineManager.tsx: Продакшн-компонент с диалогом установки PWA, баннером выхода в оффлайн и уведомлением об обновлении приложения.// Service Worker: Fullstack Agentic Lab Enterprise PWA Core v2.4.0
// Использует Workbox из официального CDN Google
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
if (workbox) {
console.log('[SW]: Workbox успешно инициализирован.');
// Принудительное управление клиентами
workbox.core.clientsClaim();
// 1. Прекэширование критического каркаса приложения (App Shell)
const PRECACHE_ASSETS = [
{ url: '/', revision: '2026-v2-core' },
{ url: '/manifest.webmanifest', revision: '2026-v2-manifest' },
{ url: '/offline.html', revision: '2026-v2-offline-page' },
];
workbox.precaching.precacheAndRoute(PRECACHE_ASSETS);
// 2. Стратегия Cache-First для неизменяемой статики: шрифты, логотипы, скомпилированные CSS/JS
workbox.routing.registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'font' ||
request.destination === 'image',
new workbox.strategies.CacheFirst({
cacheName: 'agenticlab-static-assets-v2',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
new workbox.expiration.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 дней
purgeOnQuotaError: true,
}),
],
})
);
// 3. Стратегия Stale-While-Revalidate для REST / GraphQL API запросов
workbox.routing.registerRoute(
({ url, request }) => url.pathname.startsWith('/api/') && request.method === 'GET',
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'agenticlab-api-cache-v2',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [200],
}),
new workbox.expiration.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60, // 24 часа
}),
],
})
);
// 4. Стратегия Network-First с быстрым таймаутом для HTML-навигации
const navigationHandler = new workbox.strategies.NetworkFirst({
cacheName: 'agenticlab-pages-cache-v2',
networkTimeoutSeconds: 3,
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [200],
}),
],
});
workbox.routing.registerRoute(({ request }) => request.mode === 'navigate', async (params) => {
try {
return await navigationHandler.handle(params);
} catch {
// При полном отсутствии сети и кэша отдаем оффлайн-заглушку
return (await caches.match('/offline.html')) || Response.error();
}
});
// 5. Background Sync: гарантированная отправка мутаций при появлении сети
const bgSyncPlugin = new workbox.backgroundSync.BackgroundSyncPlugin('offline-mutations-queue', {
maxRetentionTime: 24 * 60, // Очередь живет 24 часа (в минутах)
});
workbox.routing.registerRoute(
({ url, request }) => url.pathname.startsWith('/api/mutations/') && request.method === 'POST',
new workbox.strategies.NetworkOnly({
plugins: [bgSyncPlugin],
}),
'POST'
);
// 6. Слушатель межпроцессного сообщения для немедленного обновления
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
console.log('[SW]: Получена команда SKIP_WAITING. Активация нового воркера...');
self.skipWaiting();
}
});
} else {
console.error('[SW]: Ошибка загрузки скриптов Workbox.');
}'use client';
import * as React from 'react';
/**
* Подписка на нативные браузерные события смены сетевого статуса
*/
function subscribe(callback: () => void): () => void {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getSnapshot(): boolean {
return navigator.onLine;
}
function getServerSnapshot(): boolean {
// На сервере всегда предполагаем наличие сети для предотвращения hydration mismatch
return true;
}
/**
* Хук отслеживания статуса интернет-соединения
* @returns boolean: true если сеть доступна, false если устройство оффлайн
*/
export function useOnlineStatus(): boolean {
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}'use client';
import * as React from 'react';
interface ServiceWorkerUpdateState {
isUpdateAvailable: boolean;
registration: ServiceWorkerRegistration | null;
updateApp: () => void;
}
/**
* Хук управления жизненным циклом и обновлением Service Worker
*/
export function useServiceWorkerUpdate(): ServiceWorkerUpdateState {
const [isUpdateAvailable, setIsUpdateAvailable] = React.useState(false);
const [registration, setRegistration] = React.useState<ServiceWorkerRegistration | null>(null);
React.useEffect(() => {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return;
}
let refreshing = false;
// Автоматическая перезагрузка страницы после смены контроллера
const handleControllerChange = () => {
if (!refreshing) {
refreshing = true;
window.location.reload();
}
};
navigator.serviceWorker.addEventListener('controllerchange', handleControllerChange);
navigator.serviceWorker
.register('/sw.js')
.then((reg) => {
setRegistration(reg);
// Если новая версия уже ожидает активации (например, открыта параллельная вкладка)
if (reg.waiting) {
setIsUpdateAvailable(true);
}
// Слушатель обнаружения новой версии файла sw.js
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
// Если новый воркер установился, но старый все еще управляет страницей
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
setIsUpdateAvailable(true);
}
});
});
})
.catch((err) => {
console.error('[SW Registration Error]:', err);
});
return () => {
navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange);
};
}, []);
const updateApp = React.useCallback(() => {
if (registration?.waiting) {
// Отправляем команду SKIP_WAITING в ожидающий воркер
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}, [registration]);
return { isUpdateAvailable, registration, updateApp };
}'use client';
import * as React from 'react';
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
import { useServiceWorkerUpdate } from '@/hooks/useServiceWorkerUpdate';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function PwaOfflineManager() {
const isOnline = useOnlineStatus();
const { isUpdateAvailable, updateApp } = useServiceWorkerUpdate();
const [installPrompt, setInstallPrompt] = React.useState<BeforeInstallPromptEvent | null>(null);
const [isStandalone, setIsStandalone] = React.useState(false);
React.useEffect(() => {
// Детекция standalone-режима (уже установлено на домашний экран)
const checkStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone === true;
setIsStandalone(checkStandalone);
// Перехват системного диалога установки
const handleBeforeInstall = (e: Event) => {
e.preventDefault();
setInstallPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
window.addEventListener('appinstalled', () => {
setIsStandalone(true);
setInstallPrompt(null);
});
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
};
}, []);
const handleInstallClick = async () => {
if (!installPrompt) return;
await installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') {
setIsStandalone(true);
}
setInstallPrompt(null);
};
return (
<div aria-live="polite" className="pointer-events-none fixed inset-x-0 bottom-0 z-50 p-4 flex flex-col gap-3 items-end">
{/* 1. Баннер обрыва интернет-соединения */}
{!isOnline && (
<div className="pointer-events-auto w-full max-w-md p-3.5 rounded-xl bg-red-950/95 border border-red-800 text-red-100 text-xs flex items-center justify-between shadow-2xl backdrop-blur-md animate-fade-in">
<div className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 animate-ping shrink-0" />
<span>Вы в оффлайн-режиме. Приложение работает из локального кэша, мутации сохраняются.</span>
</div>
<span className="font-mono text-[10px] uppercase font-bold text-red-400 bg-red-900/60 px-2 py-0.5 rounded">
Offline
</span>
</div>
)}
{/* 2. Баннер доступного обновления приложения */}
{isUpdateAvailable && (
<div className="pointer-events-auto w-full max-w-md p-4 rounded-2xl bg-bg-surface border border-accent-violet/40 shadow-2xl space-y-3 animate-slide-up">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-accent-violet/20 border border-accent-violet/40 flex items-center justify-center text-accent-violet font-bold text-sm shrink-0">
⚡
</div>
<div>
<h4 className="text-sm font-bold text-text-primary">Доступно обновление приложения</h4>
<p className="text-xs text-text-secondary mt-0.5">
Загружена новая версия платформы. Обновите страницу для применения изменений.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={updateApp}
className="px-3.5 py-1.5 text-xs font-semibold bg-accent-violet text-white rounded-lg hover:bg-accent-violet/90 transition-colors cursor-pointer shadow-md"
>
Обновить сейчас
</button>
</div>
</div>
)}
{/* 3. Предложение установки PWA на рабочий стол */}
{installPrompt && !isStandalone && (
<div className="pointer-events-auto w-full max-w-sm p-4 rounded-2xl bg-bg-surface border border-border shadow-2xl space-y-3 animate-slide-up">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-violet/15 border border-accent-violet/30 flex items-center justify-center text-accent-violet font-bold text-base shrink-0">
AL
</div>
<div className="flex-1">
<h4 className="text-sm font-bold text-text-primary">Установить Fullstack Agentic Lab</h4>
<p className="text-xs text-text-secondary mt-0.5">
Быстрый запуск с рабочего стола и оффлайн-доступ к учебным материалам.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={() => setInstallPrompt(null)}
className="px-3 py-1.5 text-xs text-text-muted hover:text-text-primary transition-colors cursor-pointer"
>
Позже
</button>
<button
type="button"
onClick={handleInstallClick}
className="px-3 py-1.5 text-xs font-semibold bg-accent-violet text-white rounded-lg hover:bg-accent-violet/90 transition-colors cursor-pointer"
>
Установить
</button>
</div>
</div>
)}
</div>
);
}Если ваш веб-сервер (Nginx, Cloudflare, Vercel) отдает файл sw.js с заголовком Cache-Control: public, max-age=31536000, браузер будет брать код воркера из своего HTTP-кэша и никогда не узнает о выходе новой версии приложения.
Решение: Для эндпоинта /sw.js сервер обязан возвращать строгие заголовки Cache-Control: no-cache, no-store, must-revalidate.
Наивная реализация перехватчика fetch(request).then(res => cache.put(request, res)) сохраняет в дисковый кэш ответы об ошибках бэкенда. Если бэкенд кратковременно вернул 500, пользователь навсегда останется с закэшированной ошибкой даже после починки сервера.
Решение: Всегда фильтруйте статус ответа: кэшировать разрешено только response.status === 200 (либо используйте плагин workbox-cacheable-response).
Контекст ServiceWorkerGlobalScope не имеет доступа к window.localStorage и document. Синхронные операции ввода-вывода заблокировали бы поток перехвата сетевых пакетов всего браузера.
Решение: Для хранения структурированных данных внутри Service Worker используйте исключительно асинхронные интерфейсы IndexedDB (например, через библиотеку idb) и Cache Storage.
В условиях слабого сотового соединения («Lie-Fi») стандартный вызов fetch() может висеть до 60–90 секунд, прежде чем браузер выбросит ошибку таймаута. Интерфейс приложения все это время остается замороженным на спиннере, хотя в локальном кэше лежат рабочие данные.
Решение: Всегда конфигурируйте жесткий таймаут сети: networkTimeoutSeconds: 3. Если за 3 секунды сеть не ответила — немедленно отдавать данные из кэша.
Файл воркера, размещенный по адресу /static/js/sw.js, по умолчанию получает Scope /static/js/. Он физически не сможет перехватывать запросы к корню / или API /api/*.
Решение: Размещайте файл sw.js строго в корне сайта public/sw.js или отдавайте его со специальным HTTP-заголовком Service-Worker-Allowed: /.
self.skipWaiting() и self.clients.claim() обеспечивают управляемый переход на новую версию без ожидания закрытия вкладок.Cache-First для неизменяемой статики, Stale-While-Revalidate для мгновенной отдачи с фоновым обновлением, и Network-First для свежих критических данных.useSyncExternalStore синхронизирует состояние navigator.onLine без предупреждений гидратации SSR, а обработчик beforeinstallprompt предоставляет брендированный UI для установки PWA на рабочий стол.