Progressive Web Apps: native experience в браузере
Service Workers, offline работа и установка веб-приложений
PWA стирают границу между веб и нативными приложениями. Работают offline, устанавливаются на homescreen, получают push уведомления. Twitter, Starbucks, Uber — все используют PWA. Разберем как создать свое. Что такое PWA Progressive Web App — веб-приложение с возможностями нативного: ✅ Работает offline ✅ Устанавливается на устройство ✅ Push уведомления ✅ Полноэкранный режим ✅ Доступ к камере, GPS, sensors Требования PWA HTTPS — обязательно Web App Manifest — metadata приложения Service Worker — для offline работы Responsive — работает на всех устройствах Web App Manifest // manifest.json { "name": "My Awesome App", "short_name": "MyApp", "description": "Amazing PWA application", "start_url": "/", "display": "standalone", "theme_color": "#3367D6", "background_color": "#ffffff", "orientation": "portrait", "icons": [ { "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "any maskable" }, { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } ], "screenshots": [ { "src": "/screenshots/home.png", "sizes": "540x720", "type": "image/png" } ], "categories": ["productivity", "utilities"], "shortcuts": [ { "name": "New Document", "url": "/new", "icons": [{ "src": "/icons/new.png", "sizes": "96x96" }] } ] } Подключение в HTML: Service Worker: основы Регистрация: // main.js if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') .then(registration => { console.log('SW registered:', registration); }) .catch(error => { console.log('SW registration failed:', error); }); }); } Service Worker lifecycle: // sw.js const CACHE_NAME = 'v1'; const urlsToCache = [ '/', '/styles.css', '/script.js', '/offline.html' ]; // Install event self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME) .then(cache => { console.log('Opened cache'); return cache.addAll(urlsToCache); }) ); self.skipWaiting(); // Активировать сразу }); // Activate event self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => { if (cacheName !== CACHE_NAME) { return caches.delete(cacheName); } }) ); }) ); return self.clients.claim(); }); Caching стратегии 1. Cache First (offline first): self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request) .then(response => { // Возвращаем из кэша или делаем сетевой запрос return response || fetch(event.request); }) ); }); 2. Network First (fresh content): self.addEventListener('fetch', (event) => { 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(() => { // Fallback на кэш при ошибке return caches.match(event.request); }) ); }); 3. Stale While Revalidate: self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request).then(cachedResponse => { const fetchPromise = fetch(event.request).then(networkResponse => { caches.open(CACHE_NAME).then(cache => { cache.put(event.request, networkResponse.clone()); }); return networkResponse; }); // Возвращаем кэш сразу, обновляем в фоне return cachedResponse || fetchPromise; }) ); }); 4. Cache Only: self.addEventListener('fetch', (event) => { event.respondWith(caches.match(event.request)); }); 5. Network Only: self.addEventListener('fetch', (event) => { event.respondWith(fetch(event.request)); }); Advanced Caching Разные стратегии для разных ресурсов: self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); // API: Network First if (url.pathname.startsWith('/api/')) { event.respondWith(networkFirst(request)); } // Static assets: Cache First else if (request.destination === 'image' || request.destination === 'style' || request.destination === 'script') { event.respondWith(cacheFirst(request)); } // HTML: Stale While Revalidate else { event.respondWith(staleWhileRevalidate(request)); } }); Offline страница self.addEventListener('fetch', (event) => { event.respondWith( fetch(event.request) .catch(() => { // Если запрос к HTML странице if (event.request.mode === 'navigate') { return caches.match('/offline.html'); } // Для изображений показываем placeholder if (event.request.destination === 'image') { return caches.match('/offline-image.svg'); } }) ); }); Background Sync Отправка данных когда появится интернет: // В приложении navigator.serviceWorker.ready.then(registration => { registration.sync.register('sync-messages'); }); // В Service Worker self.addEventListener('sync', (event) => { if (event.tag === 'sync-messages') { event.waitUntil( sendMessages() // Отправляем накопленные сообщения ); } }); Push Notifications Запрос разрешения: async function subscribeToPush() { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY) }); // Отправляем subscription на сервер await fetch('/api/push-subscribe', { method: 'POST', body: JSON.stringify(subscription), headers: { 'Content-Type': 'application/json' } }); } Обработка push событий: // Service Worker self.addEventListener('push', (event) => { const data = event.data.json(); const options = { body: data.body, icon: '/icon.png', badge: '/badge.png', vibrate: [200, 100, 200], data: { url: data.url }, actions: [ { action: 'open', title: 'Open' }, { action: 'close', title: 'Close' } ] }; event.waitUntil( self.registration.showNotification(data.title, options) ); }); // Клик на уведомление self.addEventListener('notificationclick', (event) => { event.notification.close(); if (event.action === 'open') { event.waitUntil( clients.openWindow(event.notification.data.url) ); } }); Install Prompt let deferredPrompt; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; // Показываем свою кнопку "Установить" showInstallButton(); }); // По клику на кнопку installButton.addEventListener('click', async () => { if (!deferredPrompt) return; deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; if (outcome === 'accepted') { console.log('User accepted install'); } deferredPrompt = null; }); Update Strategy // Проверка обновлений navigator.serviceWorker.register('/sw.js').then(registration => { // Проверять каждый час setInterval(() => { registration.update(); }, 60 * 60 * 1000); }); // Уведомление о новой версии navigator.serviceWorker.addEventListener('controllerchange', () => { if (confirm('New version available! Reload to update?')) { window.location.reload(); } }); IndexedDB для offline data // Открытие базы const dbPromise = indexedDB.open('myDB', 1); dbPromise.onupgradeneeded = (event) => { const db = event.target.result; db.createObjectStore('messages', { keyPath: 'id', autoIncrement: true }); }; // Сохранение данных async function saveMessage(message) { const db = await dbPromise; const tx = db.transaction('messages', 'readwrite'); await tx.objectStore('messages').add(message); await tx.complete; } // Чтение данных async function getMessages() { const db = await dbPromise; return db.transaction('messages').objectStore('messages').getAll(); } Workbox: упрощение PWA // vite.config.js import { VitePWA } from 'vite-plugin-pwa'; export default { plugins: [ VitePWA({ registerType: 'autoUpdate', workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg}'], runtimeCaching: [ { urlPattern: /^https:\/\/api\.example\.com\/.*/, handler: 'NetworkFirst', options: { cacheName: 'api-cache', expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 // 1 hour } } }, { urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/, handler: 'CacheFirst', options: { cacheName: 'images-cache', expiration: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days } } } ] } }) ] }; Testing PWA Chrome DevTools: Application tab — проверка manifest, SW, storage Lighthouse — PWA audit Network throttling — тест offline режима Checklist: ✅ HTTPS ✅ Manifest валидный ✅ Service Worker зарегистрирован ✅ Offline страница работает ✅ Responsive на всех экранах ✅ Fast loading ( ✅ Install prompt работает Deployment Cache busting: // sw.js с версией const CACHE_VERSION = 'v2'; const CACHE_NAME = `my-app-${CACHE_VERSION}`; Headers для Service Worker: # netlify.toml [[headers]] for = "/sw.js" [headers.values] Cache-Control = "no-cache" PWA vs Native App PWA плюсы: Нет App Store approval Instant updates Меньше размер Один код для всех платформ SEO Native плюсы: Полный доступ к API устройства Лучшая производительность Офлайн работа сложных функций App Store visibility Заключение: PWA — будущее веб-приложений. Начните с manifest и базового Service Worker. Добавьте offline поддержку через caching стратегии. Используйте Workbox для упрощения. Тестируйте через Lighthouse. PWA дают 90% функциональности нативных приложений при 10% усилий. Инвестируйте в PWA для лучшего user experience.