Offline Mode
Offline mode позволяет приложению работать без интернета. Основа реализации - Service Worker для кэширования ресурсов и IndexedDB для хранения данных. Подход универсален и не зависит от фреймворка, потому что Service Worker работает на уровне браузера, ниже любого фреймворка.
React, Next.js, Angular, Vue, Svelte - все используют один и тот же Service Worker API. Различия есть только в интеграции с билд-системой (как подключить SW к проекту), но сам offline-функционал реализуется одинаково.
Summary
Фреймворк-специфичной реализации offline mode не существует. Service Worker, Cache API и IndexedDB - это browser API, которые одинаково работают независимо от фреймворка. Ниже - универсальный подход, применимый к любому проекту.
Архитектура offline-приложения
┌─────────────────────────────────────────────────┐
│ Приложение (React/Vue/Angular/Svelte/Next.js) │
├─────────────────────────────────────────────────┤
│ IndexedDB (данные, очередь offline-запросов) │
├─────────────────────────────────────────────────┤
│ Service Worker (кэш ресурсов, перехват запросов)│
├─────────────────────────────────────────────────┤
│ Cache API (статика, API-ответы) │
└─────────────────────────────────────────────────┘
Полная реализация
1. Service Worker с offline-стратегиями
// sw.js
const CACHE_VERSION = 'v1';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
// Ресурсы для предварительного кэширования (app shell)
const APP_SHELL = [
'/',
'/index.html',
'/manifest.json',
'/styles.css',
'/app.js',
'/offline.html',
'/icons/icon-192.png',
];
// Установка: кэшируем app shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL))
);
self.skipWaiting();
});
// Активация: чистим устаревшие кэши
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key.endsWith(CACHE_VERSION) === false)
.map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
// Fetch: маршрутизация по типу запроса
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Навигация (HTML) - Network First с offline fallback
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
const clone = response.clone();
caches.open(DYNAMIC_CACHE).then((cache) => cache.put(request, clone));
return response;
})
.catch(() => caches.match(request).then((r) => r || caches.match('/offline.html')))
);
return;
}
// API запросы - Network First, сохраняем последний успешный ответ
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request)
.then((response) => {
if (request.method === 'GET') {
const clone = response.clone();
caches.open(API_CACHE).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => {
if (request.method === 'GET') {
return caches.match(request);
}
// POST/PUT/DELETE offline - сохраняем в очередь
return new Response(
JSON.stringify({ offline: true, queued: true }),
{ headers: { 'Content-Type': 'application/json' } }
);
})
);
return;
}
// Статика (JS, CSS, изображения) - Cache First
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
const clone = response.clone();
caches.open(DYNAMIC_CACHE).then((cache) => cache.put(request, clone));
return response;
});
})
);
});2. Очередь offline-запросов через IndexedDB
Когда пользователь отправляет данные без сети, запросы сохраняются в IndexedDB и выполняются при восстановлении соединения.
// offline-queue.js
class OfflineQueue {
constructor(dbName = 'offline-queue') {
this.dbName = dbName;
this.dbPromise = this.openDB();
}
openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('requests')) {
db.createObjectStore('requests', {
keyPath: 'id',
autoIncrement: true,
});
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async enqueue(request) {
const db = await this.dbPromise;
const tx = db.transaction('requests', 'readwrite');
const store = tx.objectStore('requests');
store.add({
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body: request.method !== 'GET' ? await request.clone().text() : null,
timestamp: Date.now(),
});
return new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = reject;
});
}
async flush() {
const db = await this.dbPromise;
const tx = db.transaction('requests', 'readonly');
const store = tx.objectStore('requests');
return new Promise((resolve) => {
store.getAll().onsuccess = async (event) => {
const requests = event.target.result;
for (const req of requests) {
try {
await fetch(req.url, {
method: req.method,
headers: req.headers,
body: req.body,
});
await this.remove(req.id);
} catch {
break; // сеть снова упала - прекращаем
}
}
resolve();
};
});
}
async remove(id) {
const db = await this.dbPromise;
const tx = db.transaction('requests', 'readwrite');
tx.objectStore('requests').delete(id);
}
}3. Детекция online/offline и хук для приложения
Универсальный модуль, который работает в любом фреймворке:
// network-status.js
class NetworkStatus {
constructor() {
this.listeners = new Set();
this.online = navigator.onLine;
window.addEventListener('online', () => this.update(true));
window.addEventListener('offline', () => this.update(false));
}
update(status) {
this.online = status;
this.listeners.forEach((fn) => fn(status));
}
subscribe(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
isOnline() {
return this.online;
}
}
export const networkStatus = new NetworkStatus();Интеграция с фреймворком - тонкая обёртка поверх универсального модуля:
// React: useNetworkStatus.ts
import { useSyncExternalStore } from 'react';
import { networkStatus } from './network-status';
export function useNetworkStatus() {
return useSyncExternalStore(
(cb) => networkStatus.subscribe(cb),
() => networkStatus.isOnline()
);
}
// Использование
function App() {
const isOnline = useNetworkStatus();
return (
<div>
{!isOnline && <OfflineBanner />}
<MainContent />
</div>
);
}// Vue: useNetworkStatus.ts
import { ref, onMounted, onUnmounted } from 'vue';
import { networkStatus } from './network-status';
export function useNetworkStatus() {
const online = ref(networkStatus.isOnline());
let unsub: () => void;
onMounted(() => {
unsub = networkStatus.subscribe((status) => {
online.value = status;
});
});
onUnmounted(() => unsub?.());
return online;
}// Angular: network-status.service.ts
import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class NetworkStatusService implements OnDestroy {
private onlineSubject = new BehaviorSubject<boolean>(navigator.onLine);
readonly online$ = this.onlineSubject.asObservable();
private onOnline = () => this.onlineSubject.next(true);
private onOffline = () => this.onlineSubject.next(false);
constructor() {
window.addEventListener('online', this.onOnline);
window.addEventListener('offline', this.onOffline);
}
ngOnDestroy() {
window.removeEventListener('online', this.onOnline);
window.removeEventListener('offline', this.onOffline);
}
}// Svelte: network-status.svelte.ts
import { networkStatus } from './network-status';
export function createNetworkStatus() {
let online = $state(networkStatus.isOnline());
$effect(() => {
return networkStatus.subscribe((status) => {
online = status;
});
});
return {
get online() { return online; },
};
}4. Регистрация SW и Background Sync
// register-sw.js
export async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
const registration = await navigator.serviceWorker.register('/sw.js');
// Регистрируем Background Sync для offline-очереди
if ('sync' in registration) {
navigator.serviceWorker.ready.then((reg) => {
// Sync сработает при восстановлении сети
reg.sync.register('flush-offline-queue');
});
}
return registration;
}
// sw.js (дополнение)
self.addEventListener('sync', (event) => {
if (event.tag === 'flush-offline-queue') {
event.waitUntil(flushOfflineQueue());
}
});
async function flushOfflineQueue() {
// Открываем IndexedDB и отправляем накопленные запросы
const db = await openDB();
const tx = db.transaction('requests', 'readonly');
const store = tx.objectStore('requests');
const requests = await getAllFromStore(store);
for (const req of requests) {
try {
await fetch(req.url, {
method: req.method,
headers: req.headers,
body: req.body,
});
await removeFromDB(req.id);
} catch {
break;
}
}
}5. Offline-страница
<!-- offline.html -->
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Offline</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: system-ui, sans-serif;
background: #f5f5f5;
margin: 0;
}
.container { text-align: center; padding: 2rem; }
h1 { font-size: 1.5rem; color: #333; }
p { color: #666; }
button {
margin-top: 1rem;
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
background: #4a90d9;
color: white;
cursor: pointer;
}
</style>
</head>
<body>
<div class="container">
<h1>Нет подключения к интернету</h1>
<p>Контент из кэша может быть доступен на других страницах</p>
<button onclick="window.location.reload()">Попробовать снова</button>
</div>
</body>
</html>Workbox: промышленная реализация
Workbox от Google - библиотека, которая упрощает работу с Service Worker. Вместо ручного написания стратегий кэширования, Workbox предоставляет готовые модули.
npm install workbox-webpack-plugin # для Webpack
# или
npm install workbox-build # для любого билдераWorkbox с ручным SW
// sw.js с Workbox
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
CacheFirst,
NetworkFirst,
StaleWhileRevalidate,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
// Прекэширование статики (инжектится билдером)
precacheAndRoute(self.__WB_MANIFEST);
// Изображения: Cache First, до 100 штук, до 30 дней
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'images',
plugins: [
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
})
);
// API GET: Network First
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }),
],
})
);
// API POST/PUT/DELETE: Background Sync
const bgSyncPlugin = new BackgroundSyncPlugin('api-mutations', {
maxRetentionTime: 24 * 60, // хранить запросы до 24 часов
});
registerRoute(
({ url, request }) =>
url.pathname.startsWith('/api/') && request.method !== 'GET',
new NetworkFirst({
plugins: [bgSyncPlugin],
}),
'POST' // метод маршрута
);
// Шрифты: Cache First с долгим сроком
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: 'fonts',
plugins: [
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 365 * 24 * 60 * 60 }),
],
})
);Интеграция Workbox с билдерами
Для Vite:
npm install vite-plugin-pwa -D// vite.config.js
import { VitePWA } from 'vite-plugin-pwa';
export default {
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 86400 },
},
},
],
},
manifest: {
name: 'My App',
short_name: 'App',
theme_color: '#ffffff',
},
}),
],
};Info
vite-plugin-pwaработает одинаково для React (Vite), Vue (Vite), Svelte (SvelteKit с Vite) и любого другого фреймворка на базе Vite. Это ещё раз подтверждает, что offline-реализация не зависит от фреймворка.
Для Next.js с next-pwa:
npm install @ducanh2912/next-pwa// next.config.js
const withPWA = require('@ducanh2912/next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 86400 },
},
},
],
});
module.exports = withPWA({
// остальной next.config
});Для Angular:
ng add @angular/pwaAngular CLI автоматически создаст ngsw-config.json и зарегистрирует SW. Конфигурация кэширования:
{
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/assets/**", "/*.(png|jpg|svg)"]
}
}
],
"dataGroups": [
{
"name": "api",
"urls": ["/api/**"],
"cacheConfig": {
"maxSize": 50,
"maxAge": "1d",
"strategy": "freshness"
}
}
]
}IndexedDB для offline-данных
Для хранения структурированных данных в offline-режиме используется IndexedDB. Обёртка idb от Jake Archibald упрощает работу:
npm install idbimport { openDB, DBSchema } from 'idb';
interface MyDB extends DBSchema {
articles: {
key: number;
value: { id: number; title: string; content: string; synced: boolean };
indexes: { 'by-synced': boolean };
};
}
const db = await openDB<MyDB>('my-app', 1, {
upgrade(db) {
const store = db.createObjectStore('articles', { keyPath: 'id' });
store.createIndex('by-synced', 'synced');
},
});
// Сохранить статью для offline
await db.put('articles', {
id: 1,
title: 'Offline Article',
content: '...',
synced: false,
});
// Получить все несинхронизированные
const unsynced = await db.getAllFromIndex('articles', 'by-synced', false);
// Синхронизация при восстановлении сети
window.addEventListener('online', async () => {
const unsynced = await db.getAllFromIndex('articles', 'by-synced', false);
for (const article of unsynced) {
await fetch('/api/articles', {
method: 'POST',
body: JSON.stringify(article),
});
await db.put('articles', { ...article, synced: true });
}
});Manifest для PWA
Для полноценного offline-режима с установкой приложения нужен web app manifest:
{
"name": "My Application",
"short_name": "MyApp",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4a90d9",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#4a90d9" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />Чеклист для offline mode
- Service Worker зарегистрирован и кэширует app shell при установке
- Стратегии кэширования настроены по типам ресурсов
- GET-запросы к API кэшируются через Network First
- Мутации (POST/PUT/DELETE) сохраняются в очередь и отправляются при восстановлении сети
- Есть offline fallback страница
- Приложение отображает статус сети пользователю
- IndexedDB используется для данных, которые нужны offline
- Устаревшие кэши очищаются при активации нового SW
- Manifest добавлен для возможности установки как PWA