Клиент 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, сохранив границу между образом программы и её постоянными данными.