Оффлайн-режим для сайта: технология PWA и сервис-воркеры
Для кого эта статья:
- Веб-разработчики, стремящиеся улучшить свои навыки в создании PWA
- Технические менеджеры и лидеры команд, ищущие решения для повышения пользовательского опыта
Студенты и обучающиеся в области программирования, заинтересованные в современных веб-технологиях
Внезапный обрыв интернет-соединения уже не должен ломать взаимодействие пользователя с вашим сайтом. Технология Progressive Web Apps позволяет создавать приложения, способные функционировать даже при отсутствии сети — благодаря умному кэшированию ресурсов и управлению данными. Хотите узнать, как реализовать это на своём проекте и повысить лояльность аудитории, даже когда интернет подводит? Давайте погрузимся в мир Service Workers и оффлайн-стратегий, которые трансформируют обычные веб-сайты в надёжные приложения, работающие в любых условиях. 🚀
Хотите профессионально освоить создание отказоустойчивых веб-приложений? Обучение веб-разработке от Skypro включает углублённый модуль по PWA и оффлайн-режимам. Вы освоите не только основы работы с Service Workers, но и продвинутые паттерны кэширования под руководством практикующих разработчиков. Наши выпускники создают приложения, которые пользователи не отличают от нативных — присоединяйтесь!
Основы создания сайта с оффлайн-режимом: технический базис
Оффлайн-режим веб-сайтов — это не просто модная фишка, а необходимость для любого сервиса, заботящегося о пользовательском опыте. Построение такой функциональности требует понимания трёх ключевых технологий: Service Workers, Cache API и стратегий хранения данных.
Service Workers представляют собой JavaScript-файлы, работающие в фоновом режиме отдельно от основной страницы. Они действуют как прокси-сервер между вашим приложением, браузером и сетью. Главная особенность — способность перехватывать сетевые запросы и кэшировать ответы для последующего использования без интернета.
Прежде чем погрузиться в практическую реализацию, давайте уточним основные требования для создания PWA с оффлайн-поддержкой:
- HTTPS-протокол — Service Workers работают только на сайтах, обслуживаемых через защищённое соединение (с исключением для localhost при разработке)
- Поддержка современных браузеров — Chrome, Firefox, Safari, Edge поддерживают Service Workers, но есть нюансы совместимости
- Web App Manifest — JSON-файл, описывающий метаданные приложения для правильного отображения при установке на устройства
- Responsive дизайн — адаптивность интерфейса критична для PWA
| Технология | Назначение | Ограничения |
|---|---|---|
| Service Workers | Перехват запросов, кэширование ресурсов | Работают только по HTTPS, не имеют доступа к DOM |
| Cache API | Хранение ответов на запросы | Ограничение по объёму (зависит от браузера) |
| IndexedDB | Хранение структурированных данных | Асинхронный API, сложнее в использовании |
| localStorage | Простое хранение ключ-значение | Лимит ~5MB, только строки, блокирует основной поток |
Антон Васильев, Lead Frontend Developer
Когда мы запускали обновление платёжного сервиса с аудиторией более миллиона пользователей, требование стабильной работы в условиях нестабильного соединения стояло особенно остро. Решение внедрить оффлайн-режим пришло после анализа метрик — 23% сессий прерывались из-за проблем с интернетом. Мы внедрили трёхуровневую стратегию кэширования: критичные статические ресурсы предзагружались при первом посещении, динамический контент хранился в IndexedDB, а для транзакционных операций реализовали очередь с отложенной синхронизацией. Результат превзошёл ожидания — количество прерванных сессий сократилось до 4%, а конверсия выросла на 18%. Технический стек решения был удивительно прост: Service Workers, Cache API и грамотно структурированное хранилище IndexedDB.
Архитектура PWA-приложения с оффлайн-режимом требует внимательного планирования. Ключевые моменты, которые необходимо продумать заранее:
- Стратегия кэширования — какие ресурсы кэшировать и как часто обновлять кэш
- Обработка ошибок — что показывать пользователю при отсутствии сети
- Синхронизация данных — как обеспечить согласованность данных после восстановления соединения
- Управление версиями кэша — как обновлять приложение без сбоев у пользователей
Теперь, когда мы понимаем фундаментальные концепции, давайте перейдём к практической реализации. 🛠️

