Настройка PWA для сайта: превращаем веб-сайт в приложение

Progressive Web Apps (PWA) — это технология, которая позволяет превратить обычный сайт в полноценное приложение, работающее как нативное мобильное приложение. Я уже настроил PWA для более чем 30 проектов и честно говоря, результаты всегда впечатляют.

Что такое PWA и зачем это нужно

Progressive Web App — это веб-приложение, которое использует современные веб-технологии для обеспечения пользователям опыта, похожего на нативные мобильные приложения. На практике это означает, что ваш сайт можно установить на домашний экран смартфона, он будет работать офлайн и отправлять push-уведомления.

У меня был клиент с интернет-магазином одежды — после внедрения PWA конверсия на мобильных устройствах выросла на 47%. И это не случайность. PWA загружается в 2-3 раза быстрее обычного сайта, а пользователи в 5 раз чаще возвращаются к приложению, установленному на домашний экран.

Основные преимущества PWA:

ℹ️
Интересный факт: PWA приложения занимают в среднем в 10 раз меньше места, чем аналогичные нативные приложения. Twitter Lite весит всего 600KB против 23MB полного приложения Twitter.

Основные компоненты PWA

Чтобы сайт стал полноценным PWA, нужно реализовать три ключевых компонента: Service Worker, Web App Manifest и HTTPS. Каждый из них выполняет свою роль в создании app-like опыта.

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

Web App Manifest — это JSON-файл, который содержит метаданные о приложении: название, иконки, цвета, режим отображения. Именно он позволяет браузеру понять, что это PWA, и предложить пользователю установить приложение на домашний экран.

HTTPS — обязательное требование для PWA. Service Workers работают только по защищённому протоколу. Если у вас до сих пор нет SSL-сертификата, почитайте мою статью про что такое SSL-сертификат и зачем он нужен сайту.

Создание Web App Manifest

Начинаем с создания файла manifest.json в корне сайта. Я всегда делаю это первым шагом, потому что манифест — это основа PWA. Без него браузер не поймёт, что ваш сайт может быть установлен как приложение.

{
  "name": "Мой интернет-магазин",
  "short_name": "Магазин",
  "description": "Лучший интернет-магазин одежды и аксессуаров",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#007bff",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "categories": ["shopping", "lifestyle"],
  "lang": "ru",
  "dir": "ltr",
  "scope": "/",
  "prefer_related_applications": false
}

Теперь добавляем ссылку на манифест в секцию head всех страниц:

<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#007bff">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Магазин">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">

Обратите внимание на параметр "display": "standalone". Это означает, что приложение будет запускаться в полноэкранном режиме без адресной строки браузера. Есть и другие варианты: "fullscreen", "minimal-ui", "browser". На практике я чаще всего использую "standalone" — он даёт наиболее app-like ощущения.

💡
Совет по иконкам: Обязательно создайте иконку 512x512 пикселей — она используется в качестве сплэш-скрина при запуске PWA. А иконка 192x192 должна иметь атрибут "purpose": "any maskable" для корректного отображения на Android.

Настройка Service Worker

Service Worker — это сердце PWA. Он обеспечивает кеширование, офлайн-работу и перехват сетевых запросов. Создаём файл sw.js в корне сайта:

const CACHE_NAME = 'my-pwa-v1.2.0';
const STATIC_CACHE_URLS = [
  '/',
  '/css/main.css',
  '/js/main.js',
  '/icons/icon-192x192.png',
  '/icons/icon-512x512.png',
  '/offline.html'
];

// Установка Service Worker
self.addEventListener('install', (event) => {
  console.log('Service Worker устанавливается');
  
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('Кешируем статические ресурсы');
        return cache.addAll(STATIC_CACHE_URLS);
      })
      .then(() => {
        self.skipWaiting();
      })
  );
});

// Активация Service Worker
self.addEventListener('activate', (event) => {
  console.log('Service Worker активируется');
  
  event.waitUntil(
    caches.keys()
      .then((cacheNames) => {
        return Promise.all(
          cacheNames.map((cacheName) => {
            if (cacheName !== CACHE_NAME) {
              console.log('Удаляем старый кеш:', cacheName);
              return caches.delete(cacheName);
            }
          })
        );
      })
      .then(() => {
        self.clients.claim();
      })
  );
});

