PWA: создаём приложения без сторов

Полный гайд по разработке Progressive Web Apps. Service Workers, офлайн-режим, push-уведомления. Без комиссий Apple и Google.

⏱ Время чтения: 25 минут

Представьте: вы создали приложение, пользователь устанавливает его одним кликом прямо из браузера, оно работает офлайн, отправляет push-уведомления — и всё это без App Store, Google Play, без $99 в год Apple и 30% комиссии с продаж.

Это не фантастика. Это PWA (Progressive Web App) — технология, которая уже сейчас используется Twitter, Starbucks, Pinterest, Uber и тысячами других компаний.

💡 Что вы узнаете

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

1. Что такое PWA и почему это важно

Progressive Web App — это веб-приложение, которое использует современные технологии для обеспечения пользовательского опыта, сравнимого с нативными приложениями.

Ключевые характеристики PWA

  • Progressive — работает у всех пользователей, независимо от браузера
  • Responsive — адаптируется под любой экран
  • Offline-first — работает без интернета
  • App-like — выглядит и ощущается как нативное приложение
  • Fresh — всегда актуальная версия благодаря Service Worker
  • Safe — только HTTPS
  • Discoverable — индексируется поисковиками
  • Installable — можно добавить на домашний экран
  • Linkable — легко поделиться ссылкой

Почему бизнесу выгодно PWA

📊 Реальные кейсы

  • Twitter Lite — 65% рост страниц за сессию, 75% рост твитов
  • Pinterest — 60% рост engagement, 44% рост рекламной выручки
  • Starbucks — PWA в 99.84% меньше нативного приложения
  • Uber — загрузка за 3 секунды на 2G сетях

Экономия на комиссиях

Apple и Google берут 15-30% комиссии с каждой покупки внутри приложения. При PWA вы принимаете платежи напрямую через Stripe, PayPal или любой другой сервис — комиссия 2-3%.

При доходе $100,000 в год:

  • App Store/Google Play: вы отдаёте $15,000-30,000
  • PWA + Stripe: вы отдаёте $2,900
  • Экономия: до $27,000 в год

2. PWA vs Native: честное сравнение

Критерий PWA Native
Установка из браузера
Работа офлайн
Push-уведомления ✓ (кроме iOS)
Доступ к камере
Геолокация
Bluetooth ✓ (Chrome)
NFC ✓ (Chrome Android)
Файловая система ✓ (ограниченно)
Фоновая синхронизация
Комиссия магазина 0% 15-30%
Модерация Не нужна 1-7 дней
Обновления Мгновенно Через магазин
SEO
Одна кодовая база iOS + Android

⚠️ Когда PWA не подходит

  • Игры с интенсивной графикой (нужен WebGL или нативный код)
  • Приложения, требующие глубокой интеграции с ОС
  • Сложная работа с Bluetooth/NFC на iOS
  • Push-уведомления на iOS (поддержка появилась только в iOS 16.4+)

3. Анатомия PWA: три кита

Любое PWA строится на трёх технологиях:

1

HTTPS

PWA работает только через защищённое соединение. Это требование безопасности: Service Worker имеет огромную власть над сетевыми запросами, и без HTTPS это было бы опасно.

Исключение: localhost для разработки.

2

Web App Manifest

JSON-файл с метаданными приложения: название, иконки, цвета, режим отображения. Браузер использует его для установки и отображения приложения.

3

Service Worker

JavaScript-файл, который работает в фоне отдельно от страницы. Перехватывает сетевые запросы, кэширует ресурсы, обеспечивает офлайн-работу и получает push-уведомления.

Минимальная структура проекта

📁 my-pwa/ ├── index.html ├── manifest.json ├── sw.js ├── app.js ├── styles.css └── 📁 icons/ ├── icon-192.png └── icon-512.png

4. Web App Manifest: паспорт приложения

Манифест — это JSON-файл, который сообщает браузеру, как ваше приложение должно выглядеть и вести себя при установке.

Полный пример manifest.json

{
  "name": "Мои Заметки",
  "short_name": "Заметки",
  "description": "Простое приложение для заметок с офлайн-доступом",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#667eea",
  "orientation": "portrait-primary",
  "scope": "/",
  "lang": "ru",
  "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": "Новая заметка",
      "short_name": "Новая",
      "url": "/new",
      "icons": [{ "src": "/icons/new.png", "sizes": "96x96" }]
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/main.png",
      "sizes": "1280x720",
      "type": "image/png"
    }
  ]
}

Разбор ключевых полей

display — режим отображения

  • fullscreen — на весь экран, без UI браузера
  • standalone — как нативное приложение, без адресной строки
  • minimal-ui — минимальный UI браузера
  • browser — обычная вкладка браузера

purpose для иконок

  • any — стандартная иконка
  • maskable — адаптивная иконка для Android (с безопасной зоной)
  • monochrome — одноцветная иконка

💡 Maskable иконки

Для Android важно создавать maskable-иконки — система может обрезать их в круг, квадрат или другую форму. Используйте maskable.app для проверки и создания таких иконок.

Подключение манифеста

