Performance optimization: как ускорить веб-приложение в 10 раз

Практические техники оптимизации производительности от начального уровня до продвинутого

Производительность веб-приложения — это не техническая деталь, о которой думают после запуска. Это бизнес-метрика. Google давно доказал: каждые 100 мс задержки снижают конверсию на 1%. Amazon подсчитал, что ускорение страницы на 1 секунду увеличивает выручку на 1%. Для интернет-магазина с оборотом 100 млн ₽ в месяц это 1 млн ₽ только от технической оптимизации. На практике мы регулярно встречаем React-приложения, которые загружаются 8–12 секунд на мобильном устройстве. После аудита и оптимизации — 1,2 секунды. Это не магия: это системная работа с бандлом, сетью, рендерингом и кэшированием. В этой статье — конкретный план действий от базовых техник до продвинутых, с цифрами и кодом. Материал рассчитан на команды, которые уже запустили продукт и начали получать жалобы на скорость — либо хотят опередить конкурентов по Core Web Vitals до того, как Google начнёт явно понижать их в поиске. Порядок разделов соответствует соотношению усилия к результату: начинаем с самых быстрых побед. Измерение перед оптимизацией: что и как считать Нельзя улучшить то, что не измерено. Перед любыми правками фиксируем базовые метрики. Ключевые показатели Core Web Vitals в 2024 году: LCP (Largest Contentful Paint) — время отрисовки главного элемента. Цель: менее 2,5 с. Плохо: более 4 с. INP (Interaction to Next Paint) — отклик на взаимодействие. Цель: менее 200 мс. Плохо: более 500 мс. CLS (Cumulative Layout Shift) — сдвиги макета. Цель: менее 0,1. Плохо: более 0,25. TTFB (Time to First Byte) — скорость ответа сервера. Цель: менее 800 мс. FCP (First Contentful Paint) — первый контент на экране. Цель: менее 1,8 с. Инструменты для измерения: Lighthouse в DevTools (синтетика), PageSpeed Insights (реальные данные пользователей через CrUX), WebPageTest.org (детальный waterfall), Sentry Performance или Datadog RUM (production-мониторинг). Всегда замеряйте на реальном железе: мобильник Moto G4 на 3G-соединении — стандартный тест-кейс Google. Фиксируйте результаты до и после каждого изменения. Оптимизация без измерений — это гадание. Оптимизация JavaScript-бандла: самый быстрый выигрыш В большинстве React/Vue/Angular приложений главная проблема — размер JS-бандла. Браузер должен скачать, распарсить и исполнить весь JavaScript до того, как пользователь увидит интерактивную страницу. Типичный нездоровый бандл — 3–5 МБ gzip. Здоровый — 200–400 КБ. Code splitting и lazy loading Базовая техника: разбиваем монолитный бандл на чанки и грузим их по мере необходимости. В React это делается через React.lazy и Suspense : // До: всё грузится сразу import AdminDashboard from './AdminDashboard'; import Reports from './Reports'; import Settings from './Settings'; // После: ленивая загрузка по маршрутам const AdminDashboard = React.lazy(() => import('./AdminDashboard')); const Reports = React.lazy(() => import('./Reports')); const Settings = React.lazy(() => import('./Settings')); function App() { return ( Suspense fallback={ PageSkeleton / } Routes Route path="/admin" element={ AdminDashboard / } / Route path="/reports" element={ Reports / } / Route path="/settings" element={ Settings / } / /Routes /Suspense ); } Результат: начальный бандл уменьшается в 3–5 раз. Пользователь грузит только то, что видит прямо сейчас. Анализ зависимостей Используйте webpack-bundle-analyzer или vite-bundle-visualizer для визуализации бандла. Часто обнаруживаем: Библиотека moment.js весит 67 КБ gzip — заменяется на date-fns (2–15 КБ в зависимости от используемых функций) lodash полностью (72 КБ) вместо lodash-es с tree-shaking Дублирующиеся версии одной библиотеки из-за конфликтов зависимостей Иконочные библиотеки, где импортируется весь пак вместо отдельных иконок // Плохо: грузит все 1200+ иконок (~500 КБ) import { Icon } from '@ant-design/icons'; // Хорошо: только нужные иконки (~2 КБ) import { SearchOutlined, UserOutlined } from '@ant-design/icons'; Кейс из практики: интернет-магазин на React, начальный бандл 4,2 МБ. После анализа нашли moment.js, полный lodash, три разных версии React-компонентов UI. После замен и настройки tree-shaking — 890 КБ. LCP упал с 9 до 3,2 секунды. Оптимизация изображений: 60% экономии за один день Изображения — самая тяжёлая часть большинства страниц. По данным HTTP Archive, средняя страница передаёт 1 МБ изображений. С правильной оптимизацией это 200–300 КБ без видимой потери качества. Современные форматы: WebP и AVIF WebP даёт 25–35% экономии по сравнению с JPEG при том же визуальном качестве. AVIF — ещё 20% поверх WebP, но с меньшей поддержкой браузеров. Правильная стратегия: picture source srcset="image.avif" type="image/avif" / source srcset="image.webp" type="image/webp" / img src="image.jpg" alt="Описание" width="800" height="600" / /picture Lazy loading и приоритизация Атрибут loading="lazy" — нативный lazy loading без JavaScript. Главный hero-образ, наоборот, должен иметь fetchpriority="high" и loading="eager" — это критично для LCP: !-- Главное изображение: грузим приоритетно -- img src="hero.webp" fetchpriority="high" loading="eager" width="1200" height="600" alt="Главный баннер" / !-- Изображения ниже fold: ленивая загрузка -- img src="product.webp" loading="lazy" width="400" height="300" alt="Товар" / Адаптивные изображения через srcset Мобильный пользователь не должен загружать 2000px-изображение для экрана 390px: img srcset=" image-400.webp 400w, image-800.webp 800w, image-1200.webp 1200w, image-2000.webp 2000w " sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 800px" src="image-800.webp" alt="Описание" / Автоматизируйте генерацию форматов: sharp в Node.js, imagemin , или плагины для Vite/Webpack. В CI/CD — Squoosh CLI . На CDN — Cloudflare Images или imgix трансформируют «на лету» по URL-параметрам. Кэширование и CDN: скорость без изменения кода Правильно настроенное кэширование может сократить время загрузки для возвращающихся пользователей до 50 мс — фактически мгновенно. Для новых пользователей CDN сокращает TTFB с 500–800 мс до 20–50 мс за счёт географической близости серверов. Cache-Control стратегия Разные типы ресурсов требуют разных стратегий кэширования: # Статические ассеты с хешем в имени (JS, CSS, шрифты) # Кэшируем навсегда — при изменении меняется имя файла Cache-Control: public, max-age=31536000, immutable # HTML страницы — не кэшируем или краткосрочно Cache-Control: no-cache # API-ответы — зависит от данных Cache-Control: private, max-age=300 # 5 минут для пользовательских данных Cache-Control: public, max-age=3600 # 1 час для публичных данных Service Worker и офлайн-стратегии Для PWA и высоконагруженных приложений Service Worker с Workbox даёт полный контроль над кэшированием: import { registerRoute } from 'workbox-routing'; import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'; // Статические ассеты: сначала кэш registerRoute( ({ request }) => request.destination === 'image', new CacheFirst({ cacheName: 'images-cache', plugins: [new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 })] }) ); // API: сначала сеть, при ошибке — кэш registerRoute( ({ url }) => url.pathname.startsWith('/api/'), new NetworkFirst({ cacheName: 'api-cache', networkTimeoutSeconds: 3 }) ); // Страницы: Stale While Revalidate registerRoute( ({ request }) => request.mode === 'navigate', new StaleWhileRevalidate({ cacheName: 'pages-cache' }) ); Оптимизация React-рендеринга: устраняем лишние перерисовки Даже после загрузки приложение может тормозить из-за неэффективного рендеринга. Браузерный DevTools Profiler покажет компоненты, которые перерисовываются слишком часто. Мемоизация компонентов и вычислений // React.memo: не перерисовываем если пропсы не изменились const ProductCard = React.memo(({ product, onAddToCart }) => { return ( div className="card" h3 {product.name} /h3 button onClick={() = onAddToCart(product.id)} В корзину /button /div ); }); // useMemo: кэшируем дорогие вычисления const filteredProducts = useMemo(() => { return products .filter(p => p.category === selectedCategory) .sort((a, b) => b.rating - a.rating); }, [products, selectedCategory]); // useCallback: стабильные ссылки на функции const handleAddToCart = useCallback((productId) => { dispatch({ type: 'ADD_TO_CART', payload: productId }); }, [dispatch]); Виртуализация длинных списков Рендер 10 000 строк таблицы в DOM убивает производительность. Виртуализация рендерит только видимые элементы: import { FixedSizeList } from 'react-window'; function ProductList({ products }) { const Row = ({ index, style }) => ( div style={style} ProductCard product={products[index]} / /div ); return ( FixedSizeList height={600} itemCount={products.length} itemSize={120} width="100%" {Row} /FixedSizeList ); } Результат: таблица с 50 000 строками рендерится за 16 мс вместо 8 секунд. Библиотеки: react-window , react-virtual (TanStack Virtual), @tanstack/react-table со встроенной виртуализацией. State management и лишние ре-рендеры Глобальный стейт-менеджер (Redux, Zustand, Jotai) должен быть гранулярным. Классическая ошибка: весь стейт в одном большом объекте, и любое изменение перерисовывает половину приложения. // Плохо: один большой атом const appState = atom({ user: {...}, products: [...], cart: [...], ui: {...} }); // Хорошо: гранулярные атомы (Jotai) const userAtom = atom(null); const productsAtom = atom([]); const cartAtom = atom([]); const uiAtom = atom({ sidebarOpen: false, theme: 'dark' }); Серверная оптимизация: TTFB и SSR Клиентская оптимизация даёт 50–70% улучшения. Остальное — на стороне сервера. Server-Side Rendering и Static Generation Для контентных страниц (лендинги, блог, каталог) SSR или SSG даёт самый быстрый FCP и LCP — браузер получает готовый HTML без ожидания JS: Next.js SSG : статическая генерация при билде. TTFB 20–50 мс с CDN. Идеально для лендингов и блогов. Next.js SSR : рендер на сервере при каждом запросе. TTFB 100–300 мс. Для персонализированного контента. ISR (Incremental Static Regeneration) : статика с фоновым обновлением. Лучший баланс скорости и актуальности. Astro : для контентных сайтов с минимальным JS — отправляет 0 КБ JavaScript по умолчанию. Оптимизация API и базы данных Медленный API = медленное приложение независимо от клиентских оптимизаций. Чек-лист серверной части: Индексы в БД на все поля в WHERE, JOIN, ORDER BY — это самая частая причина медленных запросов N+1 проблема: один запрос на список + N запросов на детали каждого элемента. Решение: JOIN или DataLoader Redis/Memcached для кэширования дорогих запросов Pagination вместо возврата всех записей GraphQL с DataLoader исключает overfetching и N+1 gzip/brotli компрессия ответов -- Плохо: запрос без индекса на большой таблице SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC; -- Хорошо: индекс ускоряет в 100-1000 раз CREATE INDEX CONCURRENTLY idx_orders_user_created ON orders(user_id, created_at DESC); -- И ограничиваем выборку SELECT id, total, status, created_at FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20; Critical Rendering Path: оптимизация первого экрана Браузер строит страницу по цепочке: HTML → DOM → CSS → CSSOM → Layout → Paint → Composite. Каждый блокирующий ресурс останавливает этот процесс. Устранение render-blocking ресурсов !-- Плохо: CSS блокирует рендер -- link rel="stylesheet" href="styles.css" link rel="stylesheet" href="fonts.css" !-- Хорошо: критический CSS inline, некритический асинхронно -- style /* Критический CSS для первого экрана — вставляем inline */ body { margin: 0; font-family: system-ui; } .header { ... } .hero { ... } /style link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'" noscript link rel="stylesheet" href="styles.css" /noscript !-- JS: defer или async, никогда синхронно в head -- script src="app.js" defer /script script src="analytics.js" async /script Preconnect, prefetch и preload !-- Устанавливаем соединение заранее с CDN и API -- link rel="preconnect" href="https://cdn.example.com" link rel="preconnect" href="https://api.example.com" crossorigin link rel="dns-prefetch" href="http