Дата публикации документа: 01-03-2026
Дата обновления документа: 01-03-2026
Документ ориентирован на решение следующих вопросов:
Содержание:
Все решения из документа находятся в промышленной эксплуатации на момент публикации. Этапы и время реализации:
| Этапы реализации | Время реализации |
|---|---|
| Проектирование архитектуры PWA для существующей веб-платформы | 20 часов |
| Разработка manifest.json, service-worker.js и offline.html | 35 часов |
| Разработка модальных окон установки и splash-экранов | 16 часов |
| Реализация логики подсчёта установок PWA | 11 часов |
| Тестирование сегментированных Service Worker | 20 часов |
| Тестирование отображения splash-экранов | 6 часов |
| Тестирование модальных окон установки | 18 часов |
| Тестирование логики подсчёта установок PWA | 7 часов |
| Интеграция PWA в архитектуру существующей веб-платформы | 56 часов |
| Эксплуатация с периодической настройкой и обновлением | ~6 месяцев |
Представленные решения в процессе эксплуатации прошли более 100 обновлений на момент составления документа
(февраль 2026).
Общее время работы решений в промышленной эксплуатации — более 12 месяцев.
service-worker.jsbrowser-service-worker.js/offline.htmlСхема показывает цепочку обработки запроса: выбор Service Worker, проверка кэша, fallback при отсутствии сети.
/manifest.json
{
// Полное название приложения. Отображается при установке и в интерфейсе ОС.
// Рекомендация: не более 30 символов, чтобы не обрезалось в системных интерфейсах.
"name": "My Store",
// Краткое имя — используется на экране «Домой» под иконкой.
// Рекомендация: до 12 символов для корректного отображения на всех устройствах.
"short_name": "Store",
// Рекомендация: использовать относительный путь от корня (например, "/").
// Абсолютный URL допустим, если принадлежит тому же источнику (same-origin).
"start_url": "/",
// Режим отображения: полный экран без браузерной оболочки.
// standalone — имитирует нативное приложение (адресная строка скрыта).
"display": "standalone",
// Цвет фона при запуске (до загрузки splash или контента).
// Используется системой при старте. Должен совпадать с фоном splash и <body>.
"background_color": "#ffffff",
// На Android определяет цвет строки состояния и навигации.
// На iOS theme_color влияет на цвет строки состояния в Safari,
// но не используется при запуске PWA. background_color применяется только в браузерах на базе Chromium.
"theme_color": "#ffffff",
// Язык интерфейса по умолчанию. Используется для локализации системных сообщений.
"lang": "ru-RU",
// Область действия Service Worker. Все маршруты внутри будут обрабатываться SW.
// Для полного покрытия сайта — указывается "/".
"scope": "/",
// Предпочтительная ориентация экрана. Актуально для мобильных устройств.
// Указывает предпочтительную ориентацию. Не гарантирует блокировку поворота — зависит от ОС и браузера.
"orientation": "portrait",
// Категории приложения. Помогают магазинам и агрегаторам классифицировать PWA.
// shopping — стандартная категория для интернет-магазинов.
"categories": ["shopping"],
// Неофициальное поле. Используется только для документации или кастомной логики — браузеры его игнорируют.
// Для splash на iOS применяется эмуляция через apple-touch-startup-image.
"splash_screen": {
// Изображение, отображаемое при запуске. Формат WebP предпочтителен (меньший размер).
// Размер: должен соответствовать типичным разрешениям (например, 1125×2436 для iPhone X+).
"image": "/img/splash.webp",
// Фон под изображением. Должен совпадать с background_color для плавного перехода.
"background_color": "#ffffff"
},
// Массив иконок для разных контекстов и устройств.
// Каждая иконка должна быть оптимизирована под назначение.
"icons": [
{
// Иконка 192×192 — основная для плитки приложений и установки.
// Назначение "any" — может использоваться как обычное изображение.
"src": "/img/icon-192-any.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
// Та же иконка, но с вырезанными полями (maskable) — для устройств с вырезами.
// Используется при установке на Android 8+ и некоторых iOS-подобных оболочках.
"src": "/img/icon-192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
// Крупная иконка 512×512 — используется в магазинах приложений, для уведомлений.
// Обязательна для прохождения Lighthouse PWA.
"src": "/img/icon-512-any.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
// Маскируемая версия 512×512 — обеспечивает единообразие на всех устройствах.
"src": "/img/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
// Скриншоты приложения — используются в инсталляционных баннерах и сторах.
// Повышают конверсию установки за счёт демонстрации UI.
"screenshots": [
{
// Скриншот основного интерфейса. Желательно — карточка товара или каталог.
"src": "/img/screen-1.webp",
"sizes": "800x600",
"type": "image/webp"
},
{
// Альтернативный скриншот для узких экранов (смартфоны).
// form_factor: narrow — явное указание типа устройства.
// Поддерживается в Chrome; помогает сортировке скриншотов в магазинах PWA.
"src": "/img/screen-2.webp",
"sizes": "750x1334",
"type": "image/webp",
"form_factor": "narrow"
}
]
}
Минимальные требования к иконкам: Chrome требует хотя бы одну иконку 192×192 и одну 512×512. Без них PWA не пройдёт проверку установки.
display: "standalone" — запуск без UI браузераorientation: "portrait" — блокировка горизонтальной ориентацииpurpose: "maskable" — для адаптивных иконок на AndroidИспользуйте Maskable.app для создания иконок с белым фоном и отступами не менее 12.5% — это гарантирует корректное отображение на устройствах с вырезами.
Поле splash_screen не стандартизировано. Для Safari используется альтернативный метод через
apple-touch-startup-image.
На Android используется изображение из background_color и image при наличии
apple-touch-startup-image (эмуляция).
Поскольку Safari не поддерживает splash_screen из manifest.json, используем
двухуровневое решение:
apple-touch-startup-image, мгновенно отображается при
запуске.<head>
(function() {
// Проверка, запущено ли приложение в режиме PWA (установлено на экран)
// Поддерживается в:
// - Chrome, Edge: через (display-mode: standalone)
// - Safari iOS: через navigator.standalone (true при запуске с экрана «Домой»)
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator.standalone === true);
// Если сайт открыт в обычном браузере — не устанавливаем splash-изображения
// Это предотвращает загрузку лишних ресурсов и конфликты
if (!isPWA) return;
// Массив изображений для splash-экрана под разные модели iPhone
// Каждое изображение соответствует конкретному разрешению и device-pixel-ratio
// Формат WebP — минимальный размер при высоком качестве
const startupImages = [
{
// iPhone 5/5S/SE (1st gen): 640×1136 @2x
href: '/img/startup-640x1136.webp',
media: '(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)'
},
{
// iPhone 6/7/8: 750×1334 @2x
href: '/img/startup-750x1334.webp',
media: '(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)'
},
{
// iPhone X/XS/11 Pro: 1125×2436 @3x
href: '/img/startup-1125x2436.webp',
media: '(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)'
},
{
// iPhone 6+/7+/8+: 1242×2208 @3x
href: '/img/startup-1242x2208.webp',
media: '(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3)'
}
];
// Динамическое создание тегов <link rel="apple-touch-startup-image">
// Это эмулирует поведение splash-экрана в Safari, так как стандартный splash_screen
// в manifest.json НЕ поддерживается в iOS.
//
// Каждое изображение привязывается к медиа-условию, чтобы загружалось ТОЛЬКО
// на устройстве с подходящим экраном.
startupImages.forEach(img => {
const link = document.createElement('link');
link.rel = 'apple-touch-startup-image'; // Ключевой атрибут для splash в iOS
link.href = img.href; // Путь к изображению
if (img.media) link.media = img.media; // Условие применения (размер экрана, dpr)
document.head.appendChild(link); // Вставка в <head> — активация механизма
});
// Результат:
// При установке PWA на iPhone и запуске с экрана «Домой» пользователь видит
// полноэкранный splash, соответствующий разрешению его устройства.
// Это создаёт эффект нативного приложения и скрывает время загрузки.
})();
<div id="splash-container">
<script>
// Создание DOM-элемента для кастомного splash-экрана
const splash = document.createElement('div');
splash.id = 'custom-splash'; // Уникальный идентификатор для стилизации и управления
// Внутренний HTML: изображение в формате WebP (оптимальное сжатие)
// alt-текст — для доступности, хотя экран временный
splash.innerHTML = '<img src="/img/custom-splash.webp" alt="Splash">';
// Вставка splash в конец body, но поверх всего — контролируется через z-index в CSS
document.body.appendChild(splash);
// Автоматическое скрытие splash-экрана:
// - Показываем 3 секунды (имитация запуска приложения)
// - Затем плавно скрываем через opacity (CSS transition)
// - Через 500 мс (после завершения анимации) удаляем из DOM
setTimeout(() => {
splash.style.opacity = 0; // Запуск плавного исчезновения
setTimeout(() => {
splash.remove(); // Полное удаление элемента
}, 500); // Время должно соответствовать длительности transition в CSS
}, 3000); // Общее время отображения splash
// Примечание: можно заменить таймер на событие загрузки контента:
// window.addEventListener('load', ...) или postMessage от Service Worker
// для более точного контроля.
</script>
</div>
Рекомендация: используйте WebP без прозрачности и с белым фоном для корректного отображения splash-экранов.
Размеры: 640×1136, 750×1334, 1125×2436, 1242×2208 — покрывают все актуальные iPhone.
Используются два независимых SW:
| Режим | Файл | Область действия | Назначение |
|---|---|---|---|
| PWA | /service-worker.js |
/ |
Полноценное автономное приложение |
| Browser | /browser/browser-service-worker.js |
/browser/ |
Оптимизация в обычном браузере |
// Проверка поддержки Service Worker браузером
// Все современные браузеры поддерживают, но проверка обязательна для безопасного использования
if ('serviceWorker' in navigator) {
// Определение режима запуска:
// - (display-mode: standalone) — PWA установлено на экран (Chrome, Edge и др.)
// - navigator.standalone — iOS Safari при запуске с иконки на домашнем экране
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator.standalone === true);
// Регистрация SW выполняется после полной загрузки страницы
// Чтобы не замедлять первый рендер
window.addEventListener('load', () => {
if (isPWA) {
// Режим PWA: используется основной service-worker.js
// Отвечает за оффлайн, кэширование, установку, splash и т.д.
navigator.serviceWorker.register('/service-worker.js')
.then(() => console.log('✅ PWA SW зарегистрирован'))
.catch(err => console.error('❌ Ошибка регистрации PWA SW', err));
} else {
// Режим браузера: используется отдельный SW для обычного веб-просмотра
// Может кэшировать статику, но не требует full PWA-функциональности
// Указывается scope '/browser/' — чтобы не пересекался с основным SW
navigator.serviceWorker.register('/browser/browser-service-worker.js', { scope: '/browser/' })
.then(() => console.log('✅ Browser SW зарегистрирован'))
.catch(err => console.error('❌ Ошибка регистрации Browser SW', err));
}
});
// Сегментация логики и гибкость управления
}
Проверка display-mode: standalone работает в Chrome. В Safari используется
window.navigator.standalone.
Логика определения isPWA используется в нескольких местах (splash, регистрация SW). Рекомендуется
вынести в отдельный скрипт или использовать как общую функцию.
Указание scope: "/browser/" гарантирует, что браузерный SW не будет перехватывать запросы вне
этой директории, избегая конфликтов с основным SW.
/service-worker.js)
const CACHE_NAME = 'pwa-static-v1';
// Имя основного кэша для статических ресурсов
// При обновлении версии (v2, v3...) старые кэши можно удалить вручную в событии activate.
// Путь к оффлайн-странице — должна быть доступна без сети
// Добавляется в кэш при установке
const OFFLINE_URL = '/offline.html';
// Максимальный объём кэша в байтах (30 МБ)
// Предотвращает переполнение хранилища на устройстве
const MAX_CACHE_SIZE = 30 * 1024 * 1024; // 30 МБ
// Имя отдельного кэша для динамически подгружаемых страниц
// Например, популярные категории или товары, загруженные постфактум (требует дополнительной реализации)
const DYNAMIC_PAGE_CACHE = 'dynamic-pages-v1';
// Список критически важных ресурсов, которые должны быть доступны офлайн
// Включает:
// - Главную страницу
// - Основные ассеты (CSS, JS)
// - Манифест, иконки, splash
// - Шрифты
// Все пути — относительные, от корня сайта
const staticResources = [
'/',
'/index.php',
'/manifest.json',
OFFLINE_URL,
'/img/splash.webp',
'/img/icon-192-any.png',
'/img/icon-192-maskable.png',
'/css/main.css',
'/js/app.js',
'/fonts/MaterialIcons-Regular.woff2'
];
// Событие install: происходит при первой установке Service Worker
// Цель — закэшировать все статические ресурсы
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
await Promise.allSettled(
staticResources.map(async (url) => {
try {
// Запрос с таймаутом 8 секунд — чтобы не висеть при недоступности ресурса
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (res.ok) {
// Клонируем ответ — оригинальный нужен браузеру, клон — для кэша
await cache.put(url, res.clone());
}
} catch (e) {
// Логируем ошибку, но не прерываем установку
console.warn(`❌ Не удалось кэшировать ${url}`, e);
}
})
);
})()
);
});
// Событие activate: происходит после установки, когда SW становится активным
// Цель — освободить место, удалить старые кэши, взять контроль над клиентами
self.addEventListener('activate', (event) => {
event.waitUntil(
Promise.all([
self.skipWaiting(), // Немедленно становится активным (без ожидания закрытия вкладок)
self.clients.claim(), // Берёт под контроль все открытые страницы
cleanupCache(CACHE_NAME, MAX_CACHE_SIZE), // Очищает статический кэш при превышении лимита
cleanupPageCache(20) // Ограничивает количество динамических страниц (последние 20)
])
);
});
// Функция очистки кэша по максимальному размеру (в байтах)
// Проходит по всем запрошенным ресурсам, суммирует их размер
// Удаляет самые старые (по порядку добавления), пока общий объём <= maxSize
async function cleanupCache(name, maxSize) {
const cache = await caches.open(name);
const requests = await cache.keys();
let totalSize = 0;
const toDelete = [];
for (const req of requests) {
const response = await cache.match(req);
// Получаем размер через blob() — точный способ измерения в байтах
const size = await response.blob().then(b => b.size);
totalSize += size;
// Если после добавления текущего ресурса лимит превышен — помечаем его и последующие на удаление
if (totalSize > maxSize) {
toDelete.push(req);
}
}
// Параллельное удаление всех помеченных запросов
await Promise.all(toDelete.map(req => cache.delete(req)));
}
// Функция очистки кэша динамических страниц по количеству
// Хранит только последние N страниц (здесь — 20)
// Удаляет самые старые (первые в списке keys)
async function cleanupPageCache(maxCount) {
const cache = await caches.open(DYNAMIC_PAGE_CACHE);
const keys = await cache.keys();
// Если количество в пределах лимита — ничего не делаем
if (keys.length <= maxCount) return;
// Берём список тех, кого нужно удалить: с начала до разницы
const toDelete = keys.slice(0, keys.length - maxCount);
// Удаляем все лишние записи
await Promise.all(toDelete.map(req => cache.delete(req)));
}
fetch
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// === КРИТИЧЕСКИЕ РЕСУРСЫ: JS, CSS, ШРИФТЫ ===
// Кэшируются при установке и обновляются при наличии сети
// Обеспечивают работоспособность приложения в оффлайне
if (/\\.(js|css|woff2)$/.test(url.pathname)) {
event.respondWith(
caches.match(event.request).then(cached => {
// Сначала — отдаём из кэша (быстрый ответ)
if (cached) return cached;
// Если нет в кэше — запрашиваем с сервера
return fetch(event.request).then(res => {
if (res.ok) {
// При успешном получении — клонируем ответ и сохраняем в кэш
// event.waitUntil — гарантирует выполнение после ответа
event.waitUntil(
caches.open(CACHE_NAME).then(cache =>
cache.put(event.request, res.clone())
)
);
}
return res; // Возвращаем сетевой ответ
});
})
);
}
// === HTML-СТРАНИЦЫ: главная, категории, товары и т.д. ===
// Приоритет: кэш → фоновое обновление → динамический кэш → сеть → оффлайн
if (/\\.(php|html)?$/.test(url.pathname) || url.pathname === '/') {
event.respondWith(
caches.match(event.request).then(async (cached) => {
if (cached) {
// Если страница есть в кэше — сразу её отдаём (мгновенная загрузка)
// И параллельно обновляем в фоне
event.waitUntil(
fetch(event.request).then(newRes => {
if (newRes.ok) {
caches.open(CACHE_NAME).then(cache =>
cache.put(event.request, newRes.clone())
);
}
}).catch(() => {}) // Ошибки фонового обновления не критичны
);
return cached;
}
// Проверяем динамический кэш (например, предзагруженные страницы)
const dynamic = await caches.match(event.request, { cacheName: DYNAMIC_PAGE_CACHE });
if (dynamic) return dynamic;
// Если нет ни в одном кэше — пытаемся получить из сети
// При ошибке — показываем оффлайн-страницу
return fetch(event.request).catch(() => caches.match(OFFLINE_URL));
})
);
}
// === ИЗОБРАЖЕНИЯ ===
// Кэшируются при первом запросе
// При ошибках — подставляем fallback (например, логотип)
if (event.request.destination === 'image') {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
// Если изображение недоступно — отдаём резервное
// Полезно при плохой связи или удалённых изображениях
return fetch(event.request).catch(() => caches.match('/img/logo.png'));
})
);
}
// === ВСЁ ОСТАЛЬНОЕ (API, шрифты, документы и пр.) ===
// По умолчанию — проксируем запрос напрямую
// Не кэшируем автоматически, чтобы не засорять хранилище
// Можно дополнить правилами под конкретный проект
event.respondWith(fetch(event.request));
});
/offline.html)
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Оффлайн</title>
<!-- Критически важен для адаптивности на мобильных устройствах -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Встроенные стили — чтобы страница работала без внешних CSS (оффлайн-доступ) -->
<style>
/* Минималистичный дизайн: быстрая загрузка, читаемость */
body {
font-family: sans-serif; /* Без засечек — лучше отображается на экранах */
text-align: center; /* Центрирование контента */
padding: 50px; /* Отступы для комфорта на мобильных */
background: #f8f8f8; /* Светлый фон — имитация "обычного" состояния */
}
h1 {
font-size: 2em; /* Крупный заголовок — понятно с первого взгляда */
margin-bottom: 20px; /* Пространство под заголовком */
}
p {
font-size: 1.2em; /* Читабельный текст */
margin-bottom: 30px; /* Отделение от кнопки */
color: #555; /* Серый цвет — визуально "неактивно", но не агрессивно */
}
button {
padding: 10px 20px; /* Удобная для нажатия область */
font-size: 1em; /* Нормальный размер шрифта */
background-color: #007bff; /* Синий — стандартный акцент (доверие, действие) */
color: white; /* Контрастный текст */
border: none; /* Без рамок — чистый вид */
border-radius: 5px; /* Слегка скруглённые углы — современный стиль */
cursor: pointer; /* Курсор-пинтер — подсказка, что кнопку можно нажать */
}
/* Визуальная обратная связь при наведении */
button:hover {
background-color: #0056b3; /* Темнее — ощущение нажатия */
}
/* Для касания на мобильных — увеличиваем активную зону */
button:active {
transform: scale(0.98); /* Лёгкое "вдавливание" */
}
</style>
</head>
<body>
<!-- Основное сообщение -->
<h1>Вы сейчас оффлайн</h1>
<!-- Пояснение ситуации -->
<p>Пожалуйста, проверьте подключение к интернету.</p>
<!-- Кнопка перезагрузки — простое действие для восстановления -->
<button onclick="window.location.reload()">Обновить</button>
<!-- - Страница должна быть автономной: без внешних JS/CSS/изображений -->
<!-- - Все ресурсы (если есть) — только вlined или из кэша -->
<!-- - Должна быть добавлена в кэш Service Worker -->
<!-- - Работать даже при полном отсутствии сети -->
</body>
</html>
Файл должен быть полностью автономным: без внешних CSS/JS, только inline-стили.
Добавьте в staticResources — он кэшируется при установке.
Ключевой момент успешного внедрения PWA — не просто техническая готовность, а опыт пользователя при установке. Ниже — реализация UX-потока и сбор аналитики без использования сторонних SDK.
Событие beforeinstallprompt активируется браузером, когда сайт соответствует критериям PWA (HTTPS,
manifest, service worker).
// Глобальная переменная для хранения события установки PWA
// Используется для отложенного показа баннера установки
let deferredPrompt = null;
// Событие beforeinstallprompt срабатывает, когда браузер определяет,
// что сайт может быть установлен как PWA (соответствует критериям)
window.addEventListener('beforeinstallprompt', (e) => {
// Предотвращаем стандартное поведение — автоматическое появление подсказки
// Это даёт нам контроль над моментом и местом показа
e.preventDefault();
// Сохраняем событие в переменную, чтобы вызвать .prompt() позже
// Без этого — событие утеряется, и повторный запрос невозможен
deferredPrompt = e;
// Логируем факт готовности PWA к установке
// Полезно для отладки и аналитики
console.log('✅ beforeinstallprompt поймано — PWA готов к установке');
// 💡 Дальнейшие шаги:
// - Показать пользовательское уведомление (кнопку "Установить приложение")
// - Обработать клик: вызвать deferredPrompt.prompt()
// - Дождаться результата: .userChoice.then(outcome => { ... })
});
Событие не поддерживается в Safari** — на iOS используется ручная инструкция.
Проверяйте наличие deferredPrompt перед вызовом .prompt().
Показываем модальное окно только после 90 секунд пребывания — это снижает возможное раздражение, делает приглашение к установке более естественным и улучшает опыт взаимодействия с вашим продуктом.
<!-- Модальное окно установки (Android) -->
<!-- Отображается только при поддержке PWA и наличии beforeinstallprompt -->
<div id="android-modal" style="display: none;">
<div class="pwa-modal-content">
<!-- Кнопка закрытия -->
<span class="pwa-close" onclick="closeAndroidModal()">×</span>
<!-- Заголовок — привлечение внимания -->
<h3>Установить мобильное приложение?</h3>
<!-- Краткие преимущества для пользователя -->
<p>Быстрый запуск, оффлайн-доступ, уведомления.</p>
<!-- Кнопка установки — активирует deferredPrompt.prompt() -->
<button id="install-button" class="btn primary">Установить</button>
</div>
</div>
<!--
💡 Логика работы:
- По умолчанию скрыто (display: none)
- Показывается через JS при срабатывании beforeinstallprompt
- Кнопка "Установить" вызывает deferredPrompt.prompt()
- После установки или отказа — модальное окно больше не показывается
-->
Для iOS:
HTML
<!-- Модальное окно установки (iOS) -->
<!-- Так как iOS не поддерживает beforeinstallprompt, показываем инструкцию -->
<div id="ios-modal" style="display: none;">
<div class="ios-os-modal-content">
<!-- Кнопка закрытия -->
<span class="ios-os-close" onclick="closeIOSModal()">×</span>
<!-- Заголовок — понятный для пользователя iOS -->
<h3>Добавить на экран «Домой»</h3>
<!-- Пошаговая инструкция — максимально простая -->
<ol>
<li>Нажмите <strong>«Поделиться»</strong></li>
<li>Выберите <strong>«На экран “Домой”»</strong></li>
<li>Нажмите <strong>«Добавить»</strong></li>
</ol>
<!-- Изображение-подсказка — визуальное руководство -->
<img data-src="/img/instructions/ios-add-to-home.webp" alt="Инструкция по установке на iOS">
</div>
</div>
<!--
💡 Особенности:
- Не требует поддержки PWA API — работает во всех Safari iOS
- Изображение подгружается динамически (через data-src), чтобы не тратить трафик
- Показывается условно: например, после 30 секунд на сайте или при навигации
- Не исчезает автоматически — пользователь должен закрыть вручную
-->
// Ждём полной загрузки DOM перед инициализацией логики установки
document.addEventListener('DOMContentLoaded', () => {
// Функция показа модального окна установки
const showInstallPrompt = () => {
// Проверяем, было ли уже показано окно установки
// Чтобы не надоедать пользователю при каждом посещении
if (localStorage.getItem('installPromptShown')) return;
// Сценарий 1: Android + поддержка PWA
// deferredPrompt — событие beforeinstallprompt, пойманное ранее
if (deferredPrompt) {
openAndroidModal(); // Показываем кнопку "Установить"
}
// Сценарий 2: iOS (iPhone, iPad, iPod) и НЕ установлено на экран
// window.navigator.standalone — true, если запущено с иконки на домашнем экране
else if (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.navigator.standalone) {
openIOSModal(); // Показываем инструкцию по добавлению на экран «Домой»
}
// Помечаем, что подсказка уже была показана
// Рекомендация UXE: Даже если пользователь закрыл окно или отказался — больше не показываем
// (но может противоречить стратегии маркетинга компании)
localStorage.setItem('installPromptShown', 'true');
// 💡 Альтернативные триггеры:
// - После определённого количества навигаций
// - При входе в определённый раздел (например, /cart)
// - По событию fromBackButton — если пользователь вернулся после выхода
};
// Задержка перед показом: 90 секунд
setTimeout(showInstallPrompt, 90000);
// ⚠️ Важно:
// - Убедитесь, что openAndroidModal() и openIOSModal() объявлены глобально
// - Модальные окна должны быть стилизованы и работать без JS после открытия
// - Работает только если DOM загружен — отсюда обёртка в DOMContentLoaded
});
Задержка в 90 секунд — небольшой, но существенный вклад в UX: не навязываем, а приглашаем стать пользователем приложения.
Все события отправляются на сервер через POST-запросы.
// Функция генерации уникального идентификатора устройства для PWA
// Используется для аналитики, привязки сессий, персонализации
function generateDeviceId() {
// Проверяем, есть ли уже сохранённый ID в localStorage
// Если да — возвращаем его, чтобы не создавать дубликаты при перезагрузке
const existing = localStorage.getItem('pwa_device_id');
if (existing) return existing;
// Генерация UUID v4 по шаблону
// Формат: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
// - 4 — фиксировано (указывает версию UUID)
// - y — маска: принимает значения 8, 9, a, b (по стандарту)
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
// Случайное число от 0 до 15 (hex)
const r = Math.random() * 16 | 0;
// Если 'x' — просто случайное число
// Если 'y' — применяется битовая маска: (r & 0x3 | 0x8) → диапазон 8–b
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16); // Преобразуем в hex-символ
});
// Сохраняем сгенерированный UUID в localStorage
// Доступен только в рамках одного домена и браузера
localStorage.setItem('pwa_device_id', uuid);
// Возвращаем ID для использования в коде
return uuid;
// Ограничения:
// - ID привязан к домену и браузеру
// - Удаляется при очистке данных
// - Не передаётся третьим сторонам (если не отправлять)
// - Подходит для агрегированной аналитики, но не для персонализации
}
// Универсальная функция логирования событий установки PWA
// Используется для сбора аналитики: сколько показов, принятий, отказов
// Отправляет данные на сервер без блокировки интерфейса (асинхронно)
function logEvent(action, extra = {}) {
// Получаем или генерируем уникальный идентификатор устройства
// Нужен для отслеживания пользовательского пути (переходы, установки)
const deviceId = generateDeviceId();
// Отправка POST-запроса на серверный эндпоинт
fetch('/log-stats.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // JSON-формат тела
body: JSON.stringify({
action, // Тип события: 'prompt_shown', 'install_accepted' и т.д.
device_id: deviceId, // Привязка к устройству
timestamp: Date.now(), // Временная метка в миллисекундах
...extra // Дополнительные поля (например, URL, версия SW)
})
}).catch(() => {}); // Подавляем ошибки сети — не критично для UX
}
// === Примеры использования ===
// Логируем момент показа модального окна установки
// Полезно для оценки CTR (click-through rate)
logEvent('prompt_shown'); // Показали модалку
// Логируем успешную установку (Android)
// Срабатывает после вызова deferredPrompt.prompt() и подтверждения
if (choiceResult.outcome === 'accepted') {
logEvent('install_accepted'); // Пользователь нажал "Установить"
}
// Логируем открытие инструкции для iOS
// Чтобы понимать, сколько пользователей видят ручную инструкцию
document.getElementById('ios-install-trigger')?.addEventListener('click', () => {
logEvent('ios_modal_opened'); // Показали инструкцию добавления на экран «Домой»
});
// 💡 Дополнительные события для аналитики:
// logEvent('install_declined'); // Пользователь отказался
// logEvent('modal_closed'); // Закрыли окно без действия
// logEvent('pwa_installed'); // После события appinstalled
// logEvent('splash_shown'); // При показе splash-экрана
// ⚠️ Рекомендации:
// - Эндпоинт /log-stats.php должен быть лёгким и быстрым
// - Не требует ответа — можно использовать keepalive (альтернатива)
// - Храните данные временно или агрегируйте для отчётов
// - Учитывайте конфиденциальность: не собирайте персональные данные
Собираем цепочки переходов, чтобы предзагружать следующие страницы.
// Глобальная переменная для хранения цепочки навигации пользователя
// Содержит последние URL-пути (без параметров), по которым прошёл пользователь
let navChain = [window.location.pathname];
// Отслеживаем клики по ссылкам на странице
window.addEventListener('click', (e) => {
// Находим ближайшую обёртку <a> (включая вложенные элементы, например иконки)
const target = e.target.closest('a');
if (!target || !target.href) return; // Пропускаем, если нет ссылки или href
// Парсим URL для проверки домена
const url = new URL(target.href);
// Игнорируем внешние ссылки — отслеживаем только внутреннюю навигацию
if (url.origin !== location.origin) return;
// Добавляем путь в цепочку навигации
navChain.push(url.pathname);
// Ограничиваем историю последними 5 переходами (память + производительность)
if (navChain.length > 5) navChain = navChain.slice(-5);
// Отправляем данные в Service Worker, как только он готов
// Полезно для предзагрузки популярных страниц или аналитики
navigator.serviceWorker.ready.then(reg => {
reg.active.postMessage({
type: 'nav_stats', // Тип сообщения — статистика навигации
data: {
chain: navChain.join(' → '), // Формат: / → /catalog → /product
timestamp: Date.now() // Временная метка для анализа
}
});
});
// 💡 Возможные применения:
// - Предварительная загрузка следующей страницы (например, категории после главной)
// - Аналитика: самые частые цепочки переходов
// - Персонализация: показ рекомендаций на основе поведения
// - Оптимизация кэширования: динамическое добавление в dynamicPagesCache
// ⚠️ Нюансы:
// - Работает только при поддержке SW и активной сессии
// - Не отправляет данные в оффлайн — postMessage работает только при активном SW
// - Можно дополнить фильтрацией служебных путей (login, cart и т.д.)
});
Цепочка навигации используется Service Worker для предварительной загрузки часто посещаемых страниц.
Это снижает TTFB на 40–70% при последовательных переходах, а главное — создает вид «нативного приложения» в глазах пользователя.
После внедрения PWA необходимо контролировать состояние кэша, обновления SW и целостность установок.
chrome://serviceworker-internals: детали регистрацииchrome://net-internals/#cache: содержимое кэша
// === Проверка регистрации Service Worker ===
// Полезно для отладки: убедиться, что SW зарегистрирован и активен
navigator.serviceWorker.getRegistrations().then(regs => {
// Перебираем все зарегистрированные Service Worker'ы (может быть несколько при разных scope)
regs.forEach(r => {
console.log('Scope:', r.scope, '→', r.active?.scriptURL);
// Пример вывода:
// Scope: https://example.com/ → https://example.com/service-worker.js
// Позволяет проверить:
// - Правильность пути к SW
// - Совпадение scope с ожидаемым
// - Наличие активного состояния (r.active не null)
});
});
// === Просмотр всех кэшей ===
// Отладка содержимого Cache Storage — что и сколько закэшировано
caches.keys().then(names => {
// Получаем список всех имён кэшей (например: 'pwa-static-v1', 'dynamic-pages-v1')
names.forEach(name => {
caches.open(name).then(cache => {
// Открываем каждый кэш и получаем список сохранённых запросов
cache.keys().then(reqs => {
console.log(`Кэш "${name}": ${reqs.length} элементов`);
// Пример вывода:
// Кэш "pwa-static-v1": 15 элементов
// Полезно для:
// - Контроля за размером кэша
// - Проверки успешности precache
// - Диагностики пропущенных ресурсов
});
});
});
});
// === Принудительное обновление Service Worker ===
// Используется при разработке или срочном обновлении логики кэширования
navigator.serviceWorker.getRegistration().then(r => {
if (r) {
r.update(); // Инициирует fetch нового service-worker.js и процесс обновления
console.log('🔄 Обновление Service Worker запрошено');
} else {
console.warn('❌ Service Worker не зарегистрирован');
}
});
// 💡 Когда использовать:
// - После деплоя изменений в service-worker.js
// - При тестировании новой версии кэша
// - Для принудительной синхронизации с сервером
// ⚠️ Важно:
// - update() не гарантирует мгновенного переключения — зависит от жизненного цикла SW
// - Новый SW становится активным только после закрытия всех вкладок (или skipWaiting())
// - В production лучше полагаться на автоматическое обновление при загрузке
# === Очистка кэша через консоль браузера (DevTools Console) ===
# Используется при разработке, отладке или сбросе состояния PWA
# Удаление конкретного кэша по имени
# Замените 'pwa-static-v1' на актуальное имя из caches.keys()
await caches.delete('pwa-static-v1');
# Удаление динамического кэша (например, страницы, предзагруженные роуты)
await caches.delete('dynamic-pages-v1');
# Принудительная перезагрузка страницы после очистки
# Нужна, чтобы загрузить свежие ресурсы и переустановить Service Worker при необходимости
location.reload();
# 💡 Альтернативные команды для диагностики:
# - Просмотр всех кэшей: await caches.keys()
# - Удаление всех кэшей сразу:
# (await caches.keys()).forEach(name => caches.delete(name));
# - Проверка регистрации SW: await navigator.serviceWorker.getRegistrations()
# ⚠️ Важно:
# - Работает только в контексте сайта (на нужной странице)
# - Требует поддержки async/await (Chrome DevTools, Firefox Console)
# - Не влияет на localStorage/sessionStorage — их нужно чистить отдельно при необходимости
# - Полезно при тестировании offline-поведения с "чистого листа"
Рекомендация: не используйте caches.delete() в коде PWA — только вручную при диагностике.
Автоматическая очистка выполняется через cleanupCache() при активации SW.
Тестируйте сценарии управления кэшем: в production могут потребоваться быстрые действия по обновлению ресурсов у пользователей.
Интеграция в CI/CD для защиты от regressions.
#!/bin/bash
# === Скрипт проверки доступности и валидности manifest.json ===
# Используется в CI/CD, перед деплоем или при диагностике PWA
# URL до манифеста — заменить на актуальный
URL="https://your-site.com/manifest.json"
# Проверка доступности файла: HTTP-статус должен быть 200
# -s: тихий режим (без прогресс-бара)
# -o /dev/null: подавляем тело ответа
# -w "%{http_code}": выводим только код ответа
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$URL")
# Если статус не 200 — прерываем выполнение
if [ "$HTTP_CODE" -ne 200 ]; then
echo "❌ manifest.json недоступен (HTTP $HTTP_CODE)"
exit 1
fi
# Передаём содержимое manifest.json в Python-скрипт для валидации JSON и полей
python3 -c
import json, sys
# Читаем JSON из stdin (данные от curl)
data = json.load(sys.stdin)
# Обязательные поля для PWA (минимальный набор)
required = ['name', 'short_name', 'start_url', 'display']
# Проверяем наличие всех требуемых полей
missing = [f for f in required if f not in data]
# Если есть отсутствующие — выводим ошибку и завершаем с кодом 1
if missing:
print('❌ Отсутствуют поля:', ' '.join(missing))
sys.exit(1)
else:
print('✅ manifest валиден')
< <(curl -s "$URL") # Подстановка вывода curl как stdin для python
# 💡 Дополнительные проверки (можно добавить):
# - Проверка формата start_url (относительный путь)
# - Наличие иконок 192x192 и 512x512
# - Значение display: standalone или fullscreen
# - Корректность путей к иконкам и splash
# ⚠️ Требования:
# - Установленные curl и python3
# - Доступ к сайту из среды выполнения (CI, локально)
# - Работает в bash/zsh (Linux, macOS, WSL)
.lighthouserc.json
{
// Конфигурация для Continuous Integration (CI) с использованием Lighthouse CI
// Позволяет автоматически тестировать PWA-свойства при каждом деплое
"ci": {
// Блок сбора данных: определяет, что и как тестируется
"collect": {
// Список URL для аудита — можно указать несколько страниц
"url": ["https://your-site.com/"],
// Настройки среды аудита
"settings": {
// Эмуляция мобильного устройства (ширина экрана, touch, UA)
"emulatedFormFactor": "mobile",
// Режим оффлайн: проверка работоспособности без сети
// Критично для тестирования offline.html и кэширования
"offline": true
}
},
// Блок проверок (assertions): задаёт пороги прохождения тестов
"assert": {
"assertions": {
// Проверка, что сайт работает в оффлайн-режиме
// Использует метрику 'works-offline' из Lighthouse
"works-offline": [
"error", // Уровень — ошибка (если не пройдено, CI падает)
{"minScore": 1} // Минимальный балл: 1 = прошло (0 = провал)
]
}
}
}
}
Запуск:
# Запуск автоматического аудита PWA с помощью Lighthouse CI
# Выполняет: сбор, проверку по правилам из .lighthouserc.json и отчет
npx @lhci/cli autorun
## Что делает команда:
# 1. Устанавливает @lhci/cli временно (если не установлен) — благодаря npx
# 2. Собирает данные по URL из конфига (например, https://your-site.com/)
# 3. Запускает Lighthouse с настройками: mobile, offline и др.
# 4. Проверяет результаты на соответствие assertions (например, works-offline)
# 5. Выводит отчет в консоль и при необходимости — завершает с ошибкой (для CI)
## Возможное использование:
# - В CI/CD (GitHub Actions, GitLab CI): контроль качества перед деплоем
# - Локально: проверка оффлайн-работоспособности после изменений
## Успешный запуск означает:
# - Страница доступна
# - manifest.json корректен
# - Service Worker работает
# - Приложение работает офлайн (если настроено)
## Требования:
# - Установленный Node.js (для npx)
# - Доступ к сайту из среды выполнения
# - Наличие конфига .lighthouserc.json или lhci.config.js
## Рекомендация: добавьте в package.json
# "scripts": {
# "audit": "lhci autorun"
# }
# И запускайте: npm run audit
Lighthouse CI блокирует деплой, если PWA не работает оффлайн.
Рекомендация: также проверяйте is-on-https, installable-pwa.
/log-stats.php)
<?php
// === Обработчик событий установки PWA ===
// Принимает POST-запросы с клиентских событий: показ модального окна, установка и т.д.
// Логирует действия пользователей для аналитики и оптимизации
// Проверка метода запроса: разрешён только POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); // Method Not Allowed
exit;
}
// Чтение и парсинг JSON из тела запроса
$data = json_decode(file_get_contents('php://input'), true);
// Валидация входных данных
if (!$data || !isset($data['action'])) {
http_response_code(400); // Bad Request
exit;
}
// Извлечение полей из запроса
$device_id = $data['device_id'] ?? 'unknown'; // Уникальный ID устройства (или unknown)
$action = $data['action']; // Тип события: prompt_shown, install_accepted и т.д.
$timestamp = date('Y-m-d H:i:s'); // Локальное время сервера
// Формирование строки лога
// Формат: [время] device_id | действие | User-Agent
$log_entry = sprintf("[%s] %s | %s | %s\n",
$timestamp,
$device_id,
$action,
$_SERVER['HTTP_USER_AGENT'] // Полезно для определения платформы (iOS, Android, Chrome и т.д.)
);
// Запись в лог-файл с блокировкой (чтобы избежать гонки при параллельных запросах)
// Флаги:
// - FILE_APPEND: добавляет в конец файла, не перезаписывая
// - LOCK_EX: эксклюзивная блокировка на время записи
file_put_contents('/var/log/pwa-actions.log', $log_entry, FILE_APPEND | LOCK_EX);
// Ответ клиенту — успешная обработка
http_response_code(200);
echo 'OK';
// Пример записи в логе:
// [2026-02-18 12:34:56] abcdef12-3456-7890-abcd-ef1234567890 | prompt_shown | Mozilla/5.0 (Linux; Android 10) ...
// Рекомендации:
// - Убедитесь, что у веб-сервера есть права на запись в /var/log/pwa-actions.log
// - Для высокой нагрузки рассмотрите очередь (Redis, RabbitMQ) или буферизацию
// - При необходимости шифруйте или анонимизируйте device_id
// - Ротация логов: logrotate или внешний мониторинг
// Безопасность:
// - Нет вывода чувствительной информации
// - Валидация метода и данных
// - Никаких SQL-запросов — минимизация рисков
#!/bin/bash
# === Скрипт ежедневной аналитики установок PWA ===
# Генерирует отчёт на основе логов из log-stats.php
# Запускайте ежедневно через cron
# Пути к файлам
LOG="/var/log/pwa-actions.log" # Входной лог: [timestamp] device_id | action | user_agent
REPORT="/var/www/html/reports/daily.txt" # Выходной отчёт (доступен через веб)
DATE=$(date '+%Y-%m-%d') # Текущая дата в формате ГГГГ-ММ-ДД
# Подсчёт количества показов модального окна установки за сегодня
INSTALLS=$(grep "$DATE" "$LOG" | grep 'install_accepted' | wc -l)
# Подсчёт количества показов приглашения к установке
PROMPTS=$(grep "$DATE" "$LOG" | grep 'prompt_shown' | wc -l)
# Расчёт конверсии: (установки / показы) × 100
# Используется `bc` для точного деления с плавающей точкой
# Если данных нет (деление на 0) — выводим "0"
CONV=$(echo "scale=2; $INSTALLS * 100 / $PROMPTS" | bc 2>/dev/null || echo "0")
# Формирование отчёта
echo "Отчёт за $DATE" > "$REPORT"
echo "Показов: $PROMPTS" >> "$REPORT"
echo "Установок: $INSTALLS" >> "$REPORT"
echo "Конверсия: $CONV%" >> "$REPORT"
# Пример вывода:
# Отчёт за 2026-02-18
# Показов: 142
# Установок: 23
# Конверсия: 16.20%
# Как добавить в cron (ежедневно в 00:05):
# crontab -e
# Добавьте строку:
# 5 0 * * * /path/to/generate-report.sh
# Рекомендации:
# - Убедитесь, что скрипт имеет права на чтение лога и запись отчёта
# - Проверьте наличие `bc` (устанавливается: apt install bc)
# - Для безопасности: храните /reports вне public, или защищайте .htaccess
# - При росте трафика — рассмотрите переход на базу данных (SQLite/MySQL)
# Альтернативы:
# - Интеграция с Grafana + Prometheus
# - Отправка отчёта по email (через mailx или sendmail)
# - JSON-формат для внешних систем
Добавьте в crontab:
# Ежедневный запуск генерации отчёта по установкам PWA
# Выполняется в 00:05 каждый день
5 0 * * * /path/to/generate-report.sh
# Расшифровка cron-расписания:
# ┌───── минута (0–59)
# │┌──── час (0–23)
# ││┌─── день месяца (1–31)
# │││┌── месяц (1–12)
# ││││┌─ день недели (0–6, где 0 = воскресенье)
# │││││
# │││││
# 5 0 * * * → каждый день в 00:05
# Что делает:
# - Запускает скрипт generate-report.sh
# - Тот читает логи за предыдущие сутки и создаёт отчёт о конверсии установок
# Как добавить:
# 1. Откройте редактор cron:
# crontab -e
# 2. Вставьте строку, заменив путь на реальный:
# 5 0 * * * /var/www/scripts/generate-report.sh
# 3. Сохраните и выйдите
# Требования:
# - Скрипт должен быть исполняемым: chmod +x /path/to/generate-report.sh
# - У пользователя, запускающего cron, должны быть права на чтение/запись
# - Убедитесь, что переменные окружения (PATH, etc.) заданы при необходимости
# Для тестирования (запуск раз в минуту):
# * * * * * /path/to/generate-report.sh
# После проверки — верните обратно
Отчёт доступен по /reports/daily.txt.
Реализация минимального рабочего решения мобильного приложения на базе PWA за 3 часа.
Особенности:
Все пути и имена обобщены — замените на свои при внедрении.
{
"name": "My App",
"short_name": "App",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"lang": "ru-RU",
"scope": "/",
"orientation": "portrait",
"icons": [
{
"src": "/img/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icon-192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/img/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
Куда положить: в корень сайта → доступно по https://yoursite.com/manifest.json.
Требования:
theme_color — цвет интерфейса браузера
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Оффлайн</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: sans-serif; text-align: center; padding: 50px; background: #f8f8f8; }
h1 { font-size: 1.5em; margin: 20px 0; }
p { color: #666; margin-bottom: 20px; }
button {
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Нет подключения</h1>
<p>Работаем оффлайн</p>
<button onclick="window.location.reload()">Попробовать снова</button>
</body>
</html>
Файл должен быть полностью автономным — без внешних CSS/JS.
Добавьте его в список кэша SW.
const CACHE_NAME = 'pwa-cache-v1';
const OFFLINE_URL = '/offline.html';
// Статические ресурсы для кэширования
const staticAssets = [
'/',
'/index.php',
'/manifest.json',
OFFLINE_URL,
'/css/main.css',
'/js/app.js',
'/img/logo.png'
];
// Исключения из кэширования
function isExcluded(url) {
const excluded = ['admin', 'api/', 'cart', 'checkout', 'login', 'register'];
return excluded.some(path => url.includes(path));
}
// Установка: кэшируем основные файлы
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(staticAssets))
);
});
// Активация: освобождение старых версий
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => {
return Promise.all(
keys.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
);
})
);
self.clients.claim();
});
// Обработка запросов
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Пропуск сторонних доменов
if (url.origin !== self.location.origin) {
event.respondWith(fetch(event.request));
return;
}
// Пропуск исключений
if (isExcluded(url.href)) {
event.respondWith(fetch(event.request));
return;
}
// Приоритет: кэш → сеть → оффлайн
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).catch(() => caches.match(OFFLINE_URL));
})
);
});
Куда положить: в корень сайта.
Что поменять:
staticAssets — добавьте свои CSS, JS, шрифтыisExcluded — укажите маршруты, которые не нужно кэшироватьCACHE_NAME — меняйте при обновлении логики
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(reg => console.log('✅ SW зарегистрирован:', reg.scope))
.catch(err => console.error('❌ Ошибка регистрации SW:', err));
});
}
</script>
Куда вставить: в <head> или перед </body>.
Проверяется наличие API и регистрируется SW после загрузки страницы.
<script>
(function() {
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone;
if (!isStandalone) return;
const images = [
{ href: '/img/splash-640x1136.png', media: '(device-width: 320px) and (device-height: 568px)' },
{ href: '/img/splash-750x1334.png', media: '(device-width: 375px) and (device-height: 667px)' },
{ href: '/img/splash-1125x2436.png', media: '(device-width: 375px) and (device-height: 812px)' }
];
images.forEach(img => {
const link = document.createElement('link');
link.rel = 'apple-touch-startup-image';
link.href = img.href;
if (img.media) link.media = img.media;
document.head.appendChild(link);
});
})();
</script>
iOS не поддерживает splash в манифесте — используется эмуляция через apple-touch-startup-image.
Формат: PNG/WebP, пропорции экрана устройства.
display: standaloneoffline.htmlРекомендация: используйте Lighthouse (в DevTools) → Audit → PWA. Цель — 90+ баллов.
Если всё работает — пользователи увидят предложение "Установить приложение".
Это решение — минимально достаточное для запуска PWA.
Работает на любой веб-платформе: Laravel, WordPress, OpenCart, чистый HTML.
Внедряется за несколько часов, масштабируется по мере роста требований.
Реализация полноценного Progressive Web App с автономной работой, установкой на экран, splash-экраном под iOS и сегментацией кэширования между PWA и браузерным режимом (смартфоны и ПК).
Особенности:
Все пути и имена обобщены — замените на свои при внедрении.
/manifest.json){
"name": "My Store",
"short_name": "Store",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"lang": "ru-RU",
"scope": "/",
"orientation": "portrait",
"categories": ["shopping"],
"splash_screen": {
"image": "/img/splash.webp",
"background_color": "#ffffff"
},
"icons": [
{
"src": "/img/icon-192-any.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icon-192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/img/icon-512-any.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/img/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"screenshots": [
{
"src": "/img/screen-1.webp",
"sizes": "800x600",
"type": "image/webp"
},
{
"src": "/img/screen-2.webp",
"sizes": "750x1334",
"type": "image/webp",
"form_factor": "narrow"
}
]
}
Куда положить: в корень сайта (доступен по https://yoursite.com/manifest.json).
Что поменять:
name, short_name — название вашего проектаsplash_screen.image — путь к splash-изображению (WebP, белый фон)icons — используйте Maskable.app для
генерацииbackground_color, theme_color — цвета интерфейса/offline.html)
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Оффлайн</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: sans-serif; text-align: center; padding: 50px; background: #f8f8f8; }
h1 { font-size: 2em; margin-bottom: 20px; }
p { font-size: 1.2em; margin-bottom: 30px; }
button {
padding: 10px 20px;
font-size: 1em;
background-color: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
button:hover { background-color: #0056b3; }
</style>
</head>
<body>
<h1>Вы сейчас оффлайн</h1>
<p>Пожалуйста, проверьте подключение к интернету.</p>
<button onclick="window.location.reload()">Обновить</button>
</body>
</html>
Файл должен быть полностью автономным — без внешних CSS/JS.
Добавьте его в список кэшируемых ресурсов в SW.
/service-worker.js)
// Имя кэша и fallback-страница
const CACHE_NAME = 'pwa-static-v1';
const OFFLINE_URL = '/offline.html';
const MAX_STATIC_SIZE = 30 * 1024 * 1024; // 30 МБ
const DYNAMIC_PAGE_CACHE = 'dynamic-pages-v1';
// Статические ресурсы, кэшируемые при установке
const staticResources = [
'/',
'/index.php',
'/manifest.json',
OFFLINE_URL,
'/img/splash.webp',
'/img/icon-192-any.png',
'/img/icon-192-maskable.png',
'/css/main.css',
'/js/app.js'
];
// Проверка URL на исключение из кэширования
function isExcluded(url) {
const EXCLUDED_PATTERNS = [
'api/', 'cart', 'checkout', 'account', 'login', 'register',
'admin', 'cron', 'upload', 'search', 'feed', 'sitemap'
];
return EXCLUDED_PATTERNS.some(pattern => url.includes(pattern));
}
// Очистка кэша по размеру
async function cleanupCache(cacheName, maxSize) {
const cache = await caches.open(cacheName);
const requests = await cache.keys();
let totalSize = 0;
const toDelete = [];
for (const req of requests) {
const response = await cache.match(req);
const size = await response.blob().then(b => b.size);
totalSize += size;
if (totalSize > maxSize) toDelete.push(req);
}
await Promise.all(toDelete.map(req => cache.delete(req)));
}
// Очистка динамического кэша (ограничение по количеству)
async function cleanupPageCache(maxCount = 20) {
const cache = await caches.open(DYNAMIC_PAGE_CACHE);
const keys = await cache.keys();
if (keys.length <= maxCount) return;
const toDelete = keys.slice(0, keys.length - maxCount);
await Promise.all(toDelete.map(req => cache.delete(req)));
}
// Предзагрузка страницы в фоне
let dynamicFetchController = null;
async function precacheDynamicPage(url) {
if (isExcluded(url)) return;
try {
if (dynamicFetchController) dynamicFetchController.abort();
dynamicFetchController = new AbortController();
const response = await fetch(url, { signal: dynamicFetchController.signal });
if (!response.ok) return;
const html = await response.text();
const pageCache = await caches.open(DYNAMIC_PAGE_CACHE);
await pageCache.put(url, new Response(html, { headers: response.headers }));
} catch (e) {
if (e.name !== 'AbortError') console.warn('⚠️ Ошибка предзагрузки:', url, e);
} finally {
dynamicFetchController = null;
}
}
// Установка: кэшируем статику
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAME);
await Promise.allSettled(
staticResources.map(async (url) => {
const controller = new AbortController();
setTimeout(() => controller.abort(), 8000);
try {
const res = await fetch(url, { signal: controller.signal });
if (res.ok) await cache.put(url, res.clone());
} catch (e) {
console.warn(`❌ Не удалось кэшировать ${url}`, e);
}
})
);
})()
);
});
// Активация: очистка старых данных
self.addEventListener('activate', (event) => {
event.waitUntil(
Promise.all([
self.skipWaiting(),
self.clients.claim(),
cleanupCache(CACHE_NAME, MAX_STATIC_SIZE),
cleanupPageCache(20)
])
);
});
// Обработка сообщений (например, цепочка навигации)
self.addEventListener('message', (event) => {
if (event.data.type === 'nav_stats') {
const urls = Object.keys(event.data.data)
.map(chain => chain.split(' → ').pop())
.filter(u => u && u !== '/' && !isExcluded(self.location.origin + u));
urls.forEach(url => precacheDynamicPage(self.location.origin + url));
}
});
// Основная стратегия fetch
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Критические ресурсы: JS, CSS, шрифты
if (/\\.(js|css|woff2)$/.test(url.pathname)) {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(res => {
if (res.ok) {
event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.put(event.request, res.clone())));
}
return res;
});
})
);
return;
}
// HTML-страницы
if (/\\.(php|html)?$/.test(url.pathname) || url.pathname === '/') {
if (isExcluded(url.href)) {
event.respondWith(fetch(event.request));
return;
}
event.respondWith(
caches.match(event.request).then(async (cached) => {
if (cached) {
// Фоновое обновление
event.waitUntil(fetch(event.request).then(newRes => {
if (newRes.ok) caches.open(CACHE_NAME).then(cache => cache.put(event.request, newRes.clone()));
}));
return cached;
}
// Попытка из динамического кэша
const dynamic = await caches.match(event.request, { cacheName: DYNAMIC_PAGE_CACHE });
if (dynamic) return dynamic;
// Fallback
return fetch(event.request).catch(() => caches.match(OFFLINE_URL));
})
);
return;
}
// Изображения
if (event.request.destination === 'image') {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).catch(() => caches.match('/img/logo.png'));
})
);
return;
}
// Всё остальное
event.respondWith(fetch(event.request));
});
Куда положить: в корень сайта.
Что поменять:
staticResources — добавьте свои CSS, JS, шрифтыisExcluded — исключите личные зоны (личный кабинет, корзина и т.п.)MAX_STATIC_SIZE — настройте под объём статики/browser/browser-service-worker.js)
const BROWSER_CACHE_NAME = 'browser-static-v1';
const MAX_CACHE_ENTRIES = 100;
const DYNAMIC_PAGE_CACHE = 'dynamic-pages-browser';
// Только общие ресурсы (без splash, manifest и т.п.)
const browserStaticResources = [
'/css/main.css',
'/js/app.js',
'/fonts/MaterialIcons-Regular.woff2'
];
// Аналогичные функции очистки
async function cleanupBrowserCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length <= maxEntries) return;
const toDelete = keys.slice(0, keys.length - maxEntries);
await Promise.all(toDelete.map(key => cache.delete(key)));
}
// Регистрация и активация
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(BROWSER_CACHE_NAME).then(cache => cache.addAll(browserStaticResources))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
Promise.all([
self.skipWaiting(),
self.clients.claim(),
cleanupBrowserCache(BROWSER_CACHE_NAME, MAX_CACHE_ENTRIES)
])
);
});
// Кэширование страниц и ресурсов
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (/\\.(js|css|woff2)$/.test(url.pathname)) {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(res => {
if (res.ok) {
event.waitUntil(caches.open(BROWSER_CACHE_NAME).then(cache => cache.put(event.request, res.clone())));
}
return res;
});
})
);
} else if (/\\.(php|html)?$/.test(url.pathname) || url.pathname === '/') {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(res => {
if (res.ok) {
event.waitUntil(caches.open(BROWSER_CACHE_NAME).then(cache => cache.put(event.request, res.clone())));
}
return res;
});
})
);
} else {
event.respondWith(fetch(event.request));
}
});
Отличие от PWA SW: не кэширует splash, manifest, offline — только общие ресурсы.
Scope: /browser/ — чтобы не конфликтовать с основным SW.
<script>
if ('serviceWorker' in navigator) {
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator.standalone === true);
window.addEventListener('load', () => {
if (isPWA) {
navigator.serviceWorker.register('/service-worker.js')
.then(() => console.log('✅ PWA SW зарегистрирован'))
.catch(err => console.error('❌ Ошибка регистрации PWA SW', err));
} else {
navigator.serviceWorker.register('/browser/browser-service-worker.js', { scope: '/browser/' })
.then(() => console.log('✅ Browser SW зарегистрирован'))
.catch(err => console.error('❌ Ошибка регистрации Browser SW', err));
}
});
}
</script>
Куда вставить: в секцию <head> или перед закрытием
</body>.
Проверка display-mode: standalone работает в Chrome, navigator.standalone — в Safari.
<script>
(function() {
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator.standalone === true);
if (!isPWA) return;
const startupImages = [
{ href: '/img/startup-640x1136.webp', media: '(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)' },
{ href: '/img/startup-750x1334.webp', media: '(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)' },
{ href: '/img/startup-1125x2436.webp', media: '(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)' },
{ href: '/img/startup-1242x2208.webp', media: '(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3)' }
];
startupImages.forEach(img => {
const link = document.createElement('link');
link.rel = 'apple-touch-startup-image';
link.href = img.href;
if (img.media) link.media = img.media;
document.head.appendChild(link);
});
})();
</script>
Этот код эмулирует splash-экран в Safari.
Рекомендация: используйте WebP без прозрачности и с белым фоном для корректного отображения splash-экранов.
Проектируйте архитектуру PWA с упором на масштабируемость — она неизбежна при развитии.
Два Service Worker — это в первую очередь независимость устройств, автономность работы и гибкость настройки.
Предзагрузка — это не только про TTFB, в первую очередь — про «нативное приложение» глазами пользователя.
Splash-экраны — первое касание с пользователем, подойдите к реализации основательно.
Fallback-страница при оффлайне может быть простой по структуре, но должна быть содержательной по сути — не оставляйте пользователя одного, особенно в таких ситуациях.
Перед сбором данных позаботьтесь о безопасности и конфиденциальности пользователей — доверие — это время, утрата доверия — миг.
Управление кэшем в экстренных случаях — например, когда нельзя быстро обновить ресурсы у пользователей из-за ошибок в коде — может повлиять на репутацию продукта. Готовьтесь к таким сценариям заранее.
Если маркетинговая стратегия компании позволяет — старайтесь не навязывать пользователю установку PWA, работайте так, чтобы это был выбор пользователя — хотеть установить ваше мобильное приложение. Это долгий и трудоёмкий процесс, но стратегически — победа компании и продукта.
⇪
Документы