<!-- В <head> вашего HTML -->
<link rel="manifest" href="/manifest.json">

<!-- Мета-теги для iOS (Safari не полностью поддерживает manifest) -->
<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-192.png">

<!-- Цвет темы -->
<meta name="theme-color" content="#667eea">

5. Service Worker: магия офлайна

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

Жизненный цикл Service Worker

// 1. Регистрация (в app.js)
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      console.log('SW зарегистрирован:', registration.scope);
    } catch (error) {
      console.error('Ошибка регистрации SW:', error);
    }
  });
}

Базовый Service Worker

// sw.js

const CACHE_NAME = 'notes-app-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/icons/icon-192.png',
  '/icons/icon-512.png'
];

// 2. Установка — кэшируем статические ресурсы
self.addEventListener('install', (event) => {
  console.log('SW: Установка');

  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('SW: Кэширование статики');
        return cache.addAll(STATIC_ASSETS);
      })
      .then(() => self.skipWaiting()) // Активируем сразу
  );
});

// 3. Активация — очищаем старый кэш
self.addEventListener('activate', (event) => {
  console.log('SW: Активация');

  event.waitUntil(
    caches.keys()
      .then((cacheNames) => {
        return Promise.all(
          cacheNames
            .filter((name) => name !== CACHE_NAME)
            .map((name) => caches.delete(name))
        );
      })
      .then(() => self.clients.claim()) // Берём контроль
  );
});

// 4. Перехват запросов
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((cachedResponse) => {
        // Возвращаем из кэша или делаем запрос
        return cachedResponse || fetch(event.request);
      })
  );
});

🔄 Важно про обновления

Service Worker обновляется только когда изменился хотя бы один байт в файле sw.js. После загрузки новой версии она ждёт, пока все вкладки с приложением будут закрыты. skipWaiting() и clients.claim() позволяют активировать новую версию сразу.

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

Выбор стратегии кэширования зависит от типа контента. Рассмотрим основные подходы.

Cache First (Сначала кэш)

Идеально для статических ресурсов: CSS, JS, шрифты, иконки.

// Сначала кэш, потом сеть
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((cached) => cached || fetch(event.request))
  );
});

Network First (Сначала сеть)

Для данных, которые часто обновляются: API, новости.

// Сначала сеть, при ошибке — кэш
self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // Сохраняем копию в кэш
        const clone = response.clone();
        caches.open(CACHE_NAME)
          .then((cache) => cache.put(event.request, clone));
        return response;
      })
      .catch(() => caches.match(event.request))
  );
});

Stale While Revalidate

Мгновенный отклик из кэша + обновление в фоне. Лучшее для баланса скорости и свежести.

// Отдаём кэш и обновляем в фоне
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) => {
      return cache.match(event.request).then((cached) => {
        const fetchPromise = fetch(event.request)
          .then((networkResponse) => {
            cache.put(event.request, networkResponse.clone());
            return networkResponse;
          });

        return cached || fetchPromise;
      });
    })
  );
});

Комбинированная стратегия

const CACHE_NAME = 'app-v1';
const STATIC_CACHE = 'static-v1';
const DYNAMIC_CACHE = 'dynamic-v1';

const STATIC_ASSETS = ['/', '/index.html', '/styles.css', '/app.js'];

// Определяем тип запроса
function isStaticAsset(url) {
  return STATIC_ASSETS.some((asset) => url.endsWith(asset));
}

function isApiRequest(url) {
  return url.includes('/api/');
}

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // Статика: Cache First
  if (isStaticAsset(url.pathname)) {
    event.respondWith(
      caches.match(request)
        .then((cached) => cached || fetch(request))
    );
    return;
  }

  // API: Network First
  if (isApiRequest(url.pathname)) {
    event.respondWith(
      fetch(request)
        .then((response) => {
          const clone = response.clone();
          caches.open(DYNAMIC_CACHE)
            .then((cache) => cache.put(request, clone));
          return response;
        })
        .catch(() => caches.match(request))
    );
    return;
  }

  // Остальное: Stale While Revalidate
  event.respondWith(
    caches.match(request).then((cached) => {
      const fetchPromise = fetch(request)
        .then((response) => {
          caches.open(DYNAMIC_CACHE)
            .then((cache) => cache.put(request, response.clone()));
          return response;
        });
      return cached || fetchPromise;
    })
  );
});

Офлайн-страница

// Показываем офлайн-страницу при отсутствии сети
self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request)
        .catch(() => caches.match('/offline.html'))
    );
  }
});

7. Push-уведомления

Push-уведомления позволяют отправлять сообщения пользователям даже когда приложение закрыто.

⚠️ Поддержка на iOS

Push-уведомления для PWA на iOS появились только в версии 16.4 (март 2023). Работают только для установленных на домашний экран приложений.

Шаг 1: Запрос разрешения

// app.js

async function requestNotificationPermission() {
  if (!('Notification' in window)) {
    console.log('Уведомления не поддерживаются');
    return false;
  }

  if (Notification.permission === 'granted') {
    return true;
  }

  if (Notification.permission !== 'denied') {
    const permission = await Notification.requestPermission();
    return permission === 'granted';
  }

  return false;
}