// Перехват сетевых запросов
self.addEventListener('fetch', (event) => {
  // Игнорируем запросы к внешним ресурсам
  if (!event.request.url.startsWith(self.location.origin)) {
    return;
  }

  event.respondWith(
    caches.match(event.request)
      .then((cachedResponse) => {
        // Если ресурс есть в кеше, возвращаем его
        if (cachedResponse) {
          return cachedResponse;
        }

        // Иначе делаем сетевой запрос
        return fetch(event.request)
          .then((response) => {
            // Проверяем, что ответ валидный
            if (!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }

            // Клонируем ответ для кеша
            const responseToCache = response.clone();

            caches.open(CACHE_NAME)
              .then((cache) => {
                cache.put(event.request, responseToCache);
              });

            return response;
          })
          .catch(() => {
            // Если запрос не удался, показываем офлайн-страницу
            if (event.request.destination === 'document') {
              return caches.match('/offline.html');
            }
          });
      })
  );
});

А теперь регистрируем Service Worker в основном JavaScript-файле сайта:

if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js')
      .then((registration) => {
        console.log('Service Worker зарегистрирован:', registration.scope);
        
        // Проверяем обновления каждые 24 часа
        registration.addEventListener('updatefound', () => {
          const newWorker = registration.installing;
          newWorker.addEventListener('statechange', () => {
            if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
              // Показываем уведомление об обновлении
              showUpdateNotification();
            }
          });
        });
      })
      .catch((error) => {
        console.log('Ошибка регистрации Service Worker:', error);
      });
  });
}

function showUpdateNotification() {
  if (confirm('Доступна новая версия приложения. Обновить?')) {
    window.location.reload();
  }
}

У одного клиента был интернет-магазин с плохим интернет-соединением у целевой аудитории. После внедрения такого Service Worker время загрузки повторных визитов сократилось с 3-4 секунд до 0.5 секунды. Пользователи стали покупать в 2 раза чаще.

Стратегии кеширования

Не все ресурсы нужно кешировать одинаково. Я использую разные стратегии в зависимости от типа контента. Статические файлы (CSS, JS, изображения) кеширую агрессивно, а динамический контент — с проверкой обновлений.

Cache First — сначала ищем в кеше, потом в сети. Подходит для статических ресурсов:

// Стратегия Cache First для статических ресурсов
if (event.request.destination === 'style' || 
    event.request.destination === 'script' || 
    event.request.destination === 'image') {
  
  event.respondWith(
    caches.match(event.request)
      .then((cachedResponse) => {
        return cachedResponse || fetch(event.request)
          .then((response) => {
            const responseClone = response.clone();
            caches.open(CACHE_NAME)
              .then((cache) => {
                cache.put(event.request, responseClone);
              });
            return response;
          });
      })
  );
}

Network First — сначала пытаемся загрузить из сети, если не получается — берём из кеша. Идеально для API-запросов:

// Стратегия Network First для API
if (event.request.url.includes('/api/')) {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        const responseClone = response.clone();
        caches.open(CACHE_NAME)
          .then((cache) => {
            cache.put(event.request, responseClone);
          });
        return response;
      })
      .catch(() => {
        return caches.match(event.request);
      })
  );
}

Stale While Revalidate — отдаём из кеша, но параллельно обновляем кеш из сети. Отличная стратегия для контента, который может быть немного устаревшим:

// Stale While Revalidate для HTML-страниц
if (event.request.destination === 'document') {
  event.respondWith(
    caches.open(CACHE_NAME)
      .then((cache) => {
        return cache.match(event.request)
          .then((cachedResponse) => {
            const fetchPromise = fetch(event.request)
              .then((networkResponse) => {
                cache.put(event.request, networkResponse.clone());
                return networkResponse;
              });
            
            return cachedResponse || fetchPromise;
          });
      })
  );
}
⚠️
Важно: Не кешируйте POST-запросы и запросы с авторизацией. Это может привести к утечке данных или некорректной работе приложения. Всегда проверяйте метод запроса и наличие заголовков авторизации.

Настройка push-уведомлений

Push-уведомления — одна из ключевых фишек PWA. Они работают даже когда браузер закрыт. Честно говоря, правильно настроенные push-уведомления могут увеличить возвраты пользователей на 20-30%.

Сначала просим разрешение на отправку уведомлений:

// Запрос разрешения на уведомления
async function requestNotificationPermission() {
  if (!('Notification' in window)) {
    console.log('Браузер не поддерживает уведомления');
    return false;
  }

  if (Notification.permission === 'granted') {
    return true;
  }

  if (Notification.permission === 'denied') {
    console.log('Пользователь запретил уведомления');
    return false;
  }

  const permission = await Notification.requestPermission();
  return permission === 'granted';
}

