Дата публикации документа: 01-03-2026

Дата обновления документа: 01-03-2026

Разработка PWA для Android и iOS

Подробное руководство PWA: пошаговая реализация мобильного веб-приложения с готовыми решениями.

Полноценное внедрение Progressive Web App: автономная работа, установка на экран, запуск как нативное приложение.

Документ ориентирован на решение следующих вопросов:

Содержание:

Все решения из документа находятся в промышленной эксплуатации на момент публикации. Этапы и время реализации:

Этапы реализации Время реализации
Проектирование архитектуры 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 месяцев.

Схема обработки запроса в PWA
Браузер
Режим: standalone?
Да → Регистрация service-worker.js
Нет → Регистрация browser-service-worker.js
Browser SW
PWA SW
Кэш найден?
Да → Быстрый ответ
Нет → Сеть
Сеть недоступна?
/offline.html
Клиент
Service Worker
Проверка
Действие
Offline

Схема показывает цепочку обработки запроса: выбор Service Worker, проверка кэша, fallback при отсутствии сети.

1. Web App Manifest: файл /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 не пройдёт проверку установки.

Ключевые поля

Генерация маскируемых иконок

Используйте Maskable.app для создания иконок с белым фоном и отступами не менее 12.5% — это гарантирует корректное отображение на устройствах с вырезами.

Поле splash_screen не стандартизировано. Для Safari используется альтернативный метод через apple-touch-startup-image.

На Android используется изображение из background_color и image при наличии apple-touch-startup-image (эмуляция).

2. Splash-экран под iOS

Поскольку Safari не поддерживает splash_screen из manifest.json, используем двухуровневое решение:

  1. Системный splash — через apple-touch-startup-image, мгновенно отображается при запуске.
  2. Кастомный splash — HTML-элемент, показывается после системного, маскирует загрузку контента.

Динамическая инъекция в <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, соответствующий разрешению его устройства.
  // Это создаёт эффект нативного приложения и скрывает время загрузки.
})();

Кастомный splash-экран (3 секунды)


<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.

3. Два уровня Service Worker

Архитектура

Используются два независимых SW:

Режим Файл Область действия Назначение
PWA /service-worker.js / Полноценное автономное приложение
Browser /browser/browser-service-worker.js /browser/ Оптимизация в обычном браузере

Регистрация SW: выбор режима


// Проверка поддержки 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.

4. Service Worker для PWA (/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));
});

5. Базовый оффлайн-режим (/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 — он кэшируется при установке.

6. Установка PWA: UX и аналитика

Ключевой момент успешного внедрения PWA — не просто техническая готовность, а опыт пользователя при установке. Ниже — реализация UX-потока и сбор аналитики без использования сторонних SDK.

6.1. Обнаружение возможности установки

Событие 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().

6.2. Модальные окна установки

Показываем модальное окно только после 90 секунд пребывания — это снижает возможное раздражение, делает приглашение к установке более естественным и улучшает опыт взаимодействия с вашим продуктом.

Для Android:


<!-- Модальное окно установки (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 секунд на сайте или при навигации - Не исчезает автоматически — пользователь должен закрыть вручную -->

6.3. Автоматический показ через 90 секунд


// Ждём полной загрузки 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: не навязываем, а приглашаем стать пользователем приложения.

6.4. Локальный трекинг и сбор аналитики

Все события отправляются на сервер через POST-запросы.

Генерация уникального ID устройства:


// Функция генерации уникального идентификатора устройства для 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 (альтернатива)
// - Храните данные временно или агрегируйте для отчётов
// - Учитывайте конфиденциальность: не собирайте персональные данные

6.5. Логирование навигации для предзагрузки

Собираем цепочки переходов, чтобы предзагружать следующие страницы.


// Глобальная переменная для хранения цепочки навигации пользователя
// Содержит последние 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% при последовательных переходах, а главное — создает вид «нативного приложения» в глазах пользователя.

7. Диагностика и администрирование

После внедрения PWA необходимо контролировать состояние кэша, обновления SW и целостность установок.

7.1. Инструменты диагностики

7.2. Проверка через консоль


// === Проверка регистрации 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 лучше полагаться на автоматическое обновление при загрузке

7.3. Очистка кэша (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 могут потребоваться быстрые действия по обновлению ресурсов у пользователей.

8. Автоматизированное тестирование PWA

Интеграция в CI/CD для защиты от regressions.

8.1. Проверка manifest.json


#!/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)

8.2. Проверка offline-работы (Lighthouse CI)

.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.

9. Мониторинг установок и аналитика поведения

9.1. Приём событий на сервере (/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-запросов — минимизация рисков

9.2. Ежедневный отчёт через cron


#!/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.

10. Заключение: Два вида готовых решений

10.1. Готовое решение: минимальное рабочее решение PWA

Реализация минимального рабочего решения мобильного приложения на базе PWA за 3 часа.

Особенности:

Все пути и имена обобщены — замените на свои при внедрении.

10.1.1. manifest.json — метаданные приложения

{
  "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.

Требования:

10.1.2. 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: 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.

10.1.3. service-worker.js — минимальный сервис-воркер


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));
    })
  );
});

Куда положить: в корень сайта.

Что поменять:

10.1.4. Регистрация Service Worker (в шаблон CMS)


<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 после загрузки страницы.

10.1.5. Splash-экран для iOS (необязательно, но рекомендуется для UX)


<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, пропорции экрана устройства.

10.1.6. Как проверить работоспособность

  1. Откройте сайт в Chrome → DevTools → Application → Manifest
  2. Убедитесь, что:
  3. Вкладка Service Workers — должен быть активен
  4. Перейдите в Network → выберите Offline → обновите страницу → должна открыться offline.html

Рекомендация: используйте Lighthouse (в DevTools) → Audit → PWA. Цель — 90+ баллов.

Если всё работает — пользователи увидят предложение "Установить приложение".

10.1.7. Дальнейшие улучшения (по желанию)

Это решение — минимально достаточное для запуска PWA.

Работает на любой веб-платформе: Laravel, WordPress, OpenCart, чистый HTML.

Внедряется за несколько часов, масштабируется по мере роста требований.

10.2. Готовое решение: полноценный PWA с двумя уровнями Service Worker

Реализация полноценного Progressive Web App с автономной работой, установкой на экран, splash-экраном под iOS и сегментацией кэширования между PWA и браузерным режимом (смартфоны и ПК).

Особенности:

Все пути и имена обобщены — замените на свои при внедрении.

10.2.1. Web App Manifest (/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).

Что поменять:

10.2.2. Оффлайн-страница (/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.

10.2.3. Service Worker для PWA (/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));
});

Куда положить: в корень сайта.

Что поменять:

10.2.4. Браузерный Service Worker (/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.

10.2.5. Регистрация SW в шаблоне CMS


<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.

10.2.6. Splash-экран под iOS


<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, работайте так, чтобы это был выбор пользователя — хотеть установить ваше мобильное приложение. Это долгий и трудоёмкий процесс, но стратегически — победа компании и продукта.

Документы