Реализация офлайн-режима для PWA
Офлайн-режим позволяет приложению частично функционировать без интернета: показывать закешированные страницы, принимать заказы для последующей синхронизации, отображать последние данные вместо ошибки. Реализуется через Service Worker + IndexedDB.
Что кешировать для офлайна
App Shell — минимальный HTML/CSS/JS для работы интерфейса. Кешируется при установке Service Worker.
Данные — последние загруженные страницы, избранное пользователя, корзина. Кешируются в runtime.
// sw.js: стратегия для разных типов контента
const SHELL_CACHE = 'shell-v1';
const CONTENT_CACHE = 'content-v1';
const IMAGES_CACHE = 'images-v1';
const APP_SHELL = ['/', '/cart', '/wishlist', '/offline.html'];
// Кешировать все посещённые HTML-страницы
self.addEventListener('fetch', event => {
if (event.request.headers.get('Accept')?.includes('text/html')) {
event.respondWith(networkFirstWithOfflineFallback(event.request));
}
});
async function networkFirstWithOfflineFallback(request) {
const cache = await caches.open(CONTENT_CACHE);
try {
const response = await Promise.race([
fetch(request),
new Promise((_, reject) => setTimeout(reject, 3000, new Error('timeout')))
]);
cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
// Отдаём офлайн-страницу с объяснением
return caches.match('/offline.html');
}
}
Индикатор состояния сети
// useNetworkStatus.ts
export function useNetworkStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [wasOffline, setWasOffline] = useState(false);
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
if (wasOffline) {
// Синхронизировать отложенные действия
syncPendingActions();
setWasOffline(false);
}
};
const handleOffline = () => {
setIsOnline(false);
setWasOffline(true);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [wasOffline]);
return { isOnline, wasOffline };
}
// В компоненте
function OfflineBanner() {
const { isOnline } = useNetworkStatus();
if (isOnline) return null;
return (
<div className="offline-banner">
Нет соединения. Показаны сохранённые данные.
</div>
);
}
IndexedDB для офлайн-данных
// db.ts — Dexie.js (wrapper для IndexedDB)
import Dexie, { type Table } from 'dexie';
interface CachedProduct {
id: number;
slug: string;
name: string;
price: number;
image: string;
cachedAt: Date;
}
interface PendingAction {
id?: number;
type: 'add_to_cart' | 'add_to_wishlist' | 'submit_review';
payload: Record<string, unknown>;
createdAt: Date;
}
class AppDatabase extends Dexie {
products!: Table<CachedProduct>;
pendingActions!: Table<PendingAction>;
constructor() {
super('AppDatabase');
this.version(1).stores({
products: 'id, slug, cachedAt',
pendingActions: '++id, type, createdAt',
});
}
}
export const db = new AppDatabase();
Отложенные действия (Optimistic UI)
// Добавление в корзину — работает офлайн
async function addToCart(productId: number, quantity: number) {
const { isOnline } = useNetworkStatus();
if (isOnline) {
// Онлайн — обычный запрос
await api.post('/cart/items', { productId, quantity });
} else {
// Офлайн — сохранить для синхронизации
await db.pendingActions.add({
type: 'add_to_cart',
payload: { productId, quantity },
createdAt: new Date(),
});
// Обновить локальный стейт (optimistic update)
updateCartLocally(productId, quantity);
// Уведомить пользователя
showToast('Товар добавлен. Синхронизируется при подключении к сети');
}
}
// Синхронизация при восстановлении соединения
async function syncPendingActions() {
const pending = await db.pendingActions.toArray();
for (const action of pending) {
try {
await processAction(action);
await db.pendingActions.delete(action.id!);
} catch (err) {
console.error('Sync failed for action:', action, err);
}
}
}
Background Sync через Service Worker
// sw.js: автоматическая синхронизация при восстановлении соединения
self.addEventListener('sync', event => {
if (event.tag === 'sync-cart') {
event.waitUntil(syncCartItems());
}
if (event.tag === 'sync-reviews') {
event.waitUntil(syncPendingReviews());
}
});
async function syncCartItems() {
const db = await openDB('AppDatabase', 1);
const pending = await db.getAll('pendingActions');
for (const action of pending.filter(a => a.type === 'add_to_cart')) {
const response = await fetch('/api/cart/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.payload),
});
if (response.ok) {
await db.delete('pendingActions', action.id);
}
}
}
// Регистрация sync из страницы
async function registerBackgroundSync() {
const registration = await navigator.serviceWorker.ready;
if ('sync' in registration) {
await (registration as SyncRegistration).sync.register('sync-cart');
}
}
Офлайн для каталога с поиском
// Кешировать последние результаты поиска
const SEARCH_CACHE_SIZE = 20;
async function search(query: string): Promise<Product[]> {
const { isOnline } = getNetworkStatus();
if (isOnline) {
const results = await api.get('/search', { params: { q: query } });
// Кешировать результат
await db.searchCache.put({ query, results, cachedAt: new Date() });
return results;
} else {
// Офлайн — поиск по IndexedDB
const cached = await db.searchCache.get(query);
if (cached) return cached.results;
// Локальный поиск по закешированным продуктам
return db.products
.filter(p => p.name.toLowerCase().includes(query.toLowerCase()))
.toArray();
}
}
Срок реализации: 2–3 дня для полного офлайн-режима с IndexedDB и Background Sync.