// Вызываем после действия пользователя (клика)
document.getElementById('enableNotifications')
  .addEventListener('click', async () => {
    const granted = await requestNotificationPermission();
    if (granted) {
      await subscribeToPush();
    }
  });

Шаг 2: Подписка на Push

// Публичный ключ VAPID (генерируется на сервере)
const VAPID_PUBLIC_KEY = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U';

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;
}

async function subscribeToPush() {
  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',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });

  console.log('Подписка оформлена:', subscription);
}

Шаг 3: Обработка Push в Service Worker

// sw.js

self.addEventListener('push', (event) => {
  console.log('Push получен');

  let data = { title: 'Уведомление', body: 'Новое сообщение' };

  if (event.data) {
    data = event.data.json();
  }

  const options = {
    body: data.body,
    icon: '/icons/icon-192.png',
    badge: '/icons/badge-72.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url || '/'
    },
    actions: [
      { action: 'open', title: 'Открыть' },
      { action: 'close', title: 'Закрыть' }
    ]
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// Клик по уведомлению
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'close') {
    return;
  }

  const url = event.notification.data.url;

  event.waitUntil(
    clients.matchAll({ type: 'window' })
      .then((windowClients) => {
        // Ищем открытое окно
        for (const client of windowClients) {
          if (client.url === url && 'focus' in client) {
            return client.focus();
          }
        }
        // Открываем новое окно
        return clients.openWindow(url);
      })
  );
});

Шаг 4: Отправка с сервера (Node.js)

// server.js
const webpush = require('web-push');

// Генерация ключей: npx web-push generate-vapid-keys
webpush.setVapidDetails(
  'mailto:admin@example.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

async function sendPushNotification(subscription, payload) {
  try {
    await webpush.sendNotification(
      subscription,
      JSON.stringify(payload)
    );
    console.log('Push отправлен');
  } catch (error) {
    console.error('Ошибка отправки push:', error);
  }
}

// Пример использования
sendPushNotification(userSubscription, {
  title: 'Новая заметка',
  body: 'Кто-то поделился с вами заметкой',
  url: '/notes/123'
});

8. Установка и промпт

Браузеры автоматически показывают предложение установить PWA, но вы можете управлять этим процессом.

Критерии для показа промпта

  • Приложение обслуживается через HTTPS
  • Есть валидный manifest.json
  • Зарегистрирован Service Worker
  • Пользователь провёл на сайте некоторое время

Кастомный промпт установки

// app.js

let deferredPrompt;
const installButton = document.getElementById('installBtn');

// Скрываем кнопку по умолчанию
installButton.style.display = 'none';

// Перехватываем событие beforeinstallprompt
window.addEventListener('beforeinstallprompt', (e) => {
  // Отменяем стандартный промпт
  e.preventDefault();
  // Сохраняем событие для позже
  deferredPrompt = e;
  // Показываем кнопку установки
  installButton.style.display = 'block';
});

// Обработчик клика на кнопку
installButton.addEventListener('click', async () => {
  if (!deferredPrompt) {
    return;
  }

  // Показываем промпт
  deferredPrompt.prompt();

  // Ждём ответа пользователя
  const { outcome } = await deferredPrompt.userChoice;
  console.log(`Пользователь ${outcome === 'accepted' ? 'установил' : 'отклонил'} приложение`);

  // Очищаем промпт
  deferredPrompt = null;
  installButton.style.display = 'none';
});

// Отслеживаем успешную установку
window.addEventListener('appinstalled', () => {
  console.log('PWA установлено!');
  deferredPrompt = null;
  // Можно отправить аналитику
  gtag('event', 'pwa_installed');
});

Определение режима запуска

// Проверяем, запущено ли как PWA
function isRunningAsPWA() {
  // iOS
  if (window.navigator.standalone) {
    return true;
  }

  // Android/Desktop
  if (window.matchMedia('(display-mode: standalone)').matches) {
    return true;
  }

  return false;
}

if (isRunningAsPWA()) {
  console.log('Приложение запущено как PWA');
  // Скрываем промо-баннер установки
  document.getElementById('installPromo').style.display = 'none';
}

9. Практика: приложение заметок

Создадим полноценное PWA — приложение для заметок с офлайн-режимом и синхронизацией.

index.html

<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Мои Заметки</title>

  <!-- PWA -->
  <link rel="manifest" href="/manifest.json">
  
  <meta name="theme-color" content="#667eea">

  <!-- iOS -->
  <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-192.png">

  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <div class="app">
    <!-- Шапка -->
    <header class="header">
      <h1>📝 Мои Заметки</h1>
      <div class="header-actions">
        <span id="onlineStatus" class="status online"></span>
        <button id="installBtn" class="btn-install" hidden>Установить</button>
      </div>
    </header>

    <!-- Форма добавления -->
    <form id="noteForm" class="note-form">
      <input 
        type="text" 
        id="noteTitle" 
        placeholder="Заголовок" 
        required
      >
      <textarea 
        id="noteContent" 
        placeholder="Текст заметки..." 
        rows="3" 
        required
      ></textarea>
      <button type="submit" class="btn-primary">Добавить заметку</button>
    </form>

    <!-- Список заметок -->
    <div id="notesList" class="notes-list">
      <!-- Заметки добавляются через JS -->
    </div>

    <!-- Сообщение о пустом списке -->
    <div id="emptyState" class="empty-state">
      <p>У вас пока нет заметок</p>
      <p>Создайте первую заметку выше ☝️</p>
    </div>
  </div>

  <script src="/app.js"></script>
</body>
</html>

styles.css

/* Базовые стили */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

:root {
  --primary: #667eea;
  --primary-dark: #5a67d8;
  --danger: #e53e3e;
  --success: #48bb78;
  --gray-100: #f7fafc;
  --gray-200: #edf2f7;
  --gray-600: #718096;
  --gray-800: #2d3748;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: var(--gray-100);
  color: var(--gray-800);
  min-height: 100vh;
}

/* Приложение */
.app {
  max-width: 600px;
  margin: 0 auto;
  padding: 0 16px 32px;
}

/* Шапка */
.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 20px 0;
  position: sticky;
  top: 0;
  background: var(--gray-100);
  z-index: 100;
}

