Logo Craft Homelab Docs Контакты Telegram
PWA: прогрессивные веб-приложения — offline и установка
Fri Dec 26 2025

Progressive Web Apps: веб с нативными возможностями

PWA — веб-приложения, которые работают как нативные: offline, push notifications, установка на устройство. PWA сочетают доступность веба с возможностями нативных приложений. Пользователи могут установить приложение на домашний экран без посещения app store.

Web App Manifest

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

// manifest.json
{
  "name": "My PWA",
  "short_name": "MyApp",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3b82f6",
  "icons": [
    {
      "src": "/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

Поле display: standalone открывает приложение без адресной строки браузера, как нативное.

Подключите manifest в HTML:

<link rel="manifest" href="/manifest.json">

Service Worker

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

// sw.js
const CACHE_NAME = 'my-pwa-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js'
];

// Install
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll(urlsToCache))
  );
});

// Fetch
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then(response => response || fetch(event.request))
  );
});

// Activate
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then(cacheNames => {
      return Promise.all(
        cacheNames.map(cacheName => {
          if (cacheName !== CACHE_NAME) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});

Регистрация Service Worker

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => {
      console.log('SW registered:', registration);
    })
    .catch(error => {
      console.log('SW registration failed:', error);
    });
}

Workbox

importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js');

const { precacheAndRoute } = workbox.precaching;
const { registerRoute } = workbox.routing;
const { StaleWhileRevalidate, CacheFirst } = workbox.strategies;
const { ExpirationPlugin } = workbox.expiration;

precacheAndRoute([
  { url: '/', revision: '1' },
  { url: '/index.html', revision: '1' },
]);

registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60
      })
    ]
  })
);

Push Notifications

// Request permission
async function requestPermission() {
  const permission = await Notification.requestPermission();
  if (permission === 'granted') {
    console.log('Notification permission granted');
  }
}

// Subscribe
async function subscribe() {
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
  });
  // Send to server
}

// Receive
self.addEventListener('push', (event) => {
  const data = event.data.json();
  self.registration.showNotification(data.title, {
    body: data.body,
    icon: '/icon-192.png'
  });
});

Offline Fallback

// Показать offline страницу
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request).catch(() => {
      return caches.match('/offline.html');
    })
  );
});

Критерии PWA

Lighthouse требования:

  • HTTPS соединение
  • Valid manifest.json
  • Service Worker registered
  • Offline поддержка
  • Responsive дизайн
  • Fast load time (LCP < 2.5s)

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

Cache First:

registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images-cache'
  })
);

Network First:

registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 3
  })
);

Stale While Revalidate:

registerRoute(
  ({ request }) => request.destination === 'script' ||
                   request.destination === 'style',
  new StaleWhileRevalidate({
    cacheName: 'static-resources'
  })
);

Background Sync

// Регистрация синхронизации
async function queueMessage(message) {
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('send-message');
  
  // Сохраняем в IndexedDB
  const db = await openDB();
  await db.add('messages', message);
}

// Обработка в SW
self.addEventListener('sync', (event) => {
  if (event.tag === 'send-message') {
    event.waitUntil(sendQueuedMessages());
  }
});

IndexedDB

import { openDB } from 'idb';

const dbPromise = openDB('my-pwa', 1, {
  upgrade(db) {
    db.createObjectStore('posts', { keyPath: 'id' });
    db.createObjectStore('users', { keyPath: 'id' });
  }
});

// Запись
async function savePost(post) {
  const db = await dbPromise;
  await db.put('posts', post);
}

// Чтение
async function getPost(id) {
  const db = await dbPromise;
  return await db.get('posts', id);
}

// Все записи
async function getAllPosts() {
  const db = await dbPromise;
  return await db.getAll('posts');
}

Install Prompt

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferredPrompt = e;
  showInstallButton();
});

async function install() {
  if (!deferredPrompt) return;
  
  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  
  if (outcome === 'accepted') {
    console.log('User accepted install');
  }
  
  deferredPrompt = null;
}

App Shell Architecture

┌─────────────────────────┐
│     App Shell (HTML)    │
│  - Header               │
│  - Navigation           │
│  - Footer               │
└─────────────────────────┘

┌─────────────────────────┐
│    Dynamic Content      │
│  - Загружается через    │
│    API/Service Worker   │
└─────────────────────────┘

Преимущества:

  • Мгновенная загрузка оболочки
  • Кэширование UI отдельно от данных
  • Offline навигация работает

Push Notifications

VAPID ключи:

npx web-push generate-vapid-keys

Серверная часть:

import webpush from 'web-push';

webpush.setVapidDetails(
  'mailto:example@example.com',
  publicKey,
  privateKey
);

// Отправка
webpush.sendNotification(subscription, JSON.stringify({
  title: 'New Message',
  body: 'You have a new message'
}));

Performance оптимизация

Critical CSS:

<head>
  <style>
    /* Критичные стили inline */
    body { margin: 0; font-family: system-ui; }
    header { background: #333; }
  </style>
  <link rel="preload" href="/styles.css" as="style">
  <link rel="stylesheet" href="/styles.css">
</head>

Lazy loading:

<img src="hero.jpg" alt="Hero" loading="eager">
<img src="lazy.jpg" alt="Lazy" loading="lazy">

<script>
  // Динамический импорт
  const module = await import('./heavy-module.js');
</script>

Тестирование PWA

Lighthouse:

npm install -g lighthouse
lighthouse https://example.com --view

Chrome DevTools:

  • Application tab → Manifest
  • Application tab → Service Workers
  • Network tab → Offline mode

Workbox CLI:

workbox generateSW workbox-config.js

Деплой

Vercel:

{
  "buildCommand": "npm run build",
  "outputDirectory": "dist",
  "devCommand": "npm run dev"
}

Netlify:

# netlify.toml
[build]
  command = "npm run build"
  publish = "dist"

[[headers]]
  for = "/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000"

Ограничения

  • Нет доступа ко всем native API
  • Ограниченный доступ к файловой системе
  • Push notifications не работают на iOS < 16.4
  • Некоторые функции требуют HTTPS

Когда использовать PWA

Подходит:

  • Контент-ориентированные приложения
  • Нужен offline режим
  • Быстрый запуск без установки

Не подходит:

  • Требуется доступ к hardware
  • Сложная графика/игры
  • Интенсивная работа с файлами