← Курс/PWA с Vue и Vite#255 из 257+30 XP

PWA с Vue и Vite

Что такое PWA

**Progressive Web App (PWA)** — это веб-приложение, которое работает как нативное: может быть установлено на рабочий стол, работает офлайн, отправляет push-уведомления. Три ключевых компонента PWA: HTTPS, Web App Manifest и Service Worker.

Установка vite-plugin-pwa

npm install -D vite-plugin-pwa
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    vue(),
    VitePWA({
      registerType: 'autoUpdate',  // автообновление SW
      manifest: {
        name: 'Моё Vue приложение',
        short_name: 'VueApp',
        description: 'Описание приложения',
        theme_color: '#4DBA87',
        background_color: '#ffffff',
        display: 'standalone',     // как нативное приложение
        icons: [
          { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
        ],
      },
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\//,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 100, maxAgeSeconds: 300 },
            },
          },
        ],
      },
    }),
  ],
})

Web App Manifest

Manifest.json описывает приложение для браузера и ОС. Без него браузер не предложит установку:

{
  "name": "Моё Vue приложение",
  "short_name": "VueApp",
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#4DBA87",
  "background_color": "#ffffff",
  "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", "purpose": "maskable" }
  ]
}

Service Worker и стратегии кэширования

Service Worker — это JavaScript-файл, работающий в фоне отдельно от страницы. Он перехватывает сетевые запросы:

Cache First      — сначала кэш, затем сеть (иконки, шрифты)
Network First    — сначала сеть, при ошибке — кэш (API данные)
Stale While Revalidate — сразу кэш, параллельно обновляет (страницы)
Cache Only       — только кэш (офлайн-режим)
Network Only     — только сеть (без кэширования)

Кнопка установки (Install Prompt)

// src/composables/useInstallPrompt.ts
import { ref, onMounted } from 'vue'

export function useInstallPrompt() {
  const installPrompt = ref(null)
  const isInstallable = ref(false)

  onMounted(() => {
    window.addEventListener('beforeinstallprompt', (e) => {
      e.preventDefault()  // не показываем автоматический баннер
      installPrompt.value = e
      isInstallable.value = true
    })
  })

  async function install() {
    if (!installPrompt.value) return
    const result = await installPrompt.value.prompt()
    if (result.outcome === 'accepted') {
      isInstallable.value = false
      installPrompt.value = null
    }
  }

  return { isInstallable, install }
}

Обновление Service Worker

// src/composables/useUpdateSW.ts
import { useRegisterSW } from 'virtual:pwa-register/vue'

export function useUpdateSW() {
  const { needRefresh, updateServiceWorker } = useRegisterSW({
    onRegistered(sw) {
      console.log('SW зарегистрирован:', sw)
    },
    onRegisterError(error) {
      console.error('Ошибка регистрации SW:', error)
    },
  })

  return { needRefresh, updateServiceWorker }
}
<!-- Показываем уведомление об обновлении -->
<template>
  <div v-if="needRefresh" class="update-banner">
    Доступно обновление!
    <button @click="updateServiceWorker()">Обновить</button>
  </div>
</template>

Офлайн-поддержка

При правильной настройке Workbox, приложение будет работать даже без интернета: статические файлы (HTML, CSS, JS) берутся из кэша. Для отображения офлайн-статуса:

import { useOnline } from '@vueuse/core'
const isOnline = useOnline()

Примеры

Эмуляция Service Worker: кэширование запросов со стратегиями Cache First, Network First и Stale While Revalidate

// ============================================
// Эмуляция Service Worker — стратегии кэширования
// ============================================
// Service Worker перехватывает fetch-запросы и решает:
// взять из кэша или сходить в сеть.
// Здесь мы симулируем эту логику в виде обычного класса.

// Симуляция кэша (аналог Cache API)
class Cache {
  constructor(name) {
    this.name = name
    this._store = new Map()
  }

  async put(url, response) {
    this._store.set(url, { response, timestamp: Date.now() })
    console.log(`  [Cache "${this.name}"] сохранён: ${url}`)
  }

  async match(url) {
    return this._store.get(url) || null
  }

  async delete(url) {
    this._store.delete(url)
  }