.header h1 {
  font-size: 1.5rem;
}

.header-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

/* Статус онлайн/офлайн */
.status {
  font-size: 12px;
  padding: 4px 8px;
  border-radius: 12px;
  background: var(--gray-200);
}

.status.online {
  color: var(--success);
}

.status.offline {
  color: var(--danger);
}

/* Кнопка установки */
.btn-install {
  background: var(--primary);
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 8px;
  font-size: 14px;
  cursor: pointer;
  transition: background 0.2s;
}

.btn-install:hover {
  background: var(--primary-dark);
}

/* Форма */
.note-form {
  background: white;
  padding: 20px;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  margin-bottom: 24px;
}

.note-form input,
.note-form textarea {
  width: 100%;
  padding: 12px;
  border: 2px solid var(--gray-200);
  border-radius: 8px;
  font-size: 16px;
  margin-bottom: 12px;
  transition: border-color 0.2s;
}

.note-form input:focus,
.note-form textarea:focus {
  outline: none;
  border-color: var(--primary);
}

.note-form textarea {
  resize: vertical;
  min-height: 80px;
}

.btn-primary {
  width: 100%;
  background: var(--primary);
  color: white;
  border: none;
  padding: 14px;
  border-radius: 8px;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
}

.btn-primary:hover {
  background: var(--primary-dark);
}

/* Список заметок */
.notes-list {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* Карточка заметки */
.note-card {
  background: white;
  padding: 16px;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  animation: slideIn 0.3s ease;
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.note-card.syncing {
  opacity: 0.7;
  border-left: 3px solid var(--primary);
}

.note-card h3 {
  font-size: 1.1rem;
  margin-bottom: 8px;
  color: var(--gray-800);
}

.note-card p {
  color: var(--gray-600);
  font-size: 0.95rem;
  line-height: 1.5;
  margin-bottom: 12px;
}

.note-card-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.note-date {
  font-size: 0.8rem;
  color: var(--gray-600);
}

.note-actions {
  display: flex;
  gap: 8px;
}

.btn-delete {
  background: none;
  border: none;
  color: var(--danger);
  cursor: pointer;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 14px;
}

.btn-delete:hover {
  background: #fed7d7;
}

/* Пустое состояние */
.empty-state {
  text-align: center;
  padding: 60px 20px;
  color: var(--gray-600);
}

.empty-state p {
  margin: 8px 0;
}

.empty-state.hidden {
  display: none;
}

/* Уведомление о синхронизации */
.sync-toast {
  position: fixed;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%);
  background: var(--gray-800);
  color: white;
  padding: 12px 24px;
  border-radius: 8px;
  font-size: 14px;
  z-index: 1000;
  animation: fadeInUp 0.3s ease;
}

@keyframes fadeInUp {
  from {
    opacity: 0;
    transform: translateX(-50%) translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
  }
}

/* Адаптивность */
@media (max-width: 480px) {
  .header h1 {
    font-size: 1.2rem;
  }

  .note-form {
    padding: 16px;
  }
}

app.js

// ==========================================
// Приложение "Заметки" — PWA
// ==========================================

// Хранилище IndexedDB
class NotesDB {
  constructor() {
    this.dbName = 'NotesApp';
    this.dbVersion = 1;
    this.db = null;
  }

  async init() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.dbVersion);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve(this.db);
      };

      request.onupgradeneeded = (event) => {
        const db = event.target.result;

        if (!db.objectStoreNames.contains('notes')) {
          const store = db.createObjectStore('notes', { 
            keyPath: 'id' 
          });
          store.createIndex('createdAt', 'createdAt', { unique: false });
          store.createIndex('synced', 'synced', { unique: false });
        }
      };
    });
  }

  async getAllNotes() {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['notes'], 'readonly');
      const store = transaction.objectStore('notes');
      const request = store.getAll();

      request.onsuccess = () => {
        const notes = request.result.sort(
          (a, b) => new Date(b.createdAt) - new Date(a.createdAt)
        );
        resolve(notes);
      };
      request.onerror = () => reject(request.error);
    });
  }

  async addNote(note) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['notes'], 'readwrite');
      const store = transaction.objectStore('notes');
      const request = store.add(note);

      request.onsuccess = () => resolve(note);
      request.onerror = () => reject(request.error);
    });
  }

  async deleteNote(id) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['notes'], 'readwrite');
      const store = transaction.objectStore('notes');
      const request = store.delete(id);

      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  async updateNote(note) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['notes'], 'readwrite');
      const store = transaction.objectStore('notes');
      const request = store.put(note);

      request.onsuccess = () => resolve(note);
      request.onerror = () => reject(request.error);
    });
  }

  async getUnsyncedNotes() {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(['notes'], 'readonly');
      const store = transaction.objectStore('notes');
      const index = store.index('synced');
      const request = index.getAll(false);

      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}

