docs: add lighthouse optimization design spec

This commit is contained in:
Kirill
2026-05-15 13:12:42 +05:00
parent 1b7ec703ee
commit 78fc1d4d96
@@ -0,0 +1,170 @@
# 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. Клиент: `<OptimizedImage>` компонент
**Расположение:** `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<Theme> // 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:** `<picture>` с `<source type="image/avif">` + `<img type="image/webp">`
**Места замены:**
- `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`:**
- `<meta name="description" content="...">`
- `<link rel="preconnect" href="...">` для внешних ресурсов
- 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] → <OptimizedImage src="/uploads/abc.png" widths={[320,640,1024]} />
[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/`