  get size() { return this._store.size }
}

// Симуляция сети
async function networkFetch(url, options = {}) {
  const { latency = 100, shouldFail = false } = options

  await new Promise(r => setTimeout(r, latency))

  if (shouldFail) {
    throw new Error('Network error: offline')
  }

  return {
    url,
    body: { data: `Свежие данные из сети для ${url}`, time: Date.now() },
    ok: true,
    clone() { return { ...this } },
  }
}

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

class ServiceWorkerStrategies {
  constructor() {
    this.caches = {}
  }

  getCache(name) {
    if (!this.caches[name]) this.caches[name] = new Cache(name)
    return this.caches[name]
  }

  // Cache First: сначала кэш, при промахе — сеть
  async cacheFirst(url, cacheName, networkOptions) {
    const cache = this.getCache(cacheName)
    const cached = await cache.match(url)

    if (cached) {
      console.log(`  [CacheFirst] HIT: ${url}`)
      return cached.response
    }

    console.log(`  [CacheFirst] MISS: ${url} — идём в сеть`)
    const response = await networkFetch(url, networkOptions)
    await cache.put(url, response.clone())
    return response
  }

  // Network First: сначала сеть, при ошибке — кэш
  async networkFirst(url, cacheName, networkOptions) {
    const cache = this.getCache(cacheName)

    try {
      console.log(`  [NetworkFirst] запрос к сети: ${url}`)
      const response = await networkFetch(url, networkOptions)
      await cache.put(url, response.clone())
      return response
    } catch (err) {
      console.log(`  [NetworkFirst] сеть недоступна: ${err.message}`)
      const cached = await cache.match(url)
      if (cached) {
        console.log(`  [NetworkFirst] возвращаем из кэша: ${url}`)
        return cached.response
      }
      throw new Error(`Нет ни сети, ни кэша для ${url}`)
    }
  }

  // Stale While Revalidate: сразу кэш + фоновое обновление
  async staleWhileRevalidate(url, cacheName, networkOptions) {
    const cache = this.getCache(cacheName)
    const cached = await cache.match(url)

    // Запускаем обновление в фоне
    const updatePromise = networkFetch(url, networkOptions)
      .then(response => {
        cache.put(url, response.clone())
        console.log(`  [SWR] фоновое обновление завершено: ${url}`)
      })
      .catch(err => console.log(`  [SWR] фоновое обновление провалилось: ${err.message}`))

    if (cached) {
      console.log(`  [SWR] возвращаем из кэша сразу: ${url}`)
      // Обновление идёт в фоне
      return cached.response
    }

    // Кэша нет — ждём сеть
    console.log(`  [SWR] кэша нет, ждём сеть: ${url}`)
    return await updatePromise.then(() => cache.match(url).then(c => c.response))
  }
}

// ============================================
// Демонстрация
// ============================================

async function demo() {
  const sw = new ServiceWorkerStrategies()

  console.log('=== Cache First (статические файлы) ===')
  await sw.cacheFirst('/assets/logo.png', 'static-v1', { latency: 50 })
  await sw.cacheFirst('/assets/logo.png', 'static-v1', { latency: 50 })  // из кэша

  console.log('\n=== Network First (API) ===')
  await sw.networkFirst('/api/users', 'api-cache', { latency: 30 })
  await sw.networkFirst('/api/users', 'api-cache', { latency: 30, shouldFail: true })  // кэш

  console.log('\n=== Network First — офлайн без кэша ===')
  try {
    await sw.networkFirst('/api/new-endpoint', 'api-cache', { shouldFail: true })
  } catch (e) {
    console.log(`  Ошибка: ${e.message}`)
  }

  console.log('\n=== Stale While Revalidate (страницы) ===')
  // Сначала заполняем кэш
  const cache = sw.getCache('pages-v1')
  await cache.put('/about', { body: 'Старые данные страницы /about', ok: true, clone() { return this } })

  await sw.staleWhileRevalidate('/about', 'pages-v1', { latency: 200 })

  console.log('\n=== Статистика кэшей ===')
  for (const [name, cache] of Object.entries(sw.caches)) {
    console.log(`  Cache "${name}": ${cache.size} записей`)
  }
}

demo()