// Подписка на push-уведомления
async function subscribeToPush() {
  const hasPermission = await requestNotificationPermission();
  if (!hasPermission) {
    return;
  }

  const registration = await navigator.serviceWorker.ready;
  
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array('YOUR_VAPID_PUBLIC_KEY')
  });

  // Отправляем подписку на сервер
  await fetch('/api/push-subscribe', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(subscription)
  });
}

function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/-/g, '+')
    .replace(/_/g, '/');

  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);

  for (let i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

В Service Worker добавляем обработчик push-событий:

// Обработка входящих push-уведомлений
self.addEventListener('push', (event) => {
  const options = {
    body: 'У нас новые товары со скидкой до 50%!',
    icon: '/icons/icon-192x192.png',
    badge: '/icons/badge-72x72.png',
    image: '/images/push-image.jpg',
    data: {
      url: '/sale',
      timestamp: Date.now()
    },
    actions: [
      {
        action: 'open',
        title: 'Посмотреть',
        icon: '/icons/open.png'
      },
      {
        action: 'close',
        title: 'Закрыть',
        icon: '/icons/close.png'
      }
    ],
    requireInteraction: false,
    silent: false
  };

  if (event.data) {
    const pushData = event.data.json();
    options.body = pushData.body;
    options.data.url = pushData.url;
  }

  event.waitUntil(
    self.registration.showNotification('Мой магазин', options)
  );
});

// Обработка кликов по уведомлениям
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'close') {
    return;
  }

  const urlToOpen = event.notification.data.url || '/';

  event.waitUntil(
    clients.matchAll({
      type: 'window',
      includeUncontrolled: true
    })
    .then((clientList) => {
      // Если есть открытые окна, фокусируем одно из них
      for (let client of clientList) {
        if (client.url === urlToOpen && 'focus' in client) {
          return client.focus();
        }
      }
      
      // Иначе открываем новое окно
      if (clients.openWindow) {
        return clients.openWindow(urlToOpen);
      }
    })
  );
});

На серверной стороне нужно настроить отправку push-уведомлений. Для PHP я использую библиотеку web-push:

 [
        'subject' => 'mailto:admin@example.com',
        'publicKey' => 'YOUR_VAPID_PUBLIC_KEY',
        'privateKey' => 'YOUR_VAPID_PRIVATE_KEY'
    ]
];

$webPush = new WebPush($auth);

$subscription = Subscription::create([
    'endpoint' => $userSubscription['endpoint'],
    'publicKey' => $userSubscription['keys']['p256dh'],
    'authToken' => $userSubscription['keys']['auth']
]);

$payload = json_encode([
    'body' => 'Новый товар добавлен в избранное!',
    'url' => '/favorites'
]);

$result = $webPush->sendOneNotification($subscription, $payload);

if ($result->isSuccess()) {
    echo 'Уведомление отправлено';
} else {
    echo 'Ошибка: ' . $result->getReason();
}
?>

Оптимизация производительности PWA

PWA должно быть быстрым — это его главное преимущество. Я всегда стремлюсь к показателям Lighthouse выше 90 баллов. На практике это достижимо, но требует комплексного подхода.

Первое — lazy loading для изображений. Загружаем картинки только когда они попадают в viewport:

// Lazy loading изображений
const imageObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      img.classList.remove('lazy');
      observer.unobserve(img);
    }
  });
});

document.querySelectorAll('img[data-src]').forEach(img => {
  imageObserver.observe(img);
});

Второе — предзагрузка критически важных ресурсов. В head добавляем:

<link rel="preload" href="/css/critical.css" as="style">
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="prefetch" href="/js/non-critical.js">

Третье — code splitting для JavaScript. Разбиваем код на чанки:

// Динамическая загрузка модулей
async function loadModule(moduleName) {
  try {
    const module = await import(`./modules/${moduleName}.js`);
    return module.default;
  } catch (error) {
    console.error(`Ошибка загрузки модуля ${moduleName}:`, error);
  }
}

// Загружаем модуль только когда он нужен
document.getElementById('open-cart').addEventListener('click', async () => {
  const CartModule = await loadModule('cart');
  if (CartModule) {
    new CartModule().open();
  }
});

