Progressive Web App (PWA) — веб-приложение, которое ведёт себя как нативное: работает офлайн, устанавливается на экран устройства, получает push-уведомления, имеет сплэш-экран.
Ключевые требования PWA:
# Create React App (устаревший) со встроенным PWA
npx create-react-app my-app --template cra-template-pwa
# Vite + vite-plugin-pwa (актуальный)
npm install vite-plugin-pwa -D// public/manifest.json
{
"name": "Моё приложение",
"short_name": "МоёПриложение",
"description": "Описание приложения",
"start_url": "/",
"display": "standalone", // fullscreen | standalone | minimal-ui | browser
"background_color": "#ffffff",
"theme_color": "#007AFF",
"orientation": "portrait",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"screenshots": [
{ "src": "/screenshot.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow" }
]
}Service Worker — скрипт, работающий в фоне браузера, перехватывает все сетевые запросы:
Регистрация → Установка (install) → Активация (activate) → Перехват запросов (fetch)// service-worker.js
const CACHE_NAME = 'my-app-v1'
const STATIC_ASSETS = ['/', '/index.html', '/main.js', '/styles.css']
// Установка: кэшируем статику
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
console.log('Кэшируем статику')
return cache.addAll(STATIC_ASSETS)
})
)
self.skipWaiting() // активируемся сразу, не ждём закрытия вкладок
})
// Активация: удаляем старые кэши
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
)
)
)
self.clients.claim() // берём контроль над всеми вкладками
})
// Fetch: перехватываем запросы
self.addEventListener('fetch', event => {
event.respondWith(cacheFirst(event.request))
})Cache First — сначала кэш, потом сеть (для статики):
async function cacheFirst(request) {
const cached = await caches.match(request)
return cached || fetch(request)
}Network First — сначала сеть, при ошибке кэш (для API):
async function networkFirst(request) {
try {
const response = await fetch(request)
const cache = await caches.open(CACHE_NAME)
cache.put(request, response.clone()) // обновляем кэш
return response
} catch {
return caches.match(request)
}
}Stale While Revalidate — отдаём кэш сразу, обновляем в фоне:
async function staleWhileRevalidate(request) {
const cached = await caches.match(request)
const fetchPromise = fetch(request).then(response => {
caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone()))
return response
})
return cached || fetchPromise // кэш сразу, или ждём сеть
}// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\//,
handler: 'NetworkFirst', // стратегия для API
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 300 }
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: 'CacheFirst', // стратегия для изображений
options: {
cacheName: 'images-cache',
expiration: { maxEntries: 60, maxAgeSeconds: 86400 }
}
}
]
}
})
]
})// src/main.tsx
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js')
.then(reg => {
console.log('SW зарегистрирован:', reg.scope)
// Слушаем обновления
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Доступна новая версия!
showUpdateBanner()
}
})
})
})
.catch(err => console.error('SW ошибка:', err))
})
}
// Баннер обновления
function UpdateBanner() {
const [showUpdate, setShowUpdate] = useState(false)
const handleUpdate = () => {
navigator.serviceWorker.controller?.postMessage({ type: 'SKIP_WAITING' })
window.location.reload()
}
if (!showUpdate) return null
return (
<div>
Доступна новая версия!
<button onClick={handleUpdate}>Обновить</button>
</div>
)
}Симуляция стратегий кэширования PWA в ванильном JS: Cache First, Network First и Stale While Revalidate с Map в роли кэша
// Симулируем три стратегии кэширования Service Worker.
// Map играет роль Cache Storage, колбэк — роль fetch().
// --- Симуляция сети ---
let networkCallCount = 0
let networkShouldFail = false
function simulateFetch(url) {
return new Promise((resolve, reject) => {
setTimeout(() => {
networkCallCount++
if (networkShouldFail) {
reject(new Error('Сеть недоступна'))
return
}
resolve({ url, data: 'Данные с сервера v' + networkCallCount, fresh: true })
}, 100)
})
}
// --- Стратегии ---
function createCacheStrategies() {
const cache = new Map()
return {
// Cache First: сначала кэш, потом сеть
async cacheFirst(key, fetchFn) {
if (cache.has(key)) {
console.log('[CacheFirst] Из кэша:', key)
return { ...cache.get(key), fromCache: true }
}
console.log('[CacheFirst] Загружаем из сети:', key)
const data = await fetchFn(key)
cache.set(key, data)
return { ...data, fromCache: false }
},
// Network First: сначала сеть, при ошибке кэш
async networkFirst(key, fetchFn) {
try {
console.log('[NetworkFirst] Пробуем сеть:', key)
const data = await fetchFn(key)
cache.set(key, data) // обновляем кэш
return { ...data, fromCache: false }
} catch (err) {
if (cache.has(key)) {
console.log('[NetworkFirst] Сеть недоступна, отдаём кэш:', key)
return { ...cache.get(key), fromCache: true, stale: true }
}
throw err
}
},
// Stale While Revalidate: кэш сразу + обновление в фоне
async staleWhileRevalidate(key, fetchFn) {
const cached = cache.get(key)
// Обновляем в фоне (не ждём)
const revalidatePromise = fetchFn(key)
.then(fresh => {
cache.set(key, fresh)
console.log('[SWR] Фоновое обновление кэша:', key)
})
.catch(() => console.log('[SWR] Фоновое обновление не удалось'))
if (cached) {
console.log('[SWR] Отдаём кэш немедленно:', key)
return { ...cached, fromCache: true }
}
console.log('[SWR] Кэша нет, ждём сеть:', key)
const fresh = await revalidatePromise.then(() => cache.get(key))
.catch(() => fetchFn(key))
return { ...fresh, fromCache: false }
},
getCacheSize() { return cache.size },
clearCache() { cache.clear() },
getCacheKeys() { return Array.from(cache.keys()) },
}
}
// --- Тесты ---
async function runTests() {
const strategies = createCacheStrategies()
console.log('=== Cache First ===')
const r1 = await strategies.cacheFirst('/api/user', simulateFetch)
console.log('Результат:', r1.data, '| Из кэша:', r1.fromCache)
const r2 = await strategies.cacheFirst('/api/user', simulateFetch)
console.log('Результат:', r2.data, '| Из кэша:', r2.fromCache)
console.log('
=== Network First ===')
const r3 = await strategies.networkFirst('/api/posts', simulateFetch)
console.log('Результат:', r3.data, '| Из кэша:', r3.fromCache)
networkShouldFail = true
const r4 = await strategies.networkFirst('/api/posts', simulateFetch)
console.log('Без сети:', r4.data, '| Из кэша:', r4.fromCache, '| Устаревший:', r4.stale)
networkShouldFail = false
console.log('
=== Stale While Revalidate ===')
const r5 = await strategies.staleWhileRevalidate('/api/news', simulateFetch)
console.log('Первый запрос (кэша нет):', r5.data)
// Небольшая задержка чтобы фоновое обновление успело
await new Promise(r => setTimeout(r, 200))
const r6 = await strategies.staleWhileRevalidate('/api/news', simulateFetch)
console.log('Второй запрос (из кэша):', r6.data, '| fromCache:', r6.fromCache)
console.log('
Всего запросов к сети:', networkCallCount)
console.log('Ключей в кэше:', strategies.getCacheSize())
console.log('Ключи:', strategies.getCacheKeys())
}
runTests()Progressive Web App (PWA) — веб-приложение, которое ведёт себя как нативное: работает офлайн, устанавливается на экран устройства, получает push-уведомления, имеет сплэш-экран.
Ключевые требования PWA:
# Create React App (устаревший) со встроенным PWA
npx create-react-app my-app --template cra-template-pwa
# Vite + vite-plugin-pwa (актуальный)
npm install vite-plugin-pwa -D// public/manifest.json
{
"name": "Моё приложение",
"short_name": "МоёПриложение",
"description": "Описание приложения",
"start_url": "/",
"display": "standalone", // fullscreen | standalone | minimal-ui | browser
"background_color": "#ffffff",
"theme_color": "#007AFF",
"orientation": "portrait",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"screenshots": [
{ "src": "/screenshot.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow" }
]
}Service Worker — скрипт, работающий в фоне браузера, перехватывает все сетевые запросы:
Регистрация → Установка (install) → Активация (activate) → Перехват запросов (fetch)// service-worker.js
const CACHE_NAME = 'my-app-v1'
const STATIC_ASSETS = ['/', '/index.html', '/main.js', '/styles.css']
// Установка: кэшируем статику
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
console.log('Кэшируем статику')
return cache.addAll(STATIC_ASSETS)
})
)
self.skipWaiting() // активируемся сразу, не ждём закрытия вкладок
})
// Активация: удаляем старые кэши
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))
)
)
)
self.clients.claim() // берём контроль над всеми вкладками
})
// Fetch: перехватываем запросы
self.addEventListener('fetch', event => {
event.respondWith(cacheFirst(event.request))
})Cache First — сначала кэш, потом сеть (для статики):
async function cacheFirst(request) {
const cached = await caches.match(request)
return cached || fetch(request)
}Network First — сначала сеть, при ошибке кэш (для API):
async function networkFirst(request) {
try {
const response = await fetch(request)
const cache = await caches.open(CACHE_NAME)
cache.put(request, response.clone()) // обновляем кэш
return response
} catch {
return caches.match(request)
}
}Stale While Revalidate — отдаём кэш сразу, обновляем в фоне:
async function staleWhileRevalidate(request) {
const cached = await caches.match(request)
const fetchPromise = fetch(request).then(response => {
caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone()))
return response
})
return cached || fetchPromise // кэш сразу, или ждём сеть
}// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\//,
handler: 'NetworkFirst', // стратегия для API
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 300 }
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: 'CacheFirst', // стратегия для изображений
options: {
cacheName: 'images-cache',
expiration: { maxEntries: 60, maxAgeSeconds: 86400 }
}
}
]
}
})
]
})// src/main.tsx
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js')
.then(reg => {
console.log('SW зарегистрирован:', reg.scope)
// Слушаем обновления
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Доступна новая версия!
showUpdateBanner()
}
})
})
})
.catch(err => console.error('SW ошибка:', err))
})
}
// Баннер обновления
function UpdateBanner() {
const [showUpdate, setShowUpdate] = useState(false)
const handleUpdate = () => {
navigator.serviceWorker.controller?.postMessage({ type: 'SKIP_WAITING' })
window.location.reload()
}
if (!showUpdate) return null
return (
<div>
Доступна новая версия!
<button onClick={handleUpdate}>Обновить</button>
</div>
)
}Симуляция стратегий кэширования PWA в ванильном JS: Cache First, Network First и Stale While Revalidate с Map в роли кэша
// Симулируем три стратегии кэширования Service Worker.
// Map играет роль Cache Storage, колбэк — роль fetch().
// --- Симуляция сети ---
let networkCallCount = 0
let networkShouldFail = false
function simulateFetch(url) {
return new Promise((resolve, reject) => {
setTimeout(() => {
networkCallCount++
if (networkShouldFail) {
reject(new Error('Сеть недоступна'))
return
}
resolve({ url, data: 'Данные с сервера v' + networkCallCount, fresh: true })
}, 100)
})
}
// --- Стратегии ---
function createCacheStrategies() {
const cache = new Map()
return {
// Cache First: сначала кэш, потом сеть
async cacheFirst(key, fetchFn) {
if (cache.has(key)) {
console.log('[CacheFirst] Из кэша:', key)
return { ...cache.get(key), fromCache: true }
}
console.log('[CacheFirst] Загружаем из сети:', key)
const data = await fetchFn(key)
cache.set(key, data)
return { ...data, fromCache: false }
},
// Network First: сначала сеть, при ошибке кэш
async networkFirst(key, fetchFn) {
try {
console.log('[NetworkFirst] Пробуем сеть:', key)
const data = await fetchFn(key)
cache.set(key, data) // обновляем кэш
return { ...data, fromCache: false }
} catch (err) {
if (cache.has(key)) {
console.log('[NetworkFirst] Сеть недоступна, отдаём кэш:', key)
return { ...cache.get(key), fromCache: true, stale: true }
}
throw err
}
},
// Stale While Revalidate: кэш сразу + обновление в фоне
async staleWhileRevalidate(key, fetchFn) {
const cached = cache.get(key)
// Обновляем в фоне (не ждём)
const revalidatePromise = fetchFn(key)
.then(fresh => {
cache.set(key, fresh)
console.log('[SWR] Фоновое обновление кэша:', key)
})
.catch(() => console.log('[SWR] Фоновое обновление не удалось'))
if (cached) {
console.log('[SWR] Отдаём кэш немедленно:', key)
return { ...cached, fromCache: true }
}
console.log('[SWR] Кэша нет, ждём сеть:', key)
const fresh = await revalidatePromise.then(() => cache.get(key))
.catch(() => fetchFn(key))
return { ...fresh, fromCache: false }
},
getCacheSize() { return cache.size },
clearCache() { cache.clear() },
getCacheKeys() { return Array.from(cache.keys()) },
}
}
// --- Тесты ---
async function runTests() {
const strategies = createCacheStrategies()
console.log('=== Cache First ===')
const r1 = await strategies.cacheFirst('/api/user', simulateFetch)
console.log('Результат:', r1.data, '| Из кэша:', r1.fromCache)
const r2 = await strategies.cacheFirst('/api/user', simulateFetch)
console.log('Результат:', r2.data, '| Из кэша:', r2.fromCache)
console.log('
=== Network First ===')
const r3 = await strategies.networkFirst('/api/posts', simulateFetch)
console.log('Результат:', r3.data, '| Из кэша:', r3.fromCache)
networkShouldFail = true
const r4 = await strategies.networkFirst('/api/posts', simulateFetch)
console.log('Без сети:', r4.data, '| Из кэша:', r4.fromCache, '| Устаревший:', r4.stale)
networkShouldFail = false
console.log('
=== Stale While Revalidate ===')
const r5 = await strategies.staleWhileRevalidate('/api/news', simulateFetch)
console.log('Первый запрос (кэша нет):', r5.data)
// Небольшая задержка чтобы фоновое обновление успело
await new Promise(r => setTimeout(r, 200))
const r6 = await strategies.staleWhileRevalidate('/api/news', simulateFetch)
console.log('Второй запрос (из кэша):', r6.data, '| fromCache:', r6.fromCache)
console.log('
Всего запросов к сети:', networkCallCount)
console.log('Ключей в кэше:', strategies.getCacheSize())
console.log('Ключи:', strategies.getCacheKeys())
}
runTests()Создай React компонент PWAStatus, который показывает статус PWA: индикатор онлайн/офлайн и кнопку установки. Компонент должен: использовать useState для isOnline (начальное значение navigator.onLine) и showInstallPrompt (false). Использовать useEffect для подписки на события "online" и "offline" окна. Отображать зелёный/красный индикатор в зависимости от isOnline. Показывать кнопку "Установить приложение" когда showInstallPrompt = true.
useState(navigator.onLine) для isOnline, useState(false) для showInstallPrompt. В useEffect: setIsOnline(true) для online, setIsOnline(false) для offline. Индикатор: "green" когда онлайн, "red" когда офлайн. Текст офлайн: "Офлайн". При клике на установку: setShowInstallPrompt(false).