Настройка Service Workers для кэширования контента сайта
Регистрация Service Worker — первый шаг к созданию оффлайн-режима. Этот процесс инициирует жизненный цикл сервис-воркера, позволяя ему начать перехватывать сетевые запросы и управлять кэшированием.
Начнём с создания базовой структуры файлов:
- index.html — главная страница сайта
- sw.js — файл Service Worker
- app.js — скрипт для регистрации Service Worker
В файле app.js разместим код для регистрации сервис-воркера:
// app.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker зарегистрирован:', registration.scope);
})
.catch(error => {
console.error('Ошибка при регистрации Service Worker:', error);
});
});
}
Теперь создадим базовую структуру файла sw.js, которая будет обрабатывать основные события жизненного цикла Service Worker:
// sw.js
const CACHE_NAME = 'my-site-cache-v1';
const urlsToCache = [
'/',
'/index.html',
'/styles/main.css',
'/scripts/app.js',
'/images/logo.png'
];
// Событие install запускается при установке Service Worker
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Кэш открыт');
return cache.addAll(urlsToCache);
})
);
});
// Событие activate запускается после установки, когда старые версии SW удалены
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName); // Удаляем устаревшие кэши
}
})
);
})
);
});
// Событие fetch перехватывает все запросы от страницы
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Возвращаем кэшированный ответ, если он существует
if (response) {
return response;
}
// Иначе делаем обычный сетевой запрос
return fetch(event.request).then(response => {
// Проверяем валидность ответа
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Клонируем ответ, т.к. тело ответа может быть использовано только один раз
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(event.request, responseToCache);
});
return response;
});
})
.catch(() => {
// Если произошла ошибка, можно вернуть заглушку
// return caches.match('/offline.html');
})
);
});
При разработке стратегий кэширования необходимо учитывать различные сценарии использования. Существует несколько основных стратегий, которые можно адаптировать под нужды вашего проекта:
| Стратегия | Описание | Когда использовать |
|---|---|---|
| Cache First | Сначала проверяется кэш, затем сеть | Для статических ресурсов, rarely изменяющихся (шрифты, изображения) |
| Network First | Сначала запрос в сеть, затем кэш при ошибке | Для часто обновляемого контента с необходимостью фоллбэка |
| Stale While Revalidate | Возвращает кэш и обновляет его в фоне | Для обеспечения быстрого ответа с отложенным обновлением |
| Cache Only | Только кэш, без сетевых запросов | Для предварительно кэшированных ресурсов без обновления |
| Network Only | Только сетевые запросы, без кэширования | Для некэшируемых данных (например, аналитика) |
Рассмотрим реализацию стратегии Stale While Revalidate — одной из наиболее эффективных для веб-приложений:
self.addEventListener('fetch', event => {
if (event.request.url.includes('/api/')) {
// Применяем Stale While Revalidate для API-запросов
event.respondWith(
caches.open(CACHE_NAME).then(cache => {
return cache.match(event.request).then(cachedResponse => {
const fetchedResponse = fetch(event.request).then(networkResponse => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
// Возвращаем кэшированный ответ или ждем сетевой
return cachedResponse || fetchedResponse;
});
})
);
} else {
// Для остальных запросов используем Cache First
event.respondWith(
caches.match(event.request).then(cachedResponse => {
return cachedResponse || fetch(event.request);
})
);
}
});
Важно помнить о версионировании кэша — при обновлении сайта пользователи должны получать свежий контент. Для этого изменяйте CACHE_NAME при выпуске новых версий, например, добавляя временную метку:
const CACHE_NAME = 'my-site-cache-v1.2.3';
// или
const CACHE_NAME = `my-site-cache-${new Date().toISOString()}`;
Теперь, когда базовая настройка Service Worker готова, перейдём к более детальному рассмотрению работы с Cache API для обеспечения полноценного оффлайн-доступа. 📦
Работа с Cache API для обеспечения доступа без интернета
Cache API представляет собой мощный интерфейс для хранения пар запрос-ответ, который тесно интегрирован с Service Workers. Правильное использование этого API — ключ к созданию надёжного оффлайн-режима.
Основные операции с Cache API включают:
- caches.open(name) — открывает кэш с указанным именем (создаёт, если не существует)
- cache.add(request) — выполняет запрос и добавляет пару запрос-ответ в кэш
- cache.addAll(requests) — добавляет несколько ресурсов одновременно
- cache.put(request, response) — напрямую добавляет пару в кэш без выполнения запроса
- cache.match(request) — проверяет наличие ответа на запрос в кэше
- cache.delete(request) — удаляет определённый ответ из кэша
Для более структурированного подхода к кэшированию рекомендую создать вспомогательные функции, которые инкапсулируют работу с Cache API:
// Кэширование одиночного ресурса
function cacheResource(cacheName, url) {
return caches.open(cacheName)
.then(cache => {
return fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`Failed to cache: ${url}`);
}
return cache.put(url, response);
});
});
}
// Проверка и получение ресурса из кэша
function getFromCache(cacheName, url) {
return caches.open(cacheName)
.then(cache => cache.match(url));
}
// Обновление кэша с ограничением по времени
async function updateCacheIfOlderThan(cacheName, url, maxAgeMinutes) {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(url);
if (!cachedResponse) {
// Ресурс не найден в кэше, загружаем
return cacheResource(cacheName, url);
}
// Проверяем время кэширования
const cachedTime = new Date(cachedResponse.headers.get('date'));
const now = new Date();
const diffMinutes = (now – cachedTime) / (1000 * 60);
if (diffMinutes > maxAgeMinutes) {
// Кэш устарел, обновляем
return cacheResource(cacheName, url);
}
// Кэш свежий, ничего не делаем
return Promise.resolve();
}
Одна из ключевых проблем при работе с кэшем — управление жизненным циклом ресурсов. Кэш имеет ограничения по размеру, которые варьируются в зависимости от браузера и доступного места на устройстве. Стратегия управления кэшем должна включать:
- Приоритизацию ресурсов для кэширования
- Регулярную очистку устаревших данных
- Мониторинг доступного пространства
- Обработку ошибок при превышении лимита
Реализуем функцию для очистки старых кэшей:
// Очистка устаревших кэшей
async function cleanupCaches(currentCacheName, prefix) {
const cacheNames = await caches.keys();
return Promise.all(
cacheNames
.filter(name => name.startsWith(prefix) && name !== currentCacheName)
.map(name => caches.delete(name))
);
}
// Ограничение размера кэша
async function limitCacheSize(cacheName, maxItems) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxItems) {
// Удаляем старые записи (FIFO)
const keysToDelete = keys.slice(0, keys.length – maxItems);
await Promise.all(keysToDelete.map(key => cache.delete(key)));
}
}
Марина Ковалева, PWA-архитектор
Наша команда столкнулась с интересной задачей: создать приложение для туристов, которое должно работать в условиях отсутствия сети — в горах, на удалённых пляжах, в метро. Мы применили многоуровневую систему кэширования с предиктивной загрузкой. Самый важный урок, который мы извлекли: управлять ожиданиями пользователей.
Мы разделили контент на три категории: гарантированно доступный оффлайн (базовая информация, карты), оптимистично кэшируемый (популярные маршруты) и онлайн-зависимый (бронирования, отзывы). Для каждой категории мы разработали чёткие визуальные индикаторы доступности и специфические оффлайн-состояния. Самое удивительное — после внедрения этой системы мы получили неожиданный фидбэк: пользователи начали преднамеренно переводить устройства в режим полёта перед походами, чтобы экономить заряд батареи, продолжая пользоваться приложением. Из проблемы низкой связности мы сделали конкурентное преимущество!
Особое внимание стоит уделить обработке динамических данных. Для API-запросов можно реализовать более сложные стратегии:
// Запрос с таймаутом и фоллбеком на кэш
async function timeoutFetchWithCacheFallback(request, cacheName, timeout = 3000) {
// Создаем промис с таймаутом
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout')), timeout);
});
try {
// Пробуем получить свежие данные с ограничением по времени
const response = await Promise.race([
fetch(request.clone()),
timeoutPromise
]);
// Обновляем кэш свежими данными
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
return response;
} catch (error) {
console.log('Falling back to cache due to:', error);
// При ошибке или таймауте используем кэш
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
// Если в кэше ничего нет, возвращаем ошибку или заглушку
return new Response(
JSON.stringify({ error: 'Network error, no cached data available' }),
{ headers: { 'Content-Type': 'application/json' } }
);
}
}
Важно помнить о пользовательском опыте при реализации оффлайн-режима. Предоставьте пользователям понятные индикаторы состояния сети и информацию о том, какие функции доступны без подключения. Это можно реализовать с помощью event listeners:
// В файле app.js
function updateNetworkStatus() {
const statusElement = document.getElementById('network-status');
if (navigator.onLine) {
statusElement.textContent = '🟢 Online';
statusElement.classList.remove('offline');
statusElement.classList.add('online');
} else {
statusElement.textContent = '🔴 Offline';
statusElement.classList.remove('online');
statusElement.classList.add('offline');
}
}
window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);
document.addEventListener('DOMContentLoaded', updateNetworkStatus);
Теперь, когда мы освоили работу с Cache API, давайте рассмотрим, как использовать IndexedDB и localStorage для хранения более сложных структур данных в оффлайн-режиме. 🔄
Использование IndexedDB и localStorage для оффлайн-данных
Хотя Cache API отлично подходит для хранения HTTP-запросов и ответов, для управления структурированными данными и состоянием приложения требуются более мощные инструменты. IndexedDB и localStorage предоставляют такие возможности, но с разными характеристиками и областями применения.
| Характеристика | IndexedDB | localStorage |
|---|---|---|
| Объем хранилища | До нескольких ГБ (зависит от браузера) | ~5MB |
| API | Асинхронный, событийно-ориентированный | Синхронный, блокирует главный поток |
| Типы данных | Практически любые (объекты, массивы, файлы) | Только строки |
| Структура | База данных с хранилищами объектов, индексы | Простая пара ключ-значение |
| Сложность | Высокая кривая обучения | Простой в использовании |
Начнём с использования localStorage для простых случаев хранения данных:
// Сохранение данных
function saveToLocalStorage(key, data) {
try {
localStorage.setItem(key, JSON.stringify(data));
return true;
} catch (error) {
console.error('Error saving to localStorage:', error);
return false;
}
}
// Получение данных
function getFromLocalStorage(key, defaultValue = null) {
try {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : defaultValue;
} catch (error) {
console.error('Error reading from localStorage:', error);
return defaultValue;
}
}
// Пример использования для сохранения пользовательских предпочтений
function saveUserPreferences(preferences) {
return saveToLocalStorage('user_preferences', preferences);
}
function getUserPreferences() {
return getFromLocalStorage('user_preferences', {
theme: 'light',
fontSize: 'medium',
notifications: true
});
}
Для более сложных данных и сценариев использования, IndexedDB является предпочтительным выбором. Однако его API достаточно сложен, поэтому рекомендую использовать библиотеку-обёртку или создать свой абстрактный слой:
// Простая обёртка для работы с IndexedDB
class IDBStorage {
constructor(dbName, version) {
this.dbName = dbName;
this.version = version;
this.db = null;
}
// Открытие соединения с базой данных
open(storeConfigs) {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = event => {
reject(`IndexedDB error: ${event.target.error}`);
};
request.onsuccess = event => {
this.db = event.target.result;
resolve(this.db);
};
// Создание или обновление структуры базы данных
request.onupgradeneeded = event => {
const db = event.target.result;
storeConfigs.forEach(config => {
if (!db.objectStoreNames.contains(config.name)) {
const store = db.createObjectStore(config.name, config.options);
// Создание индексов, если указаны
if (config.indexes) {
config.indexes.forEach(idx => {
store.createIndex(idx.name, idx.keyPath, idx.options);
});
}
}
});
};
});
}
// Добавление или обновление записи
put(storeName, data, key = null) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = key !== null ? store.put(data, key) : store.put(data);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Получение записи по ключу
get(storeName, key) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const request = store.get(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// Удаление записи
delete(storeName, key) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.delete(key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
// Получение всех записей
getAll(storeName) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
// Пример использования
async function initDatabase() {
const db = new IDBStorage('myAppDB', 1);
await db.open([
{
name: 'articles',
options: { keyPath: 'id' },
indexes: [
{ name: 'by_date', keyPath: 'publishDate', options: { unique: false } },
{ name: 'by_category', keyPath: 'category', options: { unique: false } }
]
},
{
name: 'user_data',
options: { autoIncrement: true }
}
]);
return db;
}
// Сохранение статьи в IndexedDB
async function saveArticle(article) {
const db = await initDatabase();
return db.put('articles', article);
}
// Получение статей по категории
async function getArticlesByCategory(category) {
const db = await initDatabase();
const transaction = db.db.transaction('articles', 'readonly');
const store = transaction.objectStore('articles');
const index = store.index('by_category');
return new Promise((resolve, reject) => {
const request = index.getAll(category);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
Одно из самых мощных применений IndexedDB в PWA — создание оффлайн-очереди для операций, которые требуют подключения к сети. Когда пользователь выполняет действие без доступа к интернету, мы можем сохранить его в очереди и выполнить синхронизацию, когда соединение восстановится:
// Добавление операции в очередь синхронизации
async function addToSyncQueue(operation) {
const db = await initDatabase();
// Добавляем временную метку для сортировки
operation.timestamp = new Date().toISOString();
operation.status = 'pending';
return db.put('sync_queue', operation);
}
// Обработка очереди при восстановлении соединения
async function processSyncQueue() {
const db = await initDatabase();
const pendingOperations = await db.getAll('sync_queue');
// Сортируем по времени создания
const sortedOperations = pendingOperations
.filter(op => op.status === 'pending')
.sort((a, b) => new Date(a.timestamp) – new Date(b.timestamp));
for (const operation of sortedOperations) {
try {
// Выполняем операцию
await performOperation(operation);
// Отмечаем как успешно выполненную
operation.status = 'completed';
operation.completedAt = new Date().toISOString();
await db.put('sync_queue', operation);
} catch (error) {
// Отмечаем ошибку и добавляем счётчик попыток
operation.status = 'failed';
operation.error = error.message;
operation.retries = (operation.retries || 0) + 1;
if (operation.retries < 3) {
// Вернём в статус ожидания для повторной попытки
operation.status = 'pending';
}
await db.put('sync_queue', operation);
}
}
}
// Регистрируем обработчик на восстановление соединения
window.addEventListener('online', processSyncQueue);
// Также можно использовать Background Sync API для более надёжной синхронизации
if ('serviceWorker' in navigator && 'SyncManager' in window) {
navigator.serviceWorker.ready.then(registration => {
document.getElementById('save-button').addEventListener('click', async () => {
const data = collectFormData();
if (navigator.onLine) {
// Если онлайн – отправляем сразу
await sendData(data);
} else {
// Если оффлайн – сохраняем и регистрируем sync
await addToSyncQueue({
type: 'form_submission',
data: data
});
registration.sync.register('sync-forms');
}
});
});
}
// В Service Worker обрабатываем sync событие
self.addEventListener('sync', event => {
if (event.tag === 'sync-forms') {
event.waitUntil(processSyncQueue());
}
});
Комбинируя IndexedDB и localStorage с Service Workers и Cache API, вы получаете мощный инструментарий для создания приложений, которые работают как в онлайне, так и оффлайне. В следующем разделе мы рассмотрим, как тестировать и оптимизировать созданное PWA. 🧪
Тестирование и оптимизация PWA с сервис-воркерами
Создание приложения с оффлайн-поддержкой — это лишь половина дела. Не менее важно тщательное тестирование и оптимизация PWA для обеспечения надёжной работы во всех сценариях использования.
Начнём с инструментов, необходимых для эффективного тестирования PWA:
- Chrome DevTools — вкладка Application предоставляет доступ к управлению Service Workers, кэшами и хранилищами
- Lighthouse — автоматизированный инструмент для проверки качества PWA, доступный в Chrome DevTools
- Workbox — набор библиотек и инструментов от Google для упрощения работы с Service Workers
- PWA Builder — веб-сервис для тестирования и создания манифестов и иконок для PWA
Основные сценарии тестирования для PWA с оффлайн-поддержкой:
- Первая загрузка без кэша — как ведёт себя приложение при первом посещении
- Обновление Service Worker — корректно ли происходит обновление кэша при выпуске новой версии
- Оффлайн-режим — как приложение работает при отсутствии сети
- Прерывистое соединение — как приложение ведёт себя при нестабильном соединении
- Синхронизация данных — корректно ли происходит синхронизация после восстановления соединения
- Производительность — не вызывает ли работа с кэшем и хранилищами проблем с производительностью
- Использование памяти — как управляется объём используемой памяти, особенно на мобильных устройствах
Создадим несколько функций для тестирования Service Worker:
// Функция для проверки регистрации Service Worker
async function checkServiceWorkerRegistration() {
if (!('serviceWorker' in navigator)) {
console.error('Service Workers не поддерживаются в этом браузере');
return false;
}
try {
const registration = await navigator.serviceWorker.getRegistration();
if (!registration) {
console.error('Service Worker не зарегистрирован');
return false;
}
console.log('Service Worker зарегистрирован со статусом:', registration.active ? 'active' : 'pending');
return true;
} catch (error) {
console.error('Ошибка при проверке Service Worker:', error);
return false;
}
}
// Функция для проверки кэша
async function checkCache(cacheName, urls) {
try {
const cache = await caches.open(cacheName);
const results = await Promise.all(
urls.map(async url => {
const response = await cache.match(url);
return {
url,
cached: !!response,
status: response ? response.status : null,
headers: response ? Object.fromEntries(response.headers.entries()) : null
};
})
);
console.table(results);
const missingUrls = results.filter(r => !r.cached).map(r => r.url);
if (missingUrls.length > 0) {
console.warn('URLs не найдены в кэше:', missingUrls);
}
return results;
} catch (error) {
console.error('Ошибка при проверке кэша:', error);
throw error;
}
}
// Функция для симуляции оффлайн-режима
function simulateOffline() {
if (!('serviceWorker' in navigator)) return false;
const offlineCheckbox = document.getElementById('offline-simulation');
if (offlineCheckbox.checked) {
// Включить оффлайн-режим
const originalFetch = window.fetch;
window.fetch = async (...args) => {
throw new Error('Network request failed (simulated offline)');
};
window._originalFetch = originalFetch;
console.log('🔴 Оффлайн-режим активирован (симуляция)');
} else {
// Выключить оффлайн-режим
if (window._originalFetch) {
window.fetch = window._originalFetch;
delete window._originalFetch;
console.log('🟢 Оффлайн-режим деактивирован');
}
}
}
Для оптимизации производительности PWA рекомендуется следовать следующим практикам:
- Предварительное кэширование — заранее кэшируйте критические ресурсы при установке Service Worker
- Ленивая загрузка — откладывайте загрузку ресурсов, которые не нужны немедленно
- Компрессия ресурсов — используйте сжатие для уменьшения размера кэшируемых данных
- Контроль размера кэша — regularmente очищайте устаревшие данные
- Оптимизация изображений — используйте современные форматы (WebP, AVIF) и адаптивные изображения
- Минимизация вычислений в Service Worker — выполняйте тяжёлые операции в основном потоке
Для упрощения разработки и оптимизации Service Workers рекомендую использовать Workbox — библиотеку от Google:
// Установка Workbox через npm: npm install workbox-cli --save-dev
// В файле sw.js:
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js');
// Предварительное кэширование критических ресурсов
workbox.precaching.precacheAndRoute([
{ url: '/', revision: '1' },
{ url: '/index.html', revision: '1' },
{ url: '/css/style.css', revision: '1' },
{ url: '/js/app.js', revision: '1' },
{ url: '/images/logo.png', revision: '1' },
]);
// Кэширование изображений с ограничением кэша
workbox.routing.registerRoute(
({ request }) => request.destination === 'image',
new workbox.strategies.CacheFirst({
cacheName: 'image-cache',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 дней
}),
],
})
);
// Стратегия StaleWhileRevalidate для API
workbox.routing.registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'api-cache',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 24 * 60 * 60, // 1 день
}),
],
})
);
// Кэширование шрифтов с долгим сроком жизни
workbox.routing.registerRoute(
({ request }) => request.destination === 'font',
new workbox.strategies.CacheFirst({
cacheName: 'fonts',
plugins: [
new workbox.expiration.ExpirationPlugin({
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 год
}),
],
})
);
// Оффлайн-страница
const offlineFallbackPage = '/offline.html';
workbox.routing.setCatchHandler(async ({ event }) => {
if (event.request.destination === 'document') {
return caches.match(offlineFallbackPage);
}
return Response.error();
});
// Фоновая синхронизация
workbox.backgroundSync.registerPlugin(
new workbox.backgroundSync.BackgroundSyncPlugin('form-submissions', {
maxRetentionTime: 24 * 60, // 24 часа (в минутах)
})
);
При внедрении PWA с оффлайн-поддержкой не забывайте об UX-аспектах. Пользователи должны понимать, какие функции доступны в оффлайн-режиме, а какие требуют подключения к сети:
- Предоставляйте чёткие индикаторы состояния сети
- Показывайте сообщения о том, что данные могут быть неактуальными в оффлайн-режиме
- Объясняйте, что произойдёт с данными, введёнными в оффлайн-режиме
- Предлагайте возможность принудительного обновления данных при восстановлении соединения
Наконец, внедрите мониторинг и аналитику для отслеживания эффективности вашего PWA:
// Отправка метрики производительности
function sendPerformanceMetrics() {
// Собираем метрики
const metrics = {
timeToFirstByte: performance.getEntriesByType('navigation')[0].responseStart,
domContentLoaded: performance.getEntriesByType('navigation')[0].domContentLoadedEventEnd,
loadComplete: performance.getEntriesByType('navigation')[0].loadEventEnd,
resourceCount: performance.getEntriesByType('resource').length,
cacheHits: window._cacheHits || 0,
cacheMisses: window._cacheMisses || 0,
offlineMode: !navigator.onLine,
// Дополнительные метрики
};
// Если онлайн, отправляем на сервер аналитики
if (navigator.onLine) {
fetch('/api/metrics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metrics)
}).catch(console.error);
} else {
// Иначе сохраняем для последующей отправки
addToSyncQueue({
type: 'metrics',
data: metrics
});
}
}
// Запускаем сбор метрик после загрузки страницы
window.addEventListener('load', () => {
// Отложенный запуск, чтобы не мешать основной загрузке
setTimeout(sendPerformanceMetrics, 3000);
});
Тщательное тестирование, оптимизация и мониторинг — ключевые факторы для создания надёжного PWA с оффлайн-поддержкой, которое будет радовать пользователей независимо от качества их интернет-соединения. 🚀
Создание сайта с оффлайн-режимом — не просто техническое улучшение, а стратегическое преимущество, которое выделит ваш проект среди конкурентов. Комбинируя Service Workers, Cache API и локальные хранилища, вы можете построить действительно отказоустойчивое приложение, которое работает в любых условиях. Помните: лучший пользовательский опыт создаётся не когда всё идеально, а когда ваш сайт элегантно справляется с проблемами. Ваши пользователи могут не осознавать сложность технологий под капотом, но они точно оценят приложение, которое никогда их не подводит.