docs: add lighthouse optimization design spec
This commit is contained in:
@@ -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/`
|
||||||
Reference in New Issue
Block a user