Как сделать Progressive Web App для медленного мобильного интернета Кыргызстана (2016)
Коротко: Создайте service-worker.js в корне сайта, зарегистрируйте его в JavaScript, закэшируйте оболочку приложения при установке. При отсутствии сети - отдавайте из кэша. Добавьте manifest.json для иконки «Добавить на экран». Chrome 48+ и Android 4.4+ поддерживают Service Workers - это охватывало ~60% казахстанско-кыргызстанского Android рынка в 2016 году.
Контекст: почему PWA было важно для КР в 2016
Средняя скорость мобильного интернета в районах Кыргызстана:
- Бишкек: 8-15 Мбит/с (4G от Megacom/Beeline)
- Ош, Жалал-Абад: 2-4 Мбит/с (нестабильный 3G)
- Районные центры: 100-500 кбит/с (EDGE/2G)
Для доставки продуктов наш клиент терял 40% заказов из регионов из-за плохой связи в момент оформления. PWA с офлайн-поддержкой снизило потери до 12%.
Web App Manifest
// manifest.json - в корне сайта
{
"name": "МойМагазин.кг",
"short_name": "Магазин",
"description": "Заказ продуктов с доставкой по Бишкеку",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#2196F3",
"orientation": "portrait",
"icons": [
{
"src": "/icons/icon-48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
<!-- index.html - в <head> -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2196F3">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
Service Worker
// service-worker.js - в корне сайта (НЕ в /js/!)
// Service Worker может перехватывать запросы только в своём scope
var CACHE_NAME = 'myapp-v1';
var CACHE_VERSION = 'v1.2.0';
// Ресурсы для кэширования при установке (App Shell)
var APP_SHELL = [
'/',
'/catalog/',
'/cart/',
'/css/app.css',
'/js/app.js',
'/js/vendor.js',
'/icons/icon-192.png',
'/offline.html', // Страница для полного офлайна
];
// Установка: кэшировать App Shell
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(CACHE_NAME).then(function(cache) {
console.log('SW: caching app shell');
return cache.addAll(APP_SHELL);
})
);
// Активировать немедленно, не ждать закрытия старых вкладок
self.skipWaiting();
});
// Активация: удалить старые кэши
self.addEventListener('activate', function(event) {
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => caches.delete(name))
);
})
);
// Взять контроль над всеми вкладками немедленно
self.clients.claim();
});
// Перехват запросов: стратегия зависит от типа ресурса
self.addEventListener('fetch', function(event) {
var url = new URL(event.request.url);
// API запросы: Network First (свежие данные важнее)
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstStrategy(event.request));
return;
}
// Изображения товаров: Cache First (экономия трафика)
if (url.pathname.startsWith('/uploads/')) {
event.respondWith(cacheFirstStrategy(event.request));
return;
}
// Всё остальное: Stale While Revalidate (быстро + обновление фоном)
event.respondWith(staleWhileRevalidate(event.request));
});
Стратегии кэширования
// Network First: сначала сеть, при ошибке - кэш
async function networkFirstStrategy(request) {
try {
var response = await fetch(request, { timeout: 5000 });
// Кэшировать успешные ответы
if (response.ok) {
var cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
var cached = await caches.match(request);
return cached || new Response(
JSON.stringify({ error: 'Нет подключения к интернету' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
);
}
}
// Cache First: сначала кэш, при промахе - сеть
async function cacheFirstStrategy(request) {
var cached = await caches.match(request);
if (cached) return cached;
try {
var response = await fetch(request);
if (response.ok) {
var cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
return new Response('', { status: 404 });
}
}
// Stale While Revalidate: отдать кэш, обновить фоном
async function staleWhileRevalidate(request) {
var cache = await caches.open(CACHE_NAME);
var cached = await cache.match(request);
var fetchPromise = fetch(request).then(response => {
if (response.ok) cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
}
Регистрация Service Worker
// app.js - регистрация в браузере
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/service-worker.js')
.then(function(registration) {
console.log('SW registered:', registration.scope);
// Уведомить пользователя о новой версии
registration.onupdatefound = function() {
var newWorker = registration.installing;
newWorker.onstatechange = function() {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
showUpdateBanner();
}
};
};
})
.catch(function(error) {
console.error('SW registration failed:', error);
});
});
}
function showUpdateBanner() {
var banner = document.createElement('div');
banner.innerHTML = `
<div style="position:fixed;bottom:0;left:0;right:0;background:#333;color:white;padding:12px;z-index:9999">
Доступна новая версия сайта.
<button onclick="location.reload()">Обновить</button>
</div>
`;
document.body.appendChild(banner);
}
offline.html
<!-- offline.html - отображается при полном отсутствии сети и кэша -->
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Нет подключения - МойМагазин.кг</title>
<style>
body { font-family: sans-serif; text-align: center; padding: 48px 24px; }
h1 { color: #333; }
p { color: #666; }
button { background: #2196F3; color: white; border: none; padding: 12px 24px;
border-radius: 4px; font-size: 16px; cursor: pointer; }
</style>
</head>
<body>
<h1>Нет подключения к интернету</h1>
<p>Проверьте мобильный интернет и попробуйте ещё раз.</p>
<p>Ваша корзина сохранена и будет доступна после восстановления связи.</p>
<button onclick="location.reload()">Попробовать снова</button>
</body>
</html>
Результаты после внедрения PWA
Интернет-магазин в Бишкеке с аудиторией из регионов:
| Метрика | До PWA | После PWA |
|---|---|---|
| Потери заказов из-за обрыва связи | 40% | 12% |
| Время загрузки (второй визит) | 3.2 с | 0.4 с |
| «Добавить на экран» конверсия | - | 8% пользователей |
| Bounce rate (медленный 3G) | 72% | 38% |
Service Worker кэш сделал повторные визиты практически мгновенными. На 3G в Оше это было разницей между «сайт работает» и «сайт зависает».