14. Progressive Web Apps
PWA — это веб-приложения, которые ведут себя как нативные: работают офлайн, устанавливаются на устройство, получают push-уведомления.
Что делает PWA?
Заголовок раздела «Что делает PWA?»✅ Работает офлайн (Service Worker)✅ Устанавливается на домашний экран✅ Push-уведомления✅ Быстрая загрузка (кэш)✅ Работает как нативное приложение (без адресной строки)✅ App Shortcuts (ярлыки в иконке)Web App Manifest
Заголовок раздела «Web App Manifest»{ "name": "МагазинТехники", "short_name": "МагТех", "description": "Лучший магазин техники", "start_url": "/", "scope": "/", "display": "standalone", "orientation": "portrait", "theme_color": "#89b4fa", "background_color": "#1e1e2e", "icons": [ { "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" }, { "src": "/icons/icon-96.png", "sizes": "96x96", "type": "image/png" }, { "src": "/icons/icon-128.png", "sizes": "128x128", "type": "image/png" }, { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } ], "shortcuts": [ { "name": "Каталог", "url": "/catalog/", "icons": [{ "src": "/icons/catalog.png", "sizes": "96x96" }] }, { "name": "Корзина", "url": "/cart/", "icons": [{ "src": "/icons/cart.png", "sizes": "96x96" }] } ], "screenshots": [ { "src": "/screenshots/mobile.jpg", "sizes": "390x844", "type": "image/jpeg", "form_factor": "narrow" } ]}<!-- Подключаем в HTML --><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#89b4fa"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="default"><link rel="apple-touch-icon" href="/icons/icon-192.png">Service Worker
Заголовок раздела «Service Worker»const CACHE_NAME = 'app-v1';const STATIC_CACHE = 'static-v1';
// Ресурсы для предварительного кэшированияconst PRECACHE_ASSETS = [ '/', '/styles.css', '/app.js', '/offline.html', '/icons/icon-192.png',];
// Установка — кэшируем статикуself.addEventListener('install', (event) => { event.waitUntil( caches.open(STATIC_CACHE) .then(cache => cache.addAll(PRECACHE_ASSETS)) .then(() => self.skipWaiting()) // активируемся немедленно );});
// Активация — удаляем старые кэшиself.addEventListener('activate', (event) => { event.waitUntil( caches.keys() .then(keys => Promise.all( keys.filter(key => key !== CACHE_NAME && key !== STATIC_CACHE) .map(key => caches.delete(key)) )) .then(() => self.clients.claim()) // захватываем все клиенты );});
// Перехват запросовself.addEventListener('fetch', (event) => { const { request } = event;
// Навигационные запросы — Network First if (request.mode === 'navigate') { event.respondWith( fetch(request).catch(() => caches.match('/offline.html')) ); return; }
// Изображения — Cache First if (request.destination === 'image') { event.respondWith( caches.match(request).then(cached => { return cached || fetch(request).then(response => { caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone())); return response; }); }) ); return; }
// API — Network First с fallback if (request.url.includes('/api/')) { event.respondWith( fetch(request) .then(response => { caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone())); return response; }) .catch(() => caches.match(request)) ); return; }
// Остальное — Cache First event.respondWith( caches.match(request).then(cached => cached || fetch(request)) );});Push-уведомления
Заголовок раздела «Push-уведомления»// Запрос разрешенияasync function subscribeToPush() { const permission = await Notification.requestPermission(); if (permission !== 'granted') return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), });
// Отправляем подписку на сервер await fetch('/api/push/subscribe', { method: 'POST', body: JSON.stringify(subscription), headers: { 'Content-Type': 'application/json' }, });}
// В Service Workerself.addEventListener('push', (event) => { const data = event.data.json();
event.waitUntil( self.registration.showNotification(data.title, { body: data.body, icon: '/icons/icon-192.png', badge: '/icons/badge.png', data: { url: data.url }, actions: [ { action: 'open', title: 'Открыть' }, { action: 'dismiss', title: 'Закрыть' }, ], }) );});
self.addEventListener('notificationclick', (event) => { event.notification.close();
if (event.action === 'open' || !event.action) { event.waitUntil(clients.openWindow(event.notification.data.url)); }});Next.js PWA
Заголовок раздела «Next.js PWA»npm install next-pwaconst withPWA = require('next-pwa')({ dest: 'public', register: true, skipWaiting: true, disable: process.env.NODE_ENV === 'development',});
module.exports = withPWA({ // ваши настройки Next.js});Background Sync
Заголовок раздела «Background Sync»// Синхронизация данных при восстановлении соединенияself.addEventListener('sync', (event) => { if (event.tag === 'sync-orders') { event.waitUntil(syncPendingOrders()); }});
async function syncPendingOrders() { const db = await openDB('offline-store', 1); const pendingOrders = await db.getAll('pending-orders');
for (const order of pendingOrders) { try { await fetch('/api/orders', { method: 'POST', body: JSON.stringify(order), }); await db.delete('pending-orders', order.id); } catch (error) { console.error('Sync failed:', error); } }}
// Регистрация синхронизацииasync function submitOrderOffline(order) { const db = await openDB('offline-store', 1); await db.put('pending-orders', order);
const registration = await navigator.serviceWorker.ready; await registration.sync.register('sync-orders');}