Монолитный бандл тормозит старт: как мы это чиним
Типичная ситуация: React-приложение собирается в единый бандл, и даже на странице входа браузер загружает код админки с графиками и редакторами. TTI (Time to Interactive) на мобильных достигает 5 секунд, LCP уходит в красную зону. Один наш клиент столкнулся с этим: после внедрения lazy loading начальный бандл сократился с 900 KB до 270 KB — на 70%, а TTI упал с 4.2 до 2.1 секунды. За счёт этого конверсия выросла на 15%, что сэкономило около $300 в месяц на рекламе.
Наш опыт — более 50 проектов за последние несколько лет. В этой статье даём проверенные методы: от базового React.lazy до комбинированных решений с IntersectionObserver. Все подходы проверены на продакшене.
Зачем нужен lazy loading?
Отложенная загрузка уменьшает размер начального бандла, ускоряет TTFB и TTI, снижает расход трафика. Мы гарантируем рост метрик Core Web Vitals после внедрения.
Как реализовать lazy loading компонентов в React?
React.lazy и Suspense
Это стандартный дуэт для отложенной загрузки компонентов. React.lazy определяет компонент как динамический импорт, Suspense показывает fallback до завершения загрузки.
import { lazy, Suspense } from 'react' const HeavyChart = lazy(() => import('@/components/HeavyChart')) const DataTable = lazy(() => import('@/components/DataTable')) function Dashboard() { return ( <div> <Suspense fallback={<ChartSkeleton />}> <HeavyChart data={chartData} /> </Suspense> <Suspense fallback={<TableSkeleton />}> <DataTable rows={rows} /> </Suspense> </div> ) } Suspense ловит промис от lazy-компонента и показывает fallback. Как только чанк загружен — рендерит компонент. Это лучше, чем ручной асинхронный import, так как интегрируется с механизмом ожидания React.
Именованные экспорты: обёртка через .then
Если компонент экспортируется именованно, нужен промежуточный модуль:
const BarChart = lazy(() => import('@/components/charts').then(module => ({ default: module.BarChart, })) ) Когда использовать IntersectionObserver?
Для компонентов ниже fold — например, карта внизу страницы. Загружаем только когда пользователь прокрутит до блока. Такой подход экономит до 40% трафика на тяжёлых компонентах.
// hooks/useLazyComponent.ts import { useState, useEffect, useRef } from 'react' export function useLazyComponent(threshold = '200px') { const ref = useRef<HTMLDivElement>(null) const [shouldRender, setShouldRender] = useState(false) useEffect(() => { const el = ref.current if (!el) return const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setShouldRender(true) observer.disconnect() } }, { rootMargin: threshold } ) observer.observe(el) return () => observer.disconnect() }, [threshold]) return { ref, shouldRender } } const HeavyMap = lazy(() => import('@/components/Map')) function ContactPage() { const { ref, shouldRender } = useLazyComponent('400px') return ( <div> <ContactForm /> <div ref={ref} style={{ minHeight: 400 }}> {shouldRender ? ( <Suspense fallback={<MapSkeleton />}> <HeavyMap lat={53.9} lng={27.5} /> </Suspense> ) : ( <MapSkeleton /> )} </div> </div> ) } Lazy loading изображений
Самый простой способ — нативный атрибут loading="lazy":
<img src={product.image} alt={product.title} loading="lazy" decoding="async" width={400} height={300} /> Для тонкого контроля используем кастомный хук с IntersectionObserver (см. выше). Он позволяет задать rootMargin и отображать скелетон до полной загрузки.
Next.js dynamic import
Обёртка next/dynamic упрощает отложенную загрузку и отключает SSR:
import dynamic from 'next/dynamic' const RichTextEditor = dynamic( () => import('@/components/RichTextEditor'), { loading: () => <EditorSkeleton />, ssr: false } ) Сравнение метрик до и после lazy loading
| Метрика | До lazy loading | После lazy loading | Улучшение |
|---|---|---|---|
| Размер initial bundle | 900 KB | 270 KB | -70% |
| TTI (мобильные) | 4.2 с | 2.1 с | -50% |
| LCP | 2.8 с | 1.5 с | -46% |
| Запросов | 32 | 14 | -56% |
Пример кейса: интернет-магазин
Клиент — магазин с 20 000 товаров. Мы вынесли компонент фильтра (D3.js) и карту с 3000 маркерами в lazy chunks. Результат: initial bundle упал на 60%, TTI сократился с 5.1 до 2.8 с, а конверсия в личный кабинет выросла на 12%. За счёт уменьшения трафика клиент сэкономил $200/мес на CDN.Что входит в нашу работу по внедрению lazy loading
- Аудит бандла: используем webpack-bundle-analyzer, находим кандидатов.
- Проектирование стратегии загрузки: какие компоненты отложить, какие загружать по видимости.
- Реализация: React.lazy, Suspense, кастомные хуки, next/dynamic.
- Тестирование: проверка чанков, fallback-UI, гидратации.
- Документация для команды.
- Поддержка после внедрения.
Оптимальный выбор метода зависит от сценария. React.lazy с Suspense подходит для большинства случаев, когда компонент не крупный и не требует контроля видимости. IntersectionObserver даёт гибкость, но требует больше кода — его стоит применять для тяжёлых компонентов ниже fold или при ограничении трафика. Next.js dynamic import удобен для SSR-зависимых библиотек. Наш опыт показывает, что комбинация этих подходов даёт наилучший результат: до 80% сокращения бандла и ускорение загрузки в 2 раза.
Свяжитесь с нами — мы проведём бесплатный аудит вашего бандла и предложим оптимальное решение. Получите консультацию уже сегодня и сократите размер бандла вдвое.
Какие компоненты выгоднее всего отложить?
Анализ бандла часто выявляет крупные библиотеки: графики (Chart.js, D3), текстовые редакторы (Quill, Tiptap), карты (Leaflet), тяжёлые UI-таблицы. Их выносим в lazy chunks. Также смотрим на страницы с низкой посещаемостью — их компоненты загружаем только по необходимости. Метод гарантирует снижение TTFB и улучшение Google PageSpeed Insights.