// ==========================================
// Основной класс приложения
// ==========================================

class NotesApp {
  constructor() {
    this.db = new NotesDB();
    this.isOnline = navigator.onLine;
    this.deferredPrompt = null;

    this.init();
  }

  async init() {
    // Инициализация БД
    await this.db.init();

    // Регистрация Service Worker
    await this.registerServiceWorker();

    // Привязка событий
    this.bindEvents();

    // Отображение заметок
    await this.renderNotes();

    // Обновление статуса
    this.updateOnlineStatus();

    // Синхронизация при восстановлении соединения
    if (this.isOnline) {
      this.syncNotes();
    }
  }

  async registerServiceWorker() {
    if ('serviceWorker' in navigator) {
      try {
        const registration = await navigator.serviceWorker.register('/sw.js');
        console.log('SW registered:', registration.scope);

        // Слушаем обновления
        registration.addEventListener('updatefound', () => {
          const newWorker = registration.installing;
          newWorker.addEventListener('statechange', () => {
            if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
              this.showToast('Доступна новая версия! Обновите страницу.');
            }
          });
        });
      } catch (error) {
        console.error('SW registration failed:', error);
      }
    }
  }

  bindEvents() {
    // Форма добавления заметки
    document.getElementById('noteForm')
      .addEventListener('submit', (e) => this.handleAddNote(e));

    // Статус онлайн/офлайн
    window.addEventListener('online', () => this.handleOnline());
    window.addEventListener('offline', () => this.handleOffline());

    // Установка PWA
    window.addEventListener('beforeinstallprompt', (e) => this.handleInstallPrompt(e));

    document.getElementById('installBtn')
      .addEventListener('click', () => this.installApp());

    // Событие успешной установки
    window.addEventListener('appinstalled', () => {
      this.showToast('Приложение установлено!');
      document.getElementById('installBtn').hidden = true;
    });
  }

  // ==========================================
  // Работа с заметками
  // ==========================================

  async handleAddNote(e) {
    e.preventDefault();

    const title = document.getElementById('noteTitle').value.trim();
    const content = document.getElementById('noteContent').value.trim();

    if (!title || !content) return;

    const note = {
      id: Date.now().toString(),
      title,
      content,
      createdAt: new Date().toISOString(),
      synced: this.isOnline
    };

    await this.db.addNote(note);

    // Очищаем форму
    e.target.reset();

    // Обновляем UI
    await this.renderNotes();

    // Синхронизируем, если онлайн
    if (this.isOnline) {
      await this.syncNote(note);
    } else {
      this.showToast('Заметка сохранена локально');
    }
  }

  async handleDeleteNote(id) {
    if (!confirm('Удалить заметку?')) return;

    await this.db.deleteNote(id);
    await this.renderNotes();

    if (this.isOnline) {
      // Удаляем на сервере
      try {
        await fetch(`/api/notes/${id}`, { method: 'DELETE' });
      } catch (error) {
        console.log('Ошибка удаления на сервере', error);
      }
    }
  }

  async renderNotes() {
    const notes = await this.db.getAllNotes();
    const container = document.getElementById('notesList');
    const emptyState = document.getElementById('emptyState');

    if (notes.length === 0) {
      container.innerHTML = '';
      emptyState.classList.remove('hidden');
      return;
    }

    emptyState.classList.add('hidden');

    container.innerHTML = notes.map((note) => `
      <div class="note-card ${!note.synced ? 'syncing' : ''}" data-id="${note.id}">
        <h3>${this.escapeHtml(note.title)}</h3>
        <p>${this.escapeHtml(note.content)}</p>
        <div class="note-card-footer">
          <span class="note-date">
            ${this.formatDate(note.createdAt)}
            ${!note.synced ? '• Не синхронизировано' : ''}
          </span>
          <div class="note-actions">
            <button class="btn-delete" onclick="app.handleDeleteNote('${note.id}')">
              🗑️ Удалить
            </button>
          </div>
        </div>
      </div>
    `).join('');
  }

  // ==========================================
  // Синхронизация
  // ==========================================

  async syncNotes() {
    const unsyncedNotes = await this.db.getUnsyncedNotes();

    if (unsyncedNotes.length === 0) return;

    this.showToast(`Синхронизация ${unsyncedNotes.length} заметок...`);

    for (const note of unsyncedNotes) {
      await this.syncNote(note);
    }

    await this.renderNotes();
    this.showToast('Синхронизация завершена!');
  }

  async syncNote(note) {
    try {
      const response = await fetch('/api/notes', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(note)
      });

      if (response.ok) {
        note.synced = true;
        await this.db.updateNote(note);
      }
    } catch (error) {
      console.log('Ошибка синхронизации:', error);
    }
  }

  // ==========================================
  // Онлайн/офлайн
  // ==========================================

  handleOnline() {
    this.isOnline = true;
    this.updateOnlineStatus();
    this.showToast('Соединение восстановлено');
    this.syncNotes();
  }

  handleOffline() {
    this.isOnline = false;
    this.updateOnlineStatus();
    this.showToast('Вы офлайн. Данные сохраняются локально');
  }

  updateOnlineStatus() {
    const statusEl = document.getElementById('onlineStatus');

    if (this.isOnline) {
      statusEl.textContent = '● Онлайн';
      statusEl.className = 'status online';
    } else {
      statusEl.textContent = '● Офлайн';
      statusEl.className = 'status offline';
    }
  }

  // ==========================================
  // Установка PWA
  // ==========================================

  handleInstallPrompt(e) {
    e.preventDefault();
    this.deferredPrompt = e;
    document.getElementById('installBtn').hidden = false;
  }

  async installApp() {
    if (!this.deferredPrompt) return;

    this.deferredPrompt.prompt();

    const { outcome } = await this.deferredPrompt.userChoice;
    console.log(`Результат установки: ${outcome}`);

    this.deferredPrompt = null;
  }

  // ==========================================
  // Вспомогательные методы
  // ==========================================

  showToast(message, duration = 3000) {
    const existing = document.querySelector('.sync-toast');
    if (existing) existing.remove();

    const toast = document.createElement('div');
    toast.className = 'sync-toast';
    toast.textContent = message;
    document.body.appendChild(toast);

    setTimeout(() => toast.remove(), duration);
  }

  escapeHtml(text) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
  }

  formatDate(dateString) {
    const date = new Date(dateString);
    return date.toLocaleDateString('ru-RU', {
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit'
    });
  }
}

