Перейти к содержанию

Клиент Vite: разработка и готовая сборка

API каталога работает отдельно, но пользователю нужен читаемый экран. В этом уроке создадим небольшой клиент на обычном JavaScript и Vite. Затем разделим два режима: dev-сервер с исходниками для разработки и image с готовыми статическими файлами для nginx. Результат — собственный двухсервисный проект с понятным маршрутом браузерного запроса.

Здесь не требуется React или готовый пример из другой серии. Клиент строит список через DOM и обращается к уже знакомому /api/courses. Фиксируем учебный Vite 8.0.10; это выбранный профиль, а не утверждение о latest. Его официальный манифест версии допускает Node.js 24, который используем в этой серии.

Подготовим исходники клиента

В корне professorweb-container-lab создайте frontend с подпапкой src. Ниже полные файлы клиента. Сначала frontend/package.json:

{
  "name": "professorweb-container-lab-client",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=24 <25"
  },
  "scripts": {
    "dev": "vite --host 0.0.0.0 --port 5173 --strictPort",
    "build": "vite build"
  },
  "devDependencies": {
    "vite": "8.0.10"
  }
}

Далее frontend/index.html. Файл содержит один экран, статус загрузки и кнопку повторного обращения. Он не изображает готовую серверную HTML-страницу со списком: записи появятся после ответа API.

<!doctype html>
<html lang="ru">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Каталог учебных серий</title></head>
<body>
  <main><p class="eyebrow">ProfessorWeb · Container lab</p><h1>Каталог учебных серий</h1>
    <p id="status" role="status" aria-live="polite">Подготовка каталога…</p>
    <button id="reload" type="button">Обновить каталог</button>
    <ul id="courses" aria-label="Учебные серии"></ul>
  </main><script type="module" src="/src/main.js"></script>
</body></html>

В frontend/src/main.js разместите полный контроллер чтения каталога:

import './style.css';
const status = document.querySelector('#status');
const list = document.querySelector('#courses');
const reload = document.querySelector('#reload');
async function loadCatalog() {
  reload.disabled = true;
  status.textContent = 'Загрузка каталога…';
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 5000);
  try {
    const response = await fetch('/api/courses', { signal: controller.signal });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const body = await response.json();
    if (body.project !== 'professorweb-container-lab'
      || !Array.isArray(body.courses) || body.courses.length < 1
      || body.courses.length > 100) throw new Error('Invalid catalog');
    const ids = new Set();
    const fragment = document.createDocumentFragment();
    for (const course of body.courses) {
      if (course === null || typeof course !== 'object'
        || typeof course.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(course.id)
        || ids.has(course.id) || typeof course.title !== 'string'
        || !course.title.trim() || course.title.length > 100
        || !Number.isInteger(course.lessons) || course.lessons < 1
        || course.lessons > 1000) throw new Error('Invalid course');
      ids.add(course.id);
      const item = document.createElement('li');
      const title = document.createElement('strong');
      const count = document.createElement('span');
      title.textContent = course.title;
      count.textContent = `${course.lessons} уроков`;
      item.append(title, count);
      fragment.append(item);
    }
    list.replaceChildren(fragment);
    status.textContent = `Серий в каталоге: ${body.courses.length}`;
  } catch (error) {
    status.textContent = error.name === 'AbortError'
      ? 'Сервер не ответил вовремя. Попробуйте обновить каталог.'
      : 'Не удалось получить каталог. Последний показанный список сохранён.';
  } finally { clearTimeout(timer); reload.disabled = false; }
}
reload.addEventListener('click', loadCatalog);
loadCatalog();

В frontend/src/style.css разместите собственные стили учебного интерфейса:

