Files
shop-server/docs/superpowers/specs/2026-05-15-lighthouse-optimization-design.md
T
2026-05-15 13:12:42 +05:00

171 lines
6.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/`