// Запуск приложения
const app = new NotesApp();

sw.js (полная версия)

// ==========================================
// Service Worker для приложения Заметки
// ==========================================

const CACHE_VERSION = 'v1';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;

// Файлы для предварительного кэширования
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/manifest.json',
  '/icons/icon-192.png',
  '/icons/icon-512.png',
  '/offline.html'
];

// Максимальное количество элементов в динамическом кэше
const MAX_DYNAMIC_CACHE_ITEMS = 50;

// ==========================================
// Установка
// ==========================================

self.addEventListener('install', (event) => {
  console.log('[SW] Установка');

  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then((cache) => {
        console.log('[SW] Кэширование статических ресурсов');
        return cache.addAll(STATIC_ASSETS);
      })
      .then(() => self.skipWaiting())
  );
});

// ==========================================
// Активация
// ==========================================

self.addEventListener('activate', (event) => {
  console.log('[SW] Активация');

  event.waitUntil(
    caches.keys()
      .then((cacheNames) => {
        return Promise.all(
          cacheNames
            .filter((name) => {
              return name !== STATIC_CACHE && name !== DYNAMIC_CACHE;
            })
            .map((name) => {
              console.log(`[SW] Удаление старого кэша: ${name}`);
              return caches.delete(name);
            })
        );
      })
      .then(() => self.clients.claim())
  );
});

// ==========================================
// Перехват запросов
// ==========================================

self.addEventListener('fetch', (event) => {
  const { request } = event;
  const url = new URL(request.url);

  // Пропускаем не-GET запросы
  if (request.method !== 'GET') {
    return;
  }

  // API запросы — Network First
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(request));
    return;
  }

  // Статические ресурсы — Cache First
  if (isStaticAsset(url.pathname)) {
    event.respondWith(cacheFirst(request));
    return;
  }

  // Навигация — Network First с fallback на офлайн-страницу
  if (request.mode === 'navigate') {
    event.respondWith(networkFirstWithOffline(request));
    return;
  }

  // Остальное — Stale While Revalidate
  event.respondWith(staleWhileRevalidate(request));
});

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

// Cache First — сначала кэш
async function cacheFirst(request) {
  const cached = await caches.match(request);
  return cached || fetch(request);
}

// Network First — сначала сеть
async function networkFirst(request) {
  try {
    const response = await fetch(request);
    const cache = await caches.open(DYNAMIC_CACHE);
    cache.put(request, response.clone());
    return response;
  } catch (error) {
    const cached = await caches.match(request);
    return cached;
  }
}

