171 lines
6.9 KiB
Markdown
171 lines
6.9 KiB
Markdown
# 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/`
|