Оптимизация фронтенда: как мы снизили время загрузки с 8 до 1.9 секунды

Начальный аудит

Первым делом мы прогнали сайт через Lighthouse и WebPageTest. Результаты были печальные:

МетрикаЗначениеНорма
Lighthouse Performance28/10090+
LCP (Largest Contentful Paint)7.8 с< 2.5 с
FID (First Input Delay)380 мс< 100 мс
CLS (Cumulative Layout Shift)0.42< 0.1
Total Bundle Size3.2 МБ (gzip)
Количество запросов127

Главная страница загружала весь JavaScript-бандл приложения, включая страницы бронирования, личного кабинета и админки. Изображения были в PNG без оптимизации, шрифты грузились блокирующим образом.

Анализ бандла и code splitting

Мы начали с анализа того, что вообще попадает в бандл. Инструмент webpack-bundle-analyzer показал картину:

# Установка и запуск анализатора
npm install --save-dev webpack-bundle-analyzer

# webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({ analyzerMode: 'static' })
  ]
};

Результат: moment.js с полными локалями занимал 540 КБ, библиотека карт mapbox-gl — 780 КБ, а lodash целиком — 120 КБ. Всё это грузилось на каждой странице.

Мы разбили приложение на чанки по роутам:

// До: всё в одном бандле
import BookingPage from './pages/BookingPage';
import AccountPage from './pages/AccountPage';
import AdminPanel from './pages/AdminPanel';

// После: ленивая загрузка по роутам
const BookingPage = React.lazy(() => import('./pages/BookingPage'));
const AccountPage = React.lazy(() => import('./pages/AccountPage'));
const AdminPanel = React.lazy(() => import('./pages/AdminPanel'));

// Роутинг с Suspense
function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/booking/:id" element={<BookingPage />} />
        <Route path="/account/*" element={<AccountPage />} />
        <Route path="/admin/*" element={<AdminPanel />} />
      </Routes>
    </Suspense>
  );
}

Moment.js заменили на date-fns с tree shaking, lodash — на точечные импорты:

// Было: import _ from 'lodash' (120 КБ)
// Стало:
import debounce from 'lodash/debounce';  // 1.2 КБ
import groupBy from 'lodash/groupBy';    // 2.1 КБ

Tree shaking: убираем мёртвый код

Проект использовал barrel-файлы (index.ts), которые ломали tree shaking. Webpack не мог понять, что из модуля реально используется:

// components/index.ts — barrel file, убивает tree shaking
export { Button } from './Button';
export { Modal } from './Modal';
export { DatePicker } from './DatePicker';
export { MapView } from './MapView';     // 780 КБ mapbox-gl

// Импорт одного компонента тянет весь barrel
import { Button } from '@/components';   // подтягивает MapView!

Решение: прямые импорты и настройка sideEffects: false в package.json:

// package.json
{
  "sideEffects": ["*.css", "*.scss"]
}

// Прямые импорты
import { Button } from '@/components/Button';

Результат: бандл главной страницы упал с 3.2 МБ до 380 КБ (gzip).

Оптимизация изображений

На главной странице было 40+ изображений отелей и направлений. Все в PNG, без адаптации под размер экрана. Мы внедрили полный пайплайн:

<!-- Адаптивные изображения с современными форматами -->
<picture>
  <source
    type="image/avif"
    srcset="/images/hotels/paris-400.avif 400w,
            /images/hotels/paris-800.avif 800w,
            /images/hotels/paris-1200.avif 1200w"
    sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 400px"
  >
  <source
    type="image/webp"
    srcset="/images/hotels/paris-400.webp 400w,
            /images/hotels/paris-800.webp 800w,
            /images/hotels/paris-1200.webp 1200w"
    sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 400px"
  >
  <img
    src="/images/hotels/paris-800.jpg"
    alt="Отель в Париже"
    loading="lazy"
    decoding="async"
    width="800" height="600"
  >
</picture>

Для автоматической конвертации мы написали скрипт на sharp:

// scripts/optimize-images.js
const sharp = require('sharp');
const glob = require('glob');

const SIZES = [400, 800, 1200];
const FORMATS = ['avif', 'webp', 'jpg'];

async function processImage(inputPath) {
  for (const size of SIZES) {
    for (const format of FORMATS) {
      const outputPath = inputPath
        .replace('/originals/', '/optimized/')
        .replace(/\.\w+$/, `-${size}.${format}`);

      await sharp(inputPath)
        .resize(size, null, { withoutEnlargement: true })
        .toFormat(format, {
          quality: format === 'avif' ? 50 : format === 'webp' ? 75 : 80
        })
        .toFile(outputPath);
    }
  }
}

glob('src/assets/originals/**/*.{png,jpg}', (err, files) => {
  files.forEach(processImage);
});

Средний вес изображения упал с 380 КБ (PNG) до 28 КБ (AVIF). Атрибуты width и height на всех <img> убрали CLS, вызванный перекомпоновкой при загрузке картинок.

Оптимизация шрифтов

Проект использовал Google Fonts с 5 начертаниями кириллицы — 320 КБ дополнительного трафика и блокировка рендеринга.