// Network First с офлайн-страницей для навигации
async function networkFirstWithOffline(request) {
  try {
    const response = await fetch(request);
    return response;
  } catch (error) {
    const cached = await caches.match(request);
    if (cached) return cached;

    // Возвращаем офлайн-страницу
    return caches.match('/offline.html');
  }
}

// Stale While Revalidate
async function staleWhileRevalidate(request) {
  const cache = await caches.open(DYNAMIC_CACHE);
  const cached = await cache.match(request);

  const fetchPromise = fetch(request)
    .then((response) => {
      cache.put(request, response.clone());
      trimCache(DYNAMIC_CACHE, MAX_DYNAMIC_CACHE_ITEMS);
      return response;
    })
    .catch(() => cached);

  return cached || fetchPromise;
}

// ==========================================
// Вспомогательные функции
// ==========================================

function isStaticAsset(pathname) {
  return STATIC_ASSETS.some((asset) => pathname.endsWith(asset));
}

async function trimCache(cacheName, maxItems) {
  const cache = await caches.open(cacheName);
  const keys = await cache.keys();

  if (keys.length > maxItems) {
    await cache.delete(keys[0]);
    trimCache(cacheName, maxItems);
  }
}

// ==========================================
// Push-уведомления
// ==========================================

self.addEventListener('push', (event) => {
  console.log('[SW] Push получен');

  let data = {
    title: 'Заметки',
    body: 'Новое уведомление',
    icon: '/icons/icon-192.png'
  };

  if (event.data) {
    data = { ...data, ...event.data.json() };
  }

  const options = {
    body: data.body,
    icon: data.icon,
    badge: '/icons/badge-72.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url || '/',
      dateOfArrival: Date.now()
    },
    actions: [
      { action: 'open', title: 'Открыть' },
      { action: 'close', title: 'Закрыть' }
    ]
  };

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'close') return;

  const url = event.notification.data.url;

  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true })
      .then((windowClients) => {
        for (const client of windowClients) {
          if (client.url === url && 'focus' in client) {
            return client.focus();
          }
        }
        return clients.openWindow(url);
      })
  );
});

// ==========================================
// Background Sync
// ==========================================

self.addEventListener('sync', (event) => {
  console.log(`[SW] Background sync: ${event.tag}`);

  if (event.tag === 'sync-notes') {
    event.waitUntil(syncNotes());
  }
});

