# Design Spec: Lighthouse Optimization (~100) **Date:** 2026-05-15 **Status:** Draft — awaiting user review ## Problem Lighthouse score далеко от 100 из-за: - Изображения загружаются в полном размере (7–15 МБ файлы для карточек 200px) - Нет адаптивных изображений (srcset/sizes) - Нет lazy loading на большинстве изображений - Нет Cache-Control для `/uploads/` - JS бандл 2.3 МБ без code splitting - Минимальные meta-теги в HTML ## Architecture ### 1. Серверный ресайз изображений #### Интеграция с Fastify Регистрируем маршрут `GET /uploads-resized/*` ПЕРЕД `fastify-static`: - Если запрошен `.avif` или `.webp` с `?w=` → sharp конвертирует - Если запрошен оригинал (без `?w=` или с неподдерживаемым форматом) → редирект на `/uploads/...` - `fastify-static` остаётся для прямой отдачи `/uploads/` (обратная совместимость) **URL формат:** `/uploads-resized/:relativePath/:filename.:format?w=WIDTH` - `:relativePath` — опциональная подпапка (например `reviews/`) - `:filename` — UUID оригинала - `:format` — `avif` или `webp` - `?w=` — целевая ширина (320, 640, 1024, 1600) **Примеры:** - `/uploads-resized/abc123.avif?w=640` — AVIF, 640px - `/uploads-resized/reviews/def456.webp?w=320` — отзыв, WebP, 320px **Логика:** 1. Парсим URL → находим оригинал в `/uploads/:relativePath/:filename.*` 2. Проверяем кеш: `/uploads/.cache/:relativePath/:filename_w{WIDTH}.{format}` 3. Если кеш есть — отдаём с `Cache-Control: public, max-age=31536000, immutable` 4. Если нет — sharp конвертирует, сохраняет в кеш, отдаёт 5. Без `?w=` — отдаёт оригинал с `Cache-Control: public, max-age=86400` **Зависимость:** `sharp` (npm package) #### Разделение папок | Источник | Путь сохранения | |---|---| | Админка (товары, галерея) | `/uploads/` (без изменений) | | Отзывы | `/uploads/reviews/` | **Изменения:** - `server/src/routes/api/public-reviews.js` — `POST /api/reviews/upload-image` → `saveImageBufferToUploads` в `/uploads/reviews/` - `server/src/lib/upload-images.js` — `saveImageBufferToUploads` принимает опциональный `subdir` параметр ### 2. Клиент: `` компонент **Расположение:** `client/src/shared/ui/OptimizedImage.tsx` **Props:** ```ts type OptimizedImageProps = { src: string // оригинальный URL (/uploads/...) alt: string widths?: number[] // по умолчанию [320, 640, 1024, 1600] sizes?: string // HTML sizes attribute aspectRatio?: string // CSS aspect-ratio sx?: SxProps // MUI sx priority?: boolean // true = loading="eager", false = "lazy" isReview?: boolean // true = путь /uploads/reviews/ } ``` **Генерация srcset:** ``` /uploads-resized/abc.avif?w=320 320w, /uploads-resized/abc.avif?w=640 640w, /uploads-resized/abc.avif?w=1024 1024w, /uploads-resized/abc.avif?w=1600 1600w ``` **Fallback для Safari без AVIF:** `` с `` + `` **Места замены:** - `ProductCard` — карточки товаров (mediaHeight определяет нужный size) - `ProductPage` — страница товара + отзывы - `CatalogSlider` — слайдер каталога - `GalleryGrid` — галерея админки - `AdminProductsPage` / `AdminPage` — превью в админке - `GallerySliderSection` — превью слайдов ### 3. Cache-Control для статики **Server (`server/src/index.js`):** - `/uploads/` (оригиналы) → `Cache-Control: public, max-age=86400` - `/uploads-resized/` → `Cache-Control: public, max-age=31536000, immutable` - `/uploads/.cache/` → `Cache-Control: public, max-age=31536000, immutable` ### 4. Code splitting **Vite (`client/vite.config.ts`):** ``` manualChunks: - vendor-react: react, react-dom, react-router-dom - vendor-mui: @mui/*, @emotion/* - vendor-swiper: swiper - vendor-map: maplibre-gl, react-map-gl - vendor-tiptap: @tiptap/* - vendor-effector: effector, effector-react - vendor-query: @tanstack/react-query ``` **Route-level lazy loading:** - `/admin/*` → `React.lazy()` — админка не нужна обычным пользователям - `/me/*` → `React.lazy()` — личный кабинет ### 5. HTML meta оптимизации **`client/index.html`:** - `` - `` для внешних ресурсов - OG tags (title, description, image) - Canonical URL ## Data Flow ``` [Browser] → GET /uploads-resized/abc.avif?w=640 ↓ [Fastify route /uploads-resized/*] → файл есть в .cache? ↓ NO → читаем оригинал /uploads/abc.{png|jpg|webp} → sharp конвертирует в AVIF, width=640 → сохраняем в /uploads/.cache/abc_w640.avif ↓ YES → читаем из .cache ↓ → отдаём с Cache-Control: public, max-age=31536000, immutable ``` ``` [ProductCard] → ↓ [Browser] → выбирает нужный размер из srcset по viewport ↓ [Server] → отдаёт AVIF из кеша или генерирует ``` ## Error Handling - Оригинал не найден → 404 - Sharp недоступен (не установлен) → fallback на оригинал + warn в лог - Конвертация失败 → fallback на оригинал - Невалидный `?w=` → игнорировать, использовать оригинал ## Testing - Server: тест ресайза — файл создаётся, формат правильный, размер соответствует - Server: тест кеша — повторный запрос не вызывает повторную конвертацию - Client: OptimizedImage рендерится с правильным srcset - Client: fallback для non-AVIF браузеров ## Migration Notes - Старые файлы в `/uploads/` остаются без изменений - Новые отзывы → `/uploads/reviews/` - Конвертация происходит on-demand при первом запросе - `.gitignore`: `/uploads/.cache/`