← React/PWA с React: Service Worker и кэширование#297 из 383← ПредыдущийСледующий →+30 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

PWA с React: Service Worker и кэширование

Что такое PWA

Progressive Web App (PWA) — веб-приложение, которое ведёт себя как нативное: работает офлайн, устанавливается на экран устройства, получает push-уведомления, имеет сплэш-экран.

Ключевые требования PWA:

  • HTTPS (обязательно для Service Worker)
  • Web App Manifest (иконки, название, цвет)
  • Service Worker (офлайн-работа, кэширование)
  • Responsive дизайн
  • # Create React App (устаревший) со встроенным PWA
    npx create-react-app my-app --template cra-template-pwa
    
    # Vite + vite-plugin-pwa (актуальный)
    npm install vite-plugin-pwa -D

    Web App Manifest

    // 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: жизненный цикл

    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  // кэш сразу, или ждём сеть
    }

    Workbox с React/Vite

    // 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 }
                }
              }
            ]
          }
        })
      ]
    })

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

    // 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()

    PWA с React: Service Worker и кэширование

    Что такое PWA

    Progressive Web App (PWA) — веб-приложение, которое ведёт себя как нативное: работает офлайн, устанавливается на экран устройства, получает push-уведомления, имеет сплэш-экран.

    Ключевые требования PWA:

  • HTTPS (обязательно для Service Worker)
  • Web App Manifest (иконки, название, цвет)
  • Service Worker (офлайн-работа, кэширование)
  • Responsive дизайн
  • # Create React App (устаревший) со встроенным PWA
    npx create-react-app my-app --template cra-template-pwa
    
    # Vite + vite-plugin-pwa (актуальный)
    npm install vite-plugin-pwa -D

    Web App Manifest

    // 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: жизненный цикл

    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  // кэш сразу, или ждём сеть
    }

    Workbox с React/Vite

    // 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 }
                }
              }
            ]
          }
        })
      ]
    })

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

    // 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).

    Загружаем среду выполнения...
    Загружаем AI-помощника...