async function syncNotes() {
  // Здесь логика синхронизации с сервером
  console.log('[SW] Синхронизация заметок...');
}

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: -apple-system, BlinkMacSystemFont, sans-serif;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      margin: 0;
      background: #f7fafc;
      color: #2d3748;
      text-align: center;
      padding: 20px;
    }
    h1 { font-size: 4rem; margin-bottom: 10px; }
    p { color: #718096; margin: 10px 0; }
    button {
      margin-top: 20px;
      padding: 12px 24px;
      background: #667eea;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 16px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <h1>📴</h1>
  <h2>Нет подключения к интернету</h2>
  <p>Проверьте соединение и попробуйте снова</p>
  <button onclick="location.reload()">Повторить</button>
</body>
</html>

10. Тестирование и отладка

Chrome DevTools

Chrome предоставляет мощные инструменты для работы с PWA:

  • Application → Manifest — проверка манифеста
  • Application → Service Workers — управление SW
  • Application → Cache Storage — просмотр кэша
  • Application → IndexedDB — просмотр локальной базы данных
  • Network → Offline — эмуляция офлайн-режима
  • Lighthouse — аудит PWA

Полезные приёмы отладки

// Принудительное обновление Service Worker
navigator.serviceWorker.getRegistration()
  .then((reg) => reg.update());

// Удаление всех кэшей
caches.keys().then((names) => {
  names.forEach((name) => caches.delete(name));
});

// Отмена регистрации SW
navigator.serviceWorker.getRegistrations()
  .then((registrations) => {
    registrations.forEach((reg) => reg.unregister());
  });

// Проверка состояния SW в консоли
navigator.serviceWorker.ready.then((reg) => {
  console.log('SW ready:', reg);
  console.log('Active SW:', reg.active);
  console.log('Scope:', reg.scope);
});

Lighthouse аудит

Lighthouse проверяет ваше PWA по следующим критериям:

  • ✓ Приложение работает офлайн
  • ✓ Есть валидный Web App Manifest
  • ✓ Зарегистрирован Service Worker
  • ✓ HTTPS или localhost
  • ✓ Быстрая загрузка на 3G
  • ✓ Адаптивный дизайн
  • ✓ Кастомный splash screen
  • ✓ Цвет адресной строки настроен

💡 Совет

В Chrome DevTools включите "Update on reload" в разделе Service Workers во время разработки. Это заставит браузер обновлять SW при каждой перезагрузке страницы.

Тестирование на реальных устройствах

// Для тестирования на мобильном устройстве в локальной сети:

// 1. Узнайте IP вашего компьютера
// macOS/Linux: ifconfig | grep "inet "
// Windows: ipconfig

// 2. Запустите сервер с привязкой к этому IP
npx serve -l 3000 --ssl-cert cert.pem --ssl-key key.pem

// 3. На мобильном откройте https://192.168.1.x:3000
// (понадобится принять самоподписанный сертификат)

11. Деплой и HTTPS

PWA требует HTTPS для работы (кроме localhost). Рассмотрим несколько вариантов бесплатного хостинга.

Вариант 1: GitHub Pages

# 1. Создайте репозиторий на GitHub

# 2. Загрузите файлы
git init
git add .
git commit -m "Initial PWA commit"
git remote add origin https://github.com/username/my-pwa.git
git push -u origin main

# 3. В настройках репозитория:
#    Settings → Pages → Source: main branch

# 4. Ваше PWA доступно по адресу:
#    https://username.github.io/my-pwa/

Вариант 2: Netlify

# 1. Установите Netlify CLI
npm install -g netlify-cli

# 2. Деплой одной командой
netlify deploy --prod --dir=.

# Или через drag-and-drop на netlify.com

Вариант 3: Vercel

# 1. Установите Vercel CLI
npm install -g vercel

# 2. Деплой
vercel --prod

# Автоматически получите HTTPS на домене .vercel.app

Вариант 4: Firebase Hosting

# 1. Установите Firebase CLI
npm install -g firebase-tools

# 2. Инициализация
firebase login
firebase init hosting

# 3. Деплой
firebase deploy

Настройка собственного домена

После деплоя вы можете подключить свой домен. Все перечисленные платформы предоставляют бесплатные SSL-сертификаты через Let's Encrypt.

⚠️ Важно про пути

Если приложение размещается не в корне домена (например, github.io/my-pwa/), обновите пути в manifest.json и Service Worker:

// manifest.json
{
  "start_url": "/my-pwa/",
  "scope": "/my-pwa/"
}

// sw.js — обновите пути к файлам
const STATIC_ASSETS = [
  '/my-pwa/',
  '/my-pwa/index.html',
  '/my-pwa/styles.css',
  // ...
];

12. Ограничения и подводные камни

Ограничения iOS Safari

Функция Chrome/Android Safari/iOS
Push-уведомления ✓ (iOS 16.4+, только установленные)
Background Sync
Промпт установки ✓ (автоматический) ✗ (только "Добавить на экран")
Хранилище Без ограничений* ~50MB (очищается через 7 дней неактивности)
Bluetooth/NFC
Badging API

Решение проблемы с iOS

// Показываем инструкцию для iOS пользователей
function isIOS() {
  return /iPad|iPhone|iPod/.test(navigator.userAgent);
}

function isInStandaloneMode() {
  return window.navigator.standalone === true;
}

if (isIOS() && !isInStandaloneMode()) {
  // Показываем баннер с инструкцией
  showIOSInstallBanner();
}

function showIOSInstallBanner() {
  const banner = document.createElement('div');
  banner.innerHTML = `
    <div class="ios-banner">
      <p>Установите приложение:</p>
      <p>Нажмите <strong>Поделиться</strong> 
         → <strong>На экран «Домой»</strong></p>
      <button onclick="this.parentElement.remove()">Закрыть</button>
    </div>
  `;
  document.body.appendChild(banner);
}

Типичные ошибки

❌ Частые ошибки при разработке PWA

  1. Забыли обновить версию кэша — изменения не применяются
  2. Кэширование API-запросов без стратегии — устаревшие данные
  3. Не обрабатывается офлайн-режим — белый экран
  4. Manifest с ошибками — не работает установка
  5. SW регистрируется до загрузки страницы — замедление
  6. Нет fallback для fetch — необработанные ошибки

Чек-лист перед релизом

/* PWA Чек-лист */

□ HTTPS настроен
□ manifest.json валиден (проверить в DevTools)
□ Иконки всех размеров (192x192, 512x512 минимум)
□ Service Worker регистрируется
□ Приложение работает офлайн
□ Офлайн-страница настроена
□ Lighthouse score > 90
□ Тестирование на реальных устройствах
□ iOS мета-теги добавлены
□ Кэш версионируется
□ Стратегии кэширования настроены для разных ресурсов
□ Push-уведомления работают (если нужны)
□ Обновление SW корректно обрабатывается

Заключение

PWA — это мощная технология, которая позволяет создавать приложения, сравнимые с нативными, используя только веб-технологии. Основные преимущества:

  • Нет комиссий — экономия 15-30% с каждой продажи
  • Мгновенные обновления — без модерации магазинов
  • Одна кодовая база — для всех платформ
  • SEO — индексация поисковиками
  • Низкий порог входа — установка одним кликом

🚀 Следующие шаги

  1. Создайте простое PWA по примеру из статьи
  2. Задеплойте на GitHub Pages или Netlify
  3. Протестируйте на мобильном устройстве
  4. Добавьте Push-уведомления
  5. Изучите web.dev/pwa для углубления знаний

Полезные ресурсы

  • web.dev/pwa — официальное руководство Google
  • PWABuilder — генератор манифеста и SW
  • Maskable.app — создание адаптивных иконок
  • What Web Can Do Today — возможности браузеров
  • Can I Use — поддержка API браузерами