Оффлайн-режим для сайта: технология PWA и сервис-воркеры

Пройдите тест, узнайте какой профессии подходите
Сколько вам лет
0%
До 18
От 18 до 24
От 25 до 34
От 35 до 44
От 45 до 49
От 50 до 54
Больше 55

Для кого эта статья:

  • Веб-разработчики, стремящиеся улучшить свои навыки в создании 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-приложения с оффлайн-режимом требует внимательного планирования. Ключевые моменты, которые необходимо продумать заранее:

  1. Стратегия кэширования — какие ресурсы кэшировать и как часто обновлять кэш
  2. Обработка ошибок — что показывать пользователю при отсутствии сети
  3. Синхронизация данных — как обеспечить согласованность данных после восстановления соединения
  4. Управление версиями кэша — как обновлять приложение без сбоев у пользователей

Теперь, когда мы понимаем фундаментальные концепции, давайте перейдём к практической реализации. 🛠️

Пошаговый план для смены профессии

Настройка Service Workers для кэширования контента сайта

Регистрация Service Worker — первый шаг к созданию оффлайн-режима. Этот процесс инициирует жизненный цикл сервис-воркера, позволяя ему начать перехватывать сетевые запросы и управлять кэшированием.

Начнём с создания базовой структуры файлов:

  • index.html — главная страница сайта
  • sw.js — файл Service Worker
  • app.js — скрипт для регистрации Service Worker

В файле app.js разместим код для регистрации сервис-воркера:

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:

JS
Скопировать код
// 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 — одной из наиболее эффективных для веб-приложений:

JS
Скопировать код
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 при выпуске новых версий, например, добавляя временную метку:

JS
Скопировать код
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:

JS
Скопировать код
// Кэширование одиночного ресурса
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();
}

Одна из ключевых проблем при работе с кэшем — управление жизненным циклом ресурсов. Кэш имеет ограничения по размеру, которые варьируются в зависимости от браузера и доступного места на устройстве. Стратегия управления кэшем должна включать:

  1. Приоритизацию ресурсов для кэширования
  2. Регулярную очистку устаревших данных
  3. Мониторинг доступного пространства
  4. Обработку ошибок при превышении лимита

Реализуем функцию для очистки старых кэшей:

JS
Скопировать код
// Очистка устаревших кэшей
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-запросов можно реализовать более сложные стратегии:

JS
Скопировать код
// Запрос с таймаутом и фоллбеком на кэш
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:

JS
Скопировать код
// В файле 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 для простых случаев хранения данных:

JS
Скопировать код
// Сохранение данных
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 достаточно сложен, поэтому рекомендую использовать библиотеку-обёртку или создать свой абстрактный слой:

JS
Скопировать код
// Простая обёртка для работы с 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 — создание оффлайн-очереди для операций, которые требуют подключения к сети. Когда пользователь выполняет действие без доступа к интернету, мы можем сохранить его в очереди и выполнить синхронизацию, когда соединение восстановится:

JS
Скопировать код
// Добавление операции в очередь синхронизации
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 с оффлайн-поддержкой:

  1. Первая загрузка без кэша — как ведёт себя приложение при первом посещении
  2. Обновление Service Worker — корректно ли происходит обновление кэша при выпуске новой версии
  3. Оффлайн-режим — как приложение работает при отсутствии сети
  4. Прерывистое соединение — как приложение ведёт себя при нестабильном соединении
  5. Синхронизация данных — корректно ли происходит синхронизация после восстановления соединения
  6. Производительность — не вызывает ли работа с кэшем и хранилищами проблем с производительностью
  7. Использование памяти — как управляется объём используемой памяти, особенно на мобильных устройствах

Создадим несколько функций для тестирования Service Worker:

JS
Скопировать код
// Функция для проверки регистрации 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 рекомендуется следовать следующим практикам:

  1. Предварительное кэширование — заранее кэшируйте критические ресурсы при установке Service Worker
  2. Ленивая загрузка — откладывайте загрузку ресурсов, которые не нужны немедленно
  3. Компрессия ресурсов — используйте сжатие для уменьшения размера кэшируемых данных
  4. Контроль размера кэша — regularmente очищайте устаревшие данные
  5. Оптимизация изображений — используйте современные форматы (WebP, AVIF) и адаптивные изображения
  6. Минимизация вычислений в Service Worker — выполняйте тяжёлые операции в основном потоке

Для упрощения разработки и оптимизации Service Workers рекомендую использовать Workbox — библиотеку от Google:

JS
Скопировать код
// Установка 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:

JS
Скопировать код
// Отправка метрики производительности
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 и локальные хранилища, вы можете построить действительно отказоустойчивое приложение, которое работает в любых условиях. Помните: лучший пользовательский опыт создаётся не когда всё идеально, а когда ваш сайт элегантно справляется с проблемами. Ваши пользователи могут не осознавать сложность технологий под капотом, но они точно оценят приложение, которое никогда их не подводит.

Загрузка...