← Курс/Деплой Vue приложений#256 из 257+20 XP

Деплой Vue приложений

Сборка для продакшена

Команда npm run build запускает Vite/Rollup и создаёт оптимизированную статическую сборку в папке dist/:

npm run build
dist/
├── index.html          (точка входа)
└── assets/
    ├── index-Abc123.js  (основной JS с хэшем для кэша)
    ├── vendor-Xyz789.js (зависимости: vue, vue-router...)
    └── index-Def456.css

Все файлы минифицированы, tree-shaking удалил неиспользуемый код, CSS объединён. Имена файлов содержат хэш содержимого — браузер кэширует их навсегда.

Деплой на Vercel

Vercel — самый простой способ задеплоить Vue SPA:

npm install -g vercel
vercel

Или через GitHub: подключаете репозиторий в дашборде Vercel, и каждый push в main автоматически деплоит новую версию.

Для Vue Router (history mode) нужен vercel.json:

{
  "rewrites": [
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

Деплой на Netlify

Аналогично Vercel. Файл netlify.toml для настройки:

[build]
  command = "npm run build"
  publish = "dist"

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

Правило redirect здесь критично: без него при переходе по прямой ссылке /about Netlify вернёт 404 вместо index.html.

Nginx для SPA

При самостоятельном хостинге используют nginx. Ключевая настройка — try_files:

server {
    listen 80;
    server_name myapp.com;
    root /var/www/dist;
    index index.html;

    # Сжатие gzip
    gzip on;
    gzip_types text/plain text/css application/javascript application/json;

    # Кэширование статики (файлы с хэшем — кэш навсегда)
    location ~* \.(js|css|png|jpg|ico|woff2)$ {
        expires max;
        add_header Cache-Control "public, immutable";
    }

    # SPA fallback — всё остальное отдаёт index.html
    location / {
        try_files $uri $uri/ /index.html;
    }
}

Без try_files ... /index.html при обновлении страницы на /about nginx вернёт 404.

Переменные окружения в продакшене

# .env.production
VITE_API_URL=https://api.myapp.com
VITE_SENTRY_DSN=https://xxx@sentry.io/123
# При сборке Vite подставляет значения напрямую в JS-код:
npm run build

На Vercel/Netlify переменные добавляются в настройках проекта (не в файлах), чтобы не попасть в git-репозиторий.

Docker

# Этап 1: сборка
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Этап 2: продакшен-образ
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
docker build -t my-vue-app .
docker run -p 8080:80 my-vue-app

Multi-stage build: финальный образ содержит только nginx + статические файлы, без Node.js и исходного кода. Размер образа ~25MB вместо ~500MB.

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

npm run build      # убедиться что сборка проходит без ошибок
npm run test       # прогнать все тесты
npm run lint       # проверить код на ошибки
  • Переменные окружения настроены на хостинге
  • Настроены редиректы для SPA routing
  • Подключён HTTPS (обязательно для PWA)
  • Настроены заголовки кэширования
  • Примеры

    Симуляция деплой-пайплайна: сборка, проверка артефактов, стратегии кэширования и маршрутизация SPA

    // ============================================
    // Симуляция деплой-пайплайна Vue SPA
    // ============================================
    
    // 1. Симуляция артефактов сборки (npm run build)
    function simulateBuild(config) {
      console.log('=== npm run build ===')
      console.log(`  Mode: ${config.mode}`)
      console.log(`  Outdir: ${config.outDir}`)
    
      // Rollup генерирует хэши для долгосрочного кэширования
      function hash(str) {
        let h = 0
        for (const c of str) h = ((h << 5) - h + c.charCodeAt(0)) | 0
        return Math.abs(h).toString(36).slice(0, 8)
      }
    
      const chunks = [
        { name: 'vendor', content: 'vue+vue-router+pinia', size: 142 },
        { name: 'index',  content: 'App+HomePage+AboutPage', size: 38 },
        { name: 'admin',  content: 'AdminDashboard+UserTable', size: 22 },
      ]
    
      const files = [
        { path: 'index.html', size: 1, type: 'html' },
        ...chunks.map(c => ({
          path: `assets/${c.name}-${hash(c.content)}.js`,
          size: c.size,
          type: 'js',
          name: c.name,
        })),
        { path: `assets/index-${hash('styles')}.css`, size: 18, type: 'css' },
      ]
    
      const totalSize = files.reduce((s, f) => s + f.size, 0)
      console.log('\n  Артефакты:')
      for (const f of files) {
        const bar = '█'.repeat(Math.ceil(f.size / 5))
        console.log(`    ${f.path.padEnd(40)} ${String(f.size + 'KB').padStart(6)}  ${bar}`)
      }
      console.log(`\n  Итого: ${totalSize}KB`)
    
      return { files, success: true }
    }
    
    // 2. Симуляция nginx try_files — SPA routing
    function nginxRouter(requestPath, distFiles) {
      console.log(`\n  GET ${requestPath}`)
    
      // Nginx пробует: 1) точный путь, 2) путь/ (директория), 3) /index.html
      const exactMatch = distFiles.find(f => '/' + f.path === requestPath)
      if (exactMatch) {
        console.log(`    -> 200 OK: ${exactMatch.path} (${exactMatch.type})`)
        return { status: 200, file: exactMatch }
      }
    
      // SPA fallback — всегда отдаём index.html
      const indexHtml = distFiles.find(f => f.path === 'index.html')
      console.log(`    -> 200 OK: index.html (SPA fallback)`)
      return { status: 200, file: indexHtml }
    }
    
    // 3. Стратегия кэширования (Cache-Control заголовки)
    function getCacheHeader(filePath) {
      if (filePath === 'index.html') {
        // index.html НЕ кэшируем — он ссылается на актуальные хэшированные файлы
        return 'no-cache'
      }
      if (/\/assets\/.*\.(js|css)$/.test(filePath)) {
        // JS/CSS с хэшем в имени — кэш навсегда (содержимое никогда не меняется)
        return 'public, max-age=31536000, immutable'
      }
      if (/\.(png|jpg|ico|woff2|svg)$/.test(filePath)) {
        return 'public, max-age=86400'  // изображения — 1 день
      }
      return 'no-cache'
    }
    
    // 4. Замена переменных окружения (как делает Vite при сборке)
    function injectEnvVars(code, env) {
      let result = code
      for (const [key, value] of Object.entries(env)) {
        // Vite заменяет import.meta.env.VITE_XXX на строковый литерал
        result = result.replaceAll(
          `import.meta.env.${key}`,
          JSON.stringify(value)
        )
      }
      return result
    }
    
    // ============================================
    // Демонстрация
    // ============================================
    
    const buildResult = simulateBuild({
      mode: 'production',
      outDir: 'dist',
    })
    
    console.log('\n=== Стратегии кэширования ===')
    for (const file of buildResult.files) {
      const header = getCacheHeader(file.path)
      console.log(`  ${file.path.split('/').pop().padEnd(30)} Cache-Control: ${header}`)
    }
    
    console.log('\n=== SPA Routing (nginx try_files) ===')
    const requests = ['/', '/about', '/users/42', '/assets/index-abc123.js', '/favicon.ico']
    for (const req of requests) {
      nginxRouter(req, buildResult.files)
    }
    
    console.log('\n=== Замена env переменных при сборке ===')
    const sourceCode = `
    const api = import.meta.env.VITE_API_URL
    const title = import.meta.env.VITE_APP_TITLE
    const debug = import.meta.env.DEV
    `.trim()
    
    const compiled = injectEnvVars(sourceCode, {
      VITE_API_URL: 'https://api.myapp.com',
      VITE_APP_TITLE: 'Моё приложение',
      DEV: false,
    })
    
    console.log('  До:')
    sourceCode.split('\n').forEach(l => console.log('    ' + l))
    console.log('  После:')
    compiled.split('\n').forEach(l => console.log('    ' + l))