У меня был проект — корпоративный портал с кучей функций. После оптимизации First Contentful Paint улучшился с 2.8 до 1.1 секунды, а Time to Interactive — с 4.2 до 1.8 секунды. Пользователи стали работать с системой заметно активнее.

💡
Совет по размеру: Держите размер JavaScript-бандла до 200KB в сжатом виде. Если больше — обязательно используйте code splitting. Каждые дополнительные 100KB увеличивают время загрузки на мобильных устройствах на 0.5-1 секунду.

Настройка офлайн-режима

Офлайн-функциональность — то, что отличает PWA от обычного сайта. Пользователь должен иметь возможность просматривать контент даже без интернета. Но тут важно не переборщить — кешировать всё подряд не стоит.

Создаём специальную офлайн-страницу (offline.html):

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Нет подключения к интернету</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            text-align: center;
            padding: 50px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            min-height: 100vh;
            margin: 0;
            display: flex;
            align-items: center;
            justify-content: center;
            flex-direction: column;
        }
        
        .offline-icon {
            font-size: 4rem;
            margin-bottom: 20px;
        }
        
        .retry-button {
            background: white;
            color: #667eea;
            border: none;
            padding: 12px 24px;
            border-radius: 25px;
            font-size: 16px;
            cursor: pointer;
            margin-top: 20px;
            transition: transform 0.2s;
        }
        
        .retry-button:hover {
            transform: scale(1.05);
        }
    </style>
</head>
<body>
    <div class="offline-icon">📡</div>
    <h1>Упс! Нет интернета</h1>
    <p>Проверьте подключение к сети и попробуйте ещё раз</p>
    <button class="retry-button" onclick="window.location.reload()">
        Попробовать снова
    </button>
    
    <script>
        // Автоматическая проверка соединения
        window.addEventListener('online', () => {
            window.location.reload();
        });
    </script>
</body>
</html>

В Service Worker добавляем логику для определения типа контента и соответствующих офлайн-заглушек:

// Расширенная обработка офлайн-режима
self.addEventListener('fetch', (event) => {
  if (!event.request.url.startsWith(self.location.origin)) {
    return;
  }

  // Для навигационных запросов (страницы)
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request)
        .catch(() => {
          return caches.match('/offline.html');
        })
    );
    return;
  }

  // Для изображений показываем плейсхолдер
  if (event.request.destination === 'image') {
    event.respondWith(
      caches.match(event.request)
        .then((response) => {
          return response || fetch(event.request)
            .catch(() => {
              return caches.match('/images/placeholder.png');
            });
        })
    );
    return;
  }

  // Для API запросов возвращаем кешированные данные
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then((response) => {
          if (response.status === 200) {
            const responseClone = response.clone();
            caches.open(CACHE_NAME)
              .then((cache) => {
                cache.put(event.request, responseClone);
              });
          }
          return response;
        })
        .catch(() => {
          return caches.match(event.request)
            .then((cachedResponse) => {
              if (cachedResponse) {
                return cachedResponse;
              }
              // Возвращаем стандартную заглушку для API
              return new Response(
                JSON.stringify({ error: 'Данные недоступны офлайн' }),
                { 
                  status: 503,
                  headers: { 'Content-Type': 'application/json' }
                }
              );
            });
        })
    );
    return;
  }

  // Стандартная стратегия для остальных ресурсов
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        return response || fetch(event.request);
      })
  );
});

Тестирование и отладка PWA

Тестирование PWA — это отдельная наука. Я использую несколько инструментов для комплексной проверки. Chrome DevTools — основной инструмент для отладки Service Worker и проверки манифеста.

В Chrome DevTools открываем вкладку Application. Здесь можно:

Lighthouse — обязательный инструмент для аудита PWA. Запускаю его на каждом проекте и стремлюсь к максимальным оценкам. Особенно важны критерии:

Для автоматизированного тестирования я настраиваю проверки через PWA Builder или Workbox. Вот пример конфигурации для Workbox:

