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

6.9 KiB
Raw Blame History

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 оригинала
  • :formatavif или 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.jsPOST /api/reviews/upload-imagesaveImageBufferToUploads в /uploads/reviews/
  • server/src/lib/upload-images.jssaveImageBufferToUploads принимает опциональный subdir параметр

2. Клиент: <OptimizedImage> компонент

Расположение: client/src/shared/ui/OptimizedImage.tsx

Props:

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/