/* До: блокирующая загрузка */
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;600;700');

/* После: оптимизированная загрузка */
@font-face {
  font-family: 'Roboto';
  font-weight: 400;
  font-display: swap;          /* Текст виден сразу, шрифт подменяется */
  src: url('/fonts/roboto-400-cyrillic.woff2') format('woff2');
  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

@font-face {
  font-family: 'Roboto';
  font-weight: 700;
  font-display: swap;
  src: url('/fonts/roboto-700-cyrillic.woff2') format('woff2');
  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

Мы сократили начертания до двух (400 и 700), подставили subset только для кириллицы и латиницы, и добавили font-display: swap. С 5 файлов по 64 КБ до 2 файлов по 18 КБ.

Critical CSS

Весь CSS загружался одним блокирующим файлом в 180 КБ. Мы извлекли критический CSS — стили, необходимые для отрисовки видимой части страницы (above the fold):

<!-- Критический CSS инлайнится в head -->
<style>
  /* Только стили для above-the-fold контента */
  .header { ... }
  .hero { ... }
  .search-form { ... }
  .popular-destinations { ... }
</style>

<!-- Остальной CSS загружается асинхронно -->
<link rel="preload" href="/css/main.css" as="style"
      onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/main.css"></noscript>

Для автоматической генерации критического CSS использовали critters — webpack-плагин от Google:

// webpack.config.js
const Critters = require('critters-webpack-plugin');

module.exports = {
  plugins: [
    new Critters({
      preload: 'swap',
      inlineFonts: false,
      pruneSource: true,
    })
  ]
};

Стратегия prefetch и preload

Мы добавили подсказки браузеру о ресурсах, которые понадобятся:

<!-- Preconnect к внешним API -->
<link rel="preconnect" href="https://api.travelbook.ru">
<link rel="preconnect" href="https://cdn.travelbook.ru" crossorigin>

<!-- Preload критичных ресурсов -->
<link rel="preload" href="/fonts/roboto-400-cyrillic.woff2"
      as="font" type="font/woff2" crossorigin>

<!-- Prefetch следующей вероятной страницы -->
<link rel="prefetch" href="/booking/search-results.js">

В React-приложении мы добавили программный prefetch при наведении на ссылку:

function PrefetchLink({ to, children, ...props }) {
  const prefetchTimer = useRef(null);

  const handleMouseEnter = () => {
    prefetchTimer.current = setTimeout(() => {
      // Предзагрузка чанка роута
      import(`./pages/${to}`);
    }, 100);  // задержка, чтобы не грузить при случайном наведении
  };

  const handleMouseLeave = () => {
    clearTimeout(prefetchTimer.current);
  };

  return (
    <Link to={to} onMouseEnter={handleMouseEnter}
          onMouseLeave={handleMouseLeave} {...props}>
      {children}
    </Link>
  );
}

Service Worker для кеширования

Для повторных визитов мы настроили Service Worker через Workbox:

// sw.js (генерируется workbox-webpack-plugin)
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

// Прекеш статики (JS, CSS)
precacheAndRoute(self.__WB_MANIFEST);

// Изображения: cache-first, до 200 штук, 30 дней
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 24 * 3600 })
    ],
  })
);

// API каталога: stale-while-revalidate
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/catalog'),
  new StaleWhileRevalidate({
    cacheName: 'api-catalog',
    plugins: [
      new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 3600 })
    ],
  })
);

При повторном визите страница загружается из кеша, а API-данные обновляются в фоне.

CDN

Статика раздавалась с того же сервера, что и API. Мы вынесли всё на CDN с точками присутствия в Москве, Санкт-Петербурге и Екатеринбурге:

# nginx.conf — заголовки кеширования для статики
location /static/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    add_header Vary "Accept-Encoding";
}

location /api/ {
    add_header Cache-Control "no-store";
    proxy_pass http://backend;
}

Хеширование файлов в имени (main.a3b2c1.js) позволяет ставить immutable — браузер никогда не перепроверяет файл.

Результаты: Core Web Vitals

После всех оптимизаций мы провели повторный аудит:

МетрикаДоПослеЦель
Lighthouse Performance289490+
LCP7.8 с1.9 с< 2.5 с
FID380 мс45 мс< 100 мс
CLS0.420.04< 0.1
Bundle (gzip)3.2 МБ185 КБ
Запросов12734
Время загрузки (3G)24 с4.2 с
Повторная загрузка (SW)0.8 с

Влияние на бизнес

Через месяц после оптимизации «ТревелБук» зафиксировал рост конверсии на 23% и снижение bounce rate с 58% до 31%. Позиции в поисковой выдаче улучшились — Google активно учитывает Core Web Vitals как фактор ранжирования.

Оптимизация фронтенда — не разовая акция. Мы настроили мониторинг через web-vitals библиотеку, которая отправляет реальные метрики в аналитику. Каждый PR проходит проверку bundle size — если бандл растёт больше чем на 5 КБ, CI требует обоснования. Если вашему проекту нужна оптимизация производительности — свяжитесь с нами.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#code#core#critical#devops#prefetch#preload#service#shaking
Комментарии 0

Оставить комментарий

загрузка...