Реализация Lazy Loading компонентов на сайте
Lazy loading — отложенная загрузка компонентов, изображений или модулей до момента, когда они реально нужны пользователю. Уменьшает размер начального бандла, ускоряет Time to Interactive и снижает расход трафика на мобильных устройствах.
Два уровня lazy loading
Уровень модулей — JavaScript-код компонента загружается только при первом рендере. Реализуется через динамический import().
Уровень ресурсов — изображения, iframe, видео загружаются только когда элемент входит в область видимости. Реализуется через атрибут loading="lazy" или IntersectionObserver.
React.lazy и Suspense
// Без lazy loading — весь код попадает в main bundle
import HeavyChart from '@/components/HeavyChart'
import DataTable from '@/components/DataTable'
// С lazy loading — отдельные чанки, загружаются по требованию
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. Как только чанк загружен — рендерит компонент.
Именованные экспорты с lazy
React.lazy работает только с default export. Для именованных нужна обёртка:
// Если компонент экспортируется как named export
const BarChart = lazy(() =>
import('@/components/charts').then(module => ({
default: module.BarChart,
}))
)
Условная загрузка: только при видимости
Загружать тяжёлый компонент сразу при монтировании страницы — не всегда нужно. Если компонент находится внизу страницы, стоит отложить загрузку до прокрутки к нему:
// 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 изображений
// Нативный lazy loading — поддерживается всеми современными браузерами
function ProductCard({ product }: { product: Product }) {
return (
<div>
<img
src={product.image}
alt={product.title}
loading="lazy"
decoding="async"
width={400}
height={300}
/>
</div>
)
}
Для более тонкого контроля — кастомный хук с IntersectionObserver:
function useLazyImage(src: string) {
const imgRef = useRef<HTMLImageElement>(null)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
const img = imgRef.current
if (!img) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
img.src = src
img.onload = () => setLoaded(true)
observer.disconnect()
}
},
{ rootMargin: '100px' }
)
observer.observe(img)
return () => observer.disconnect()
}, [src])
return { imgRef, loaded }
}
Next.js: dynamic import
import dynamic from 'next/dynamic'
// С кастомным loading state
const RichTextEditor = dynamic(
() => import('@/components/RichTextEditor'),
{
loading: () => <EditorSkeleton />,
ssr: false, // не рендерить на сервере — актуально для window-dependent компонентов
}
)
// Только при выполнении условия
const AdminPanel = dynamic(() => import('@/components/AdminPanel'), { ssr: false })
function Page({ isAdmin }: { isAdmin: boolean }) {
return isAdmin ? <AdminPanel /> : <UserView />
}
Vite: анализ бандла
Чтобы понять, что стоит выносить в lazy chunks:
# Установить плагин
npm install --save-dev rollup-plugin-visualizer
# vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default {
plugins: [
visualizer({ open: true, gzipSize: true, filename: 'dist/stats.html' }),
],
}
После npm run build откроется интерактивная карта бандла. Ищем крупные зависимости: chart libraries, rich text editors, date pickers, map SDKs — первые кандидаты на lazy loading.
Preload критичных чанков
Чанки, которые почти гарантированно понадобятся, можно prefetch в idle time:
// Prefetch при hover на ссылку
function NavLink({ href, chunkImport, children }) {
const handleMouseEnter = () => {
chunkImport() // () => import('@/pages/About')
}
return <a href={href} onMouseEnter={handleMouseEnter}>{children}</a>
}
Сроки
Настройка React.lazy + Suspense для существующих компонентов — 0.5 дня. Полный аудит бандла, приоритизация, реализация intersection-based загрузки с skeleton-заглушками — 2–3 дня в зависимости от количества компонентов.







