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 строится на трёх технологиях:
HTTPS
PWA работает только через защищённое соединение. Это требование безопасности: Service Worker имеет огромную власть над сетевыми запросами, и без HTTPS это было бы опасно.
Исключение: localhost для разработки.
Web App Manifest
JSON-файл с метаданными приложения: название, иконки, цвета, режим отображения. Браузер использует его для установки и отображения приложения.
Service Worker
JavaScript-файл, который работает в фоне отдельно от страницы. Перехватывает сетевые запросы, кэширует ресурсы, обеспечивает офлайн-работу и получает push-уведомления.
Минимальная структура проекта
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
- Забыли обновить версию кэша — изменения не применяются
- Кэширование API-запросов без стратегии — устаревшие данные
- Не обрабатывается офлайн-режим — белый экран
- Manifest с ошибками — не работает установка
- SW регистрируется до загрузки страницы — замедление
- Нет fallback для fetch — необработанные ошибки
Чек-лист перед релизом
/* PWA Чек-лист */
□ HTTPS настроен
□ manifest.json валиден (проверить в DevTools)
□ Иконки всех размеров (192x192, 512x512 минимум)
□ Service Worker регистрируется
□ Приложение работает офлайн
□ Офлайн-страница настроена
□ Lighthouse score > 90
□ Тестирование на реальных устройствах
□ iOS мета-теги добавлены
□ Кэш версионируется
□ Стратегии кэширования настроены для разных ресурсов
□ Push-уведомления работают (если нужны)
□ Обновление SW корректно обрабатывается
Заключение
PWA — это мощная технология, которая позволяет создавать приложения, сравнимые с нативными, используя только веб-технологии. Основные преимущества:
- Нет комиссий — экономия 15-30% с каждой продажи
- Мгновенные обновления — без модерации магазинов
- Одна кодовая база — для всех платформ
- SEO — индексация поисковиками
- Низкий порог входа — установка одним кликом
🚀 Следующие шаги
- Создайте простое PWA по примеру из статьи
- Задеплойте на GitHub Pages или Netlify
- Протестируйте на мобильном устройстве
- Добавьте Push-уведомления
- Изучите web.dev/pwa для углубления знаний
Полезные ресурсы
- web.dev/pwa — официальное руководство Google
- PWABuilder — генератор манифеста и SW
- Maskable.app — создание адаптивных иконок
- What Web Can Do Today — возможности браузеров
- Can I Use — поддержка API браузерами