Перейти к содержимому

14. Progressive Web Apps

PWA — это веб-приложения, которые ведут себя как нативные: работают офлайн, устанавливаются на устройство, получают push-уведомления.

✅ Работает офлайн (Service Worker)
✅ Устанавливается на домашний экран
✅ Push-уведомления
✅ Быстрая загрузка (кэш)
✅ Работает как нативное приложение (без адресной строки)
✅ App Shortcuts (ярлыки в иконке)
public/manifest.json
{
"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">
public/sw.js
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))
);
});
// Запрос разрешения
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 Worker
self.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));
}
});
Окно терминала
npm install next-pwa
next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
});
module.exports = withPWA({
// ваши настройки Next.js
});
// Синхронизация данных при восстановлении соединения
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');
}