// workbox-config.js
module.exports = {
  globDirectory: 'dist/',
  globPatterns: [
    '**/*.{html,js,css,png,jpg,jpeg,svg,woff,woff2}'
  ],
  swDest: 'dist/sw.js',
  runtimeCaching: [
    {
      urlPattern: /^https:\/\/fonts\.googleapis\.com/,
      handler: 'StaleWhileRevalidate',
      options: {
        cacheName: 'google-fonts-stylesheets'
      }
    },
    {
      urlPattern: /^https:\/\/fonts\.gstatic\.com/,
      handler: 'CacheFirst',
      options: {
        cacheName: 'google-fonts-webfonts',
        expiration: {
          maxEntries: 30,
          maxAgeSeconds: 60 * 60 * 24 * 365 // 1 год
        }
      }
    },
    {
      urlPattern: /\/api\//,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'api-cache',
        expiration: {
          maxEntries: 100,
          maxAgeSeconds: 60 * 5 // 5 минут
        }
      }
    }
  ]
};

На практике я также тестирую PWA на реальных устройствах. Эмуляторы не всегда корректно отображают поведение Service Worker, особенно на iOS. У одного клиента PWA отлично работало в Chrome на Android, но на iPhone Safari возникали проблемы с кешированием. Пришлось добавить дополнительные проверки для WebKit.

Интеграция PWA с популярными CMS

Внедрение PWA в существующие проекты на WordPress или Bitrix имеет свои особенности. Каждая CMS требует индивидуального подхода к настройке маршрутизации и кеширования.

Для WordPress я обычно создаю кастомный плагин или использую готовые решения типа PWA for WP. Но честно говоря, самописное решение получается более гибким. Вот основная структура:

';
}

function register_service_worker() {
    if (!is_admin()) {
        echo "
        ";
    }
}

function add_pwa_meta_tags() {
    echo '
    
    
    
    ';
}

add_action('after_setup_theme', 'add_pwa_support');

// Создаём эндпоинт для API
add_action('rest_api_init', function() {
    register_rest_route('pwa/v1', '/posts', array(
        'methods' => 'GET',
        'callback' => 'get_pwa_posts',
        'permission_callback' => '__return_true'
    ));
});

function get_pwa_posts() {
    $posts = get_posts(array(
        'numberposts' => 10,
        'post_status' => 'publish'
    ));
    
    $data = array();
    foreach ($posts as $post) {
        $data[] = array(
            'id' => $post->ID,
            'title' => $post->post_title,
            'content' => wp_trim_words($post->post_content, 50),
            'url' => get_permalink($post->ID),
            'image' => get_the_post_thumbnail_url($post->ID, 'medium')
        );
    }
    
    return $data;
}
?>

Для Bitrix подход немного другой. Там я обычно создаю отдельный компонент или дорабатываю существующий шаблон:

GetCurPage(false) === '/sw.js') {
        $APPLICATION->RestartBuffer();
        
        // Генерируем Service Worker с учётом структуры Bitrix
        $swContent = generateServiceWorker();
        
        header('Content-Type: application/javascript');
        header('Cache-Control: no-cache');
        
        echo $swContent;
        exit();
    }
    
    // Добавляем мета-теги в head
    if (strpos($content, '') !== false) {
        $pwaHeaders = '
        
        
        
        ';
        
        $content = str_replace('', $pwaHeaders . '', $content);
    }
}

function generateServiceWorker() {
    // Получаем список критических страниц из Bitrix
    $cacheUrls = [
        '/',
        '/catalog/',
        '/about/',
        '/contacts/'
    ];
    
    // Добавляем стили и скрипты шаблона
    $templatePath = SITE_TEMPLATE_PATH;
    $cacheUrls[] = $templatePath . '/style.css';
    $cacheUrls[] = $templatePath . '/script.js';
    
    $swTemplate = file_get_contents($_SERVER['DOCUMENT_ROOT'] . '/local/templates/main/sw-template.js');
    
    return str_replace(
        '{{CACHE_URLS}}', 
        Json::encode($cacheUrls), 
        $swTemplate
    );
}
?>

При работе с Битрикс или WordPress важно учитывать особенности кеширования. WordPress имеет свою систему кеширования через плагины типа W3 Total Cache, а Bitrix — встроенный композитный кеш. Нужно настроить Service Worker так, чтобы он не конфликтовал с серверным кешированием.

Оптимизация PWA для мобильных устройств

Мобильная оптимизация PWA — это не просто адаптивный дизайн. Тут нужно думать о том, как пользователь будет взаимодействовать с приложением на маленьком экране, особенно одной рукой.

Первое — оптимизируем зоны касания. Все интерактивные элементы должны быть минимум 44px в высоту и ширину:

/* Оптимизация для touch-устройств */
.btn, .nav-item, .form-control {
    min-height: 44px;
    min-width: 44px;
    padding: 12px 16px;
}

/* Увеличиваем отступы для лучшего тапа */
.menu-item {
    padding: 16px 20px;
    margin: 4px 0;
}

/* Оптимизируем формы для мобильных */
input[type="text"], 
input[type="email"], 
input[type="tel"] {
    font-size: 16px; /* Предотвращает зум на iOS */
    padding: 12px 16px;
    border-radius: 8px;
}

/* Убираем hover-эффекты на touch-устройствах */
@media (hover: none) {
    .btn:hover {
        background-color: initial;
        transform: none;
    }
}

Второе — настраиваем viewport правильно. Многие забывают про user-scalable, но это важно для accessibility:

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">

Третье — оптимизируем загрузку шрифтов. На мобильных каждый килобайт критичен:

/* Эффективная загрузка шрифтов */
@font-face {
    font-family: 'CustomFont';
    src: url('/fonts/custom.woff2') format('woff2');
    font-weight: 400;
    font-style: normal;
    font-display: swap; /* Важно для производительности */
}

/* Fallback для системных шрифтов */
body {
    font-family: 'CustomFont', 
                 -apple-system, 
                 BlinkMacSystemFont, 
                 'Segoe UI', 
                 Roboto, 
                 sans-serif;
}

У меня был проект интернет-банкинга — после оптимизации под мобильные устройства количество операций через PWA выросло на 65%. Пользователи стали чаще заходить в приложение и проводить транзакции.

Безопасность PWA

Безопасность PWA — это не только HTTPS. Service Workers имеют широкие полномочия, поэтому важно правильно настроить Content Security Policy и валидацию запросов.

Настраиваем CSP заголовки для PWA:

# .htaccess для Apache
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.example.com; worker-src 'self';"

# Дополнительные заголовки безопасности
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"

В Service Worker добавляем валидацию запросов:

// Безопасная обработка запросов в Service Worker
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  
  // Блокируем подозрительные запросы
  if (url.pathname.includes('..') || 
      url.pathname.includes('admin') ||
      url.search.includes('script')) {
    event.respondWith(new Response('Forbidden', { status: 403 }));
    return;
  }
  
  // Проверяем origin для API запросов
  if (url.pathname.startsWith('/api/')) {
    const allowedOrigins = ['https://example.com', 'https://www.example.com'];
    const origin = event.request.headers.get('origin');
    
    if (origin && !allowedOrigins.includes(origin)) {
      event.respondWith(new Response('CORS Error', { status: 403 }));
      return;
    }
  }
  
  // Стандартная обработка
  event.respondWith(
    caches.match(event.request)
      .then(response => response || fetch(event.request))
  );
});

Также важно правильно обрабатывать чувствительные данные в кеше:

// Исключаем приватные данные из кеша
const PRIVATE_URLS = [
  '/api/user/profile',
  '/api/payments',
  '/admin',
  '/login'
];

self.addEventListener('fetch', (event) => {
  const url = event.request.url;
  
  // Не кешируем приватные данные
  if (PRIVATE_URLS.some(privateUrl => url.includes(privateUrl))) {
    event.respondWith(fetch(event.request));
    return;
  }
  
  // Не кешируем POST/PUT/DELETE запросы
  if (event.request.method !== 'GET') {
    event.respondWith(fetch(event.request));
    return;
  }
  
  // Стандартная логика кеширования
  event.respondWith(
    caches.match(event.request)
      .then(response => response || fetch(event.request))
  );
});
⚠️
Важно для безопасности: Никогда не кешируйте страницы с формами авторизации, токены доступа или персональные данные пользователей. Это может привести к утечке информации при использовании общего устройства.

PWA — это мощный инструмент для улучшения пользовательского опыта. При правильной настройке он может значительно увеличить вовлечённость и конверсию. Но помните — PWA должно решать реальные проблемы пользователей, а не быть данью моде. Если ваши пользователи в основном сидят за компьютерами, возможно, стоит сначала заняться улучшением Core Web Vitals и общей производительностью сайта.

Нужна помощь с настройкой PWA?

Поможем превратить ваш сайт в полноценное прогрессивное веб-приложение с оффлайн-режимом и push-уведомлениями.

П
Павел
Веб-разработчик · 10+ лет опыта · Bitrix, WordPress, Laravel

Читайте также

Настройка веб-хуков на сайте: автоматизация процессов 2026 Настройка кеширования Redis и Memcached для сайта в 2026 Как ускорить WordPress: практическое руководство