Progressive Web Apps (PWA) — это технология, которая позволяет превратить обычный сайт в полноценное приложение, работающее как нативное мобильное приложение. Я уже настроил PWA для более чем 30 проектов и честно говоря, результаты всегда впечатляют.
Что такое PWA и зачем это нужно
Progressive Web App — это веб-приложение, которое использует современные веб-технологии для обеспечения пользователям опыта, похожего на нативные мобильные приложения. На практике это означает, что ваш сайт можно установить на домашний экран смартфона, он будет работать офлайн и отправлять push-уведомления.
У меня был клиент с интернет-магазином одежды — после внедрения PWA конверсия на мобильных устройствах выросла на 47%. И это не случайность. PWA загружается в 2-3 раза быстрее обычного сайта, а пользователи в 5 раз чаще возвращаются к приложению, установленному на домашний экран.
Основные преимущества PWA:
- Быстрая загрузка — даже при медленном интернете
- Работа офлайн или при плохом соединении
- Возможность установки на устройство без App Store/Google Play
- Push-уведомления прямо в браузере
- Автоматические обновления без участия пользователя
- Экономия места на устройстве по сравнению с нативными приложениями
Основные компоненты 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 ощущения.
Настройка 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;
});
})
);
}
Настройка 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 секунды. Пользователи стали работать с системой заметно активнее.
Настройка офлайн-режима
Офлайн-функциональность — то, что отличает 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. Здесь можно:
- Проверить корректность манифеста (раздел Manifest)
- Посмотреть статус Service Worker (раздел Service Workers)
- Проанализировать кеш (раздел Storage)
- Протестировать push-уведомления
- Симулировать офлайн-режим
Lighthouse — обязательный инструмент для аудита PWA. Запускаю его на каждом проекте и стремлюсь к максимальным оценкам. Особенно важны критерии:
- Installable — приложение можно установить
- PWA Optimized — соответствует стандартам PWA
- Performance — производительность выше 90
- Best Practices — соблюдение лучших практик
Для автоматизированного тестирования я настраиваю проверки через 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-уведомлениями.