* { box-sizing: border-box; }
body { margin: 0; background: #f8f7fb; color: #26232d; font: 1rem/1.6 system-ui, sans-serif; }
main { max-width: 52rem; margin: 0 auto; padding: 3rem 1.25rem; }
h1 { font-size: clamp(2rem, 5vw, 3rem); line-height: 1.15; }
.eyebrow { color: #6d3abc; font-weight: 650; }
button { background: #6d3abc; color: white; border: 0; border-radius: .5rem; padding: .7rem 1rem; font: inherit; cursor: pointer; }
button:disabled { opacity: .6; cursor: wait; }
button:focus-visible { outline: 3px solid #258348; outline-offset: 3px; }
ul { padding: 0; list-style: none; }
li { display: flex; justify-content: space-between; gap: 1rem; padding: 1rem 0; border-bottom: 1px solid #ddd5eb; }
li span { white-space: nowrap; color: #485345; }
@media (max-width: 35rem) { li { flex-direction: column; gap: .2rem; } }

Клиент проверяет статус ответа и форму данных до изменения списка. Fragment позволяет заменить список после успешной подготовки всех записей; текст записывается через textContent. При отказе сохраняем ранее показанный список, сообщаем об ошибке и снова разрешаем кнопку. Таймер прерывает долгое ожидание, но не превращает сетевой сбой в успешно полученный каталог.

Это читающий запрос: он не меняет данные, поэтому повторная загрузка не требует механизма идемпотентной записи. Поля из JSON не становятся HTML-разметкой. Такое простое устройство облегчает изучение контейнерной сети и не скрывает её за большим frontend-фреймворком.

Получим настоящий lockfile до сборки

В архиве намеренно нет package-lock.json для frontend. Автор не выполнял установку зависимостей и не подделывал их разрешение. Манифест фиксирует сам Vite, но его транзитивное дерево должно быть получено настоящим npm. Читателю с совместимым Node.js 24 и npm нужно выполнить из корня проекта:

cd frontend
npm install --package-lock-only --ignore-scripts
cd ..

Это отдельная операция с registry и записью файла, которая не выполнялась при подготовке урока. После неё package-lock.json должен лежать рядом с package.json. Только теперь применимы следующие Dockerfile с COPY обоих манифестов и npm ci. Отсутствующий lockfile должен давать понятную ошибку, а не незаметную альтернативную установку.

Зафиксируйте реальные версии Node/npm и полученный lockfile. В дальнейшем изменять зависимости нужно намеренно, затем согласовывать оба файла. При возврате к полному snapshot без читательского lockfile эта предпосылка снова требуется. Учебный ZIP предоставляет исходники, а не утверждает существование уже собранного клиента.

Настроим proxy режима разработки

Создайте полный frontend/vite.config.js:

import { defineConfig } from 'vite';
export default defineConfig({
  server: {
    host: '0.0.0.0',
    port: 5173,
    strictPort: true,
    proxy: { '/api': { target: 'http://api:4100' } },
  },
});

Vite внутри контейнера слушает 0.0.0.0:5173, а strictPort запрещает незаметный переход на следующий номер. Если сервер выбрал бы другой порт, правило Docker продолжало бы направлять запрос к 5173. Proxy пересылает /api к имени api внутри Compose-сети и сохраняет полный путь /api/courses. Параметры объясняются в справочнике dev-сервера Vite.

Браузер обращается к относительному пути на том же origin, где открыт frontend. Он не разрешает имя api самостоятельно. Поэтому нам не требуется разрешать любые CORS-origin или отключать проверку allowedHosts. Для локального занятия достаточно loopback-публикации и стандартных ограничений Vite; открывать dev-сервер в интернет ради устранения ошибки proxy не следует.

Создайте полный frontend/.dockerignore:

node_modules
.git
.env
.env.*
*.log
npm-debug.log*
private
dist

Создайте frontend/Dockerfile.dev. Он устанавливает зависимости внутри Linux-image, а не копирует node_modules ноутбука:

FROM node:24-bookworm-slim
WORKDIR /app
RUN chown node:node /app
COPY --chown=node:node package.json package-lock.json ./
USER node
RUN npm ci
COPY --chown=node:node . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

Опишем полный dev-проект

Добавьте compose.dev.yaml. Это самостоятельный полный файл, который выбирают через --file; его не надо объединять со старым compose.yaml как override.

name: professorweb-container-lab-dev
services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile.dev
    environment:
      PORT: "4100"
      CATALOG_FILE: /app/data/catalog.json
    ports:
      - "127.0.0.1:4100:4100"
    volumes:
      - type: bind
        source: ./api/src
        target: /app/src
        read_only: true
      - catalog-data:/app/data
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4100/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 5s
      timeout: 3s
      retries: 3
      start_period: 3s
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile.dev
    ports:
      - "127.0.0.1:5173:5173"
    volumes:
      - type: bind
        source: ./frontend
        target: /app
      - frontend-deps:/app/node_modules
    depends_on:
      api:
        condition: service_healthy
volumes:
  catalog-data:
  frontend-deps:

Исходники frontend подключены bind mount к /app. Он перекрывает подготовленное содержимое этого пути. Отдельный named volume frontend-deps поверх /app/node_modules сохраняет Linux-зависимости, которые иначе скрыла бы папка хоста. Новый пустой volume получает подготовленное содержимое; уже существующий volume не обновляется автоматически при каждой сборке image.

Перед dev-запуском остановите API-проект прошлого урока через обычный docker compose down, сохраняя его данные. Затем читатель выполняет:

docker compose --file compose.dev.yaml up --detach --build
docker compose --file compose.dev.yaml ps

Ожидаемый экран находится на http://127.0.0.1:5173. После успешного API-запроса он показывает четыре серии. Dev-проект имеет другое имя и отдельный volume каталога, поэтому изменения из прежнего Compose-проекта не обязаны появиться здесь. Если watcher на конкретной файловой системе не получает события, сначала подтвердите видимость файла. Polling можно рассмотреть после диагностики, учитывая расход CPU; не включаем его как универсальное обязательное условие.

Если изменились манифесты, существующий frontend-deps может содержать старые модули. После согласования lockfile можно явно обновить именно это хранилище через сервис:

docker compose --file compose.dev.yaml run --rm frontend npm ci

Image после rebuild и подключённый непустой volume — разные источники содержимого. Такой отдельный шаг лучше объясняет обновление модулей, чем удаление всех volumes, вместе с каталогом API, ради ошибки одного клиента.

Соберём статику через отдельный stage

Режим выпуска использует готовые файлы dist. Vite dev и vite preview не назначаем production-сервером; preview предназначен для локального просмотра сборки согласно документации Vite. Создайте полный frontend/Dockerfile:

FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:stable-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Первый stage получает Node.js и зависимости для build. Второй начинает с nginx и копирует только результат из /app/dist, а также нашу конфигурацию. В runtime-image не требуется Node только ради раздачи этих статических файлов. Это конкретная граница материалов, а не обещание измеренного уменьшения размера. Модель описана в документации multi-stage Docker.

Создайте frontend/nginx.conf. Отсутствие завершающего слеша у proxy_pass сохраняет путь /api/courses для API; его замена на другой URI изменила бы маршрут:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;
    location /api/ {
        proxy_pass http://api:4100;
        proxy_set_header Host $host;
    }
    location / {
        try_files $uri $uri/ =404;
    }
}

Правило proxy_pass имеет значение для обработки URI; детали приведены в документации nginx. Наш клиент имеет одну страницу и не требует SPA-fallback для неизвестных адресов. Поэтому отсутствующий статический путь получает обычный отказ, а не любую копию index.html.

Выберем полный файл раздачи

Добавьте compose.prod.yaml, также самостоятельный полный файл:

name: professorweb-container-lab
services:
  api:
    build:
      context: ./api
    image: professorweb-container-lab-api:release-01
    environment:
      PORT: "4100"
      CATALOG_FILE: /app/data/catalog.json
    volumes:
      - catalog-data:/app/data
    healthcheck:
      test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4100/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 5s
      timeout: 3s
      retries: 3
      start_period: 3s
  frontend:
    build:
      context: ./frontend
    image: professorweb-container-lab-web:release-01
    ports:
      - "127.0.0.1:8080:80"
    depends_on:
      api:
        condition: service_healthy
volumes:
  catalog-data:
Свойство Dev-файл Файл раздачи готовой сборки
Frontend-процесс Vite с исходниками nginx со статическими файлами
Адрес браузера Loopback:5173 Loopback:8080
Mount исходников клиента Есть Нет
Node_modules runtime В dev-volume Не нужны nginx
Путь к API Dev-proxy nginx proxy

Сначала остановите dev-проект обычным down без удаления volumes. Затем читатель может выполнить up --detach --build с --file compose.prod.yaml. Ожидаемый экран теперь находится на http://127.0.0.1:8080, а API доступен frontend внутри сети, без собственного host-порта. Имя этого проекта совпадает с Compose API прошлого урока, поэтому его прежний catalog-data может использоваться снова.

Разберём отказ и границы готовности

Успешный healthcheck API подтверждает только /health. При повреждённом каталоге frontend должен показать ошибку чтения, сохранив прежний список. Если nginx не может разрешить api при старте или достичь его, посмотрите состояние сервиса и журнал. После пересоздания API его IP может измениться; минимальная конфигурация nginx не обещает динамическое обновление DNS во всех версиях. Для такого учебного случая может понадобиться restart сервиса frontend после готовности API.

Название prod обозначает способ раздачи собранных материалов. В этом примере не выполнены внешнее размещение, HTTPS, мониторинг, резервное копирование или настройка прав registry. Используется изменяемый nginx:stable-alpine без выдуманного digest. Перед настоящим выпуском эти условия нужно определить отдельно.

Все исходники и start/after представлены в архиве серии. STATE прямо отмечает not_run и обязательный читательский lockfile. Мы получили полную модель клиента и API, а следующим шагом разберём передачу двух images через registry, сохранив границу между образом программы и её постоянными данными.

Оглавление серии