Когда вы создаёте React-приложение, Webpack (или Vite) по умолчанию собирает весь код в один JavaScript-файл. Если у вас страница настроек, страница статистики и страница профиля — весь их код загружается при открытии главной страницы, даже если пользователь никогда не перейдёт в настройки.
Последствия: большой начальный бандл → долгая загрузка → плохой UX и Core Web Vitals.
Решение: Code Splitting — разбиваем код на части и загружаем только то, что нужно сейчас.
React.lazy() принимает функцию, возвращающую динамический импорт. Компонент загружается только при первом рендеринге:
import React, { lazy, Suspense } from 'react'
// Обычный импорт — всегда в бандле:
import HeavyChart from './HeavyChart' // 200KB библиотека
// Ленивый импорт — загрузится только когда нужен:
const HeavyChart = lazy(() => import('./HeavyChart'))
const SettingsPage = lazy(() => import('./pages/Settings'))
const ProfilePage = lazy(() => import('./pages/Profile'))
// Suspense показывает fallback пока компонент загружается:
function App() {
return (
<Suspense fallback={<div>Загрузка...</div>}>
<HeavyChart data={data} />
</Suspense>
)
}Когда React встречает ленивый компонент первый раз:
1. Вызывает функцию () => import('./HeavyChart')
2. Начинается загрузка отдельного JavaScript-чанка
3. Компонент бросает промис (throws a Promise) — это сигнал для Suspense
4. Suspense перехватывает промис и отображает fallback
5. Когда промис разрешается — Suspense убирает fallback и рендерит компонент
// Упрощённая схема работы React.lazy:
function lazy(importFn) {
let status = 'pending'
let result
const promise = importFn().then(
module => { status = 'success'; result = module.default },
error => { status = 'error'; result = error }
)
return function LazyComponent(props) {
if (status === 'pending') throw promise // Suspense поймает
if (status === 'error') throw result // ErrorBoundary поймает
return React.createElement(result, props) // успех
}
}Самый распространённый use case — разделение по страницам:
import { Routes, Route } from 'react-router-dom'
import { lazy, Suspense } from 'react'
const HomePage = lazy(() => import('./pages/Home'))
const DashboardPage = lazy(() => import('./pages/Dashboard'))
const SettingsPage = lazy(() => import('./pages/Settings'))
function App() {
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
)
}Теперь код каждой страницы загружается только при переходе на неё.
React.lazy требует default export. Для named exports используйте обёртку:
// Если компонент экспортируется через named export:
// export { SettingsPage } — без default
const SettingsPage = lazy(() =>
import('./pages/Settings').then(module => ({
default: module.SettingsPage // приводим к default
}))
)В React 18 Suspense работает не только с lazy, но и с data fetching. Библиотеки вроде TanStack Query поддерживают это:
// С TanStack Query (suspense: true):
function UserProfile({ id }) {
const { data: user } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
suspense: true, // бросает промис пока данные не загружены
})
// Сюда код попадает только когда user уже загружен!
return <div>{user.name}</div>
}
// Родитель управляет состоянием загрузки:
<Suspense fallback={<Skeleton />}>
<UserProfile id={1} />
</Suspense>Управляет порядком появления нескольких Suspense-компонентов:
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<Skeleton />}><ProfilePic /></Suspense>
<Suspense fallback={<Skeleton />}><ProfileDetails /></Suspense>
<Suspense fallback={<Skeleton />}><ProfileTimeline /></Suspense>
</SuspenseList>
// forwards: компоненты появляются последовательно сверху вниз
// tail: "collapsed" — показываем только один skeleton за разДо code splitting:
main.js: 800KB (всё приложение)После React.lazy для 4 страниц:
main.js: 200KB (общий код + React)home.js: 150KB (загружается при открытии)dashboard.js: 300KB (загружается при переходе)settings.js: 80KB (загружается при переходе)profile.js: 70KB (загружается при переходе)Первоначальная загрузка уменьшается с 800KB до 350KB — на 56%.
Симуляция ленивой загрузки компонентов: динамический импорт, состояния загрузки, кэширование чанков и паттерн Suspense через промисы
// Симулируем React.lazy() и Suspense на чистом JavaScript.
// Показываем как динамическая загрузка модулей работает под капотом.
// --- Симуляция "тяжёлых" модулей ---
const fakeModules = {
'./HeavyChart': {
size: '180KB',
loadTime: 800,
default: function HeavyChart(props) {
return '[Chart: ' + props.title + ']'
}
},
'./pages/Dashboard': {
size: '250KB',
loadTime: 1200,
default: function Dashboard() {
return '[Dashboard Page]'
}
},
'./pages/Settings': {
size: '90KB',
loadTime: 400,
default: function Settings() {
return '[Settings Page]'
}
},
}
// --- Симуляция динамического import() ---
function dynamicImport(path) {
return new Promise((resolve, reject) => {
const module = fakeModules[path]
if (!module) {
reject(new Error('Модуль не найден: ' + path))
return
}
console.log(' Начинаем загрузку ' + path + ' (' + module.size + ')...')
setTimeout(() => {
console.log(' Загружен ' + path + ' за ' + module.loadTime + 'мс')
resolve({ default: module.default })
}, module.loadTime)
})
}
// --- Реализация React.lazy() ---
function lazy(importFn) {
let status = 'idle'
let result = null
let promise = null
let cachedModule = null
return {
_getStatus: () => status,
_getModule: () => cachedModule,
load() {
if (status === 'success') return Promise.resolve(cachedModule)
if (promise) return promise // дедупликация: не загружаем дважды
status = 'pending'
promise = importFn().then(
(module) => {
status = 'success'
cachedModule = module.default
return module.default
},
(error) => {
status = 'error'
result = error
throw error
}
)
return promise
},
render(props) {
if (status === 'idle' || status === 'pending') {
throw promise || this.load() // Suspense поймает!
}
if (status === 'error') throw result
return cachedModule(props)
}
}
}
// --- Симуляция Suspense ---
async function Suspense(fallback, renderFn) {
console.log('Suspense: начинаем рендер')
while (true) {
try {
const result = renderFn()
console.log('Suspense: компонент готов →', result)
return result
} catch (promise) {
if (promise instanceof Promise) {
console.log('Suspense: компонент ещё загружается, показываем:', fallback)
await promise // ждём загрузки
console.log('Suspense: загрузка завершена, повторяем рендер...')
} else {
throw promise // настоящая ошибка — пробрасываем
}
}
}
}
// --- Тест 1: Ленивая загрузка одного компонента ---
async function test1() {
console.log('=== Тест 1: Ленивая загрузка HeavyChart ===')
const HeavyChart = lazy(() => dynamicImport('./HeavyChart'))
HeavyChart.load() // начинаем загрузку
const output = await Suspense(
'[Загрузка графика...]',
() => HeavyChart.render({ title: 'Продажи за месяц' })
)
console.log('Итог:', output)
}
// --- Тест 2: Кэширование (не загружает дважды) ---
async function test2() {
console.log('
=== Тест 2: Кэширование чанков ===')
const Settings = lazy(() => dynamicImport('./pages/Settings'))
console.log('Первая загрузка:')
await Settings.load()
console.log('Статус:', Settings._getStatus()) // success
console.log('
Вторая "загрузка" (должна использовать кэш):')
const start = performance.now()
await Settings.load()
const elapsed = Math.round(performance.now() - start)
console.log('Время:', elapsed + 'мс (из кэша, почти 0мс)')
console.log('Загрузка из кэша:', elapsed < 10)
}
// --- Тест 3: Параллельная загрузка нескольких страниц ---
async function test3() {
console.log('
=== Тест 3: Параллельная загрузка страниц ===')
const pages = {
dashboard: lazy(() => dynamicImport('./pages/Dashboard')),
settings: lazy(() => dynamicImport('./pages/Settings')),
}
const startAll = performance.now()
// Загружаем параллельно (как при preload)
await Promise.all([
pages.dashboard.load(),
pages.settings.load(),
])
console.log('Все страницы загружены параллельно за', Math.round(performance.now() - startAll) + 'мс')
console.log('(вместо последовательных', 1200 + 400, 'мс = 1600мс)')
console.log('Dashboard:', pages.dashboard.render({}))
console.log('Settings:', pages.settings.render({}))
}
async function runAll() {
await test1()
await test2()
await test3()
}
runAll()Когда вы создаёте React-приложение, Webpack (или Vite) по умолчанию собирает весь код в один JavaScript-файл. Если у вас страница настроек, страница статистики и страница профиля — весь их код загружается при открытии главной страницы, даже если пользователь никогда не перейдёт в настройки.
Последствия: большой начальный бандл → долгая загрузка → плохой UX и Core Web Vitals.
Решение: Code Splitting — разбиваем код на части и загружаем только то, что нужно сейчас.
React.lazy() принимает функцию, возвращающую динамический импорт. Компонент загружается только при первом рендеринге:
import React, { lazy, Suspense } from 'react'
// Обычный импорт — всегда в бандле:
import HeavyChart from './HeavyChart' // 200KB библиотека
// Ленивый импорт — загрузится только когда нужен:
const HeavyChart = lazy(() => import('./HeavyChart'))
const SettingsPage = lazy(() => import('./pages/Settings'))
const ProfilePage = lazy(() => import('./pages/Profile'))
// Suspense показывает fallback пока компонент загружается:
function App() {
return (
<Suspense fallback={<div>Загрузка...</div>}>
<HeavyChart data={data} />
</Suspense>
)
}Когда React встречает ленивый компонент первый раз:
1. Вызывает функцию () => import('./HeavyChart')
2. Начинается загрузка отдельного JavaScript-чанка
3. Компонент бросает промис (throws a Promise) — это сигнал для Suspense
4. Suspense перехватывает промис и отображает fallback
5. Когда промис разрешается — Suspense убирает fallback и рендерит компонент
// Упрощённая схема работы React.lazy:
function lazy(importFn) {
let status = 'pending'
let result
const promise = importFn().then(
module => { status = 'success'; result = module.default },
error => { status = 'error'; result = error }
)
return function LazyComponent(props) {
if (status === 'pending') throw promise // Suspense поймает
if (status === 'error') throw result // ErrorBoundary поймает
return React.createElement(result, props) // успех
}
}Самый распространённый use case — разделение по страницам:
import { Routes, Route } from 'react-router-dom'
import { lazy, Suspense } from 'react'
const HomePage = lazy(() => import('./pages/Home'))
const DashboardPage = lazy(() => import('./pages/Dashboard'))
const SettingsPage = lazy(() => import('./pages/Settings'))
function App() {
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
)
}Теперь код каждой страницы загружается только при переходе на неё.
React.lazy требует default export. Для named exports используйте обёртку:
// Если компонент экспортируется через named export:
// export { SettingsPage } — без default
const SettingsPage = lazy(() =>
import('./pages/Settings').then(module => ({
default: module.SettingsPage // приводим к default
}))
)В React 18 Suspense работает не только с lazy, но и с data fetching. Библиотеки вроде TanStack Query поддерживают это:
// С TanStack Query (suspense: true):
function UserProfile({ id }) {
const { data: user } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
suspense: true, // бросает промис пока данные не загружены
})
// Сюда код попадает только когда user уже загружен!
return <div>{user.name}</div>
}
// Родитель управляет состоянием загрузки:
<Suspense fallback={<Skeleton />}>
<UserProfile id={1} />
</Suspense>Управляет порядком появления нескольких Suspense-компонентов:
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<Skeleton />}><ProfilePic /></Suspense>
<Suspense fallback={<Skeleton />}><ProfileDetails /></Suspense>
<Suspense fallback={<Skeleton />}><ProfileTimeline /></Suspense>
</SuspenseList>
// forwards: компоненты появляются последовательно сверху вниз
// tail: "collapsed" — показываем только один skeleton за разДо code splitting:
main.js: 800KB (всё приложение)После React.lazy для 4 страниц:
main.js: 200KB (общий код + React)home.js: 150KB (загружается при открытии)dashboard.js: 300KB (загружается при переходе)settings.js: 80KB (загружается при переходе)profile.js: 70KB (загружается при переходе)Первоначальная загрузка уменьшается с 800KB до 350KB — на 56%.
Симуляция ленивой загрузки компонентов: динамический импорт, состояния загрузки, кэширование чанков и паттерн Suspense через промисы
// Симулируем React.lazy() и Suspense на чистом JavaScript.
// Показываем как динамическая загрузка модулей работает под капотом.
// --- Симуляция "тяжёлых" модулей ---
const fakeModules = {
'./HeavyChart': {
size: '180KB',
loadTime: 800,
default: function HeavyChart(props) {
return '[Chart: ' + props.title + ']'
}
},
'./pages/Dashboard': {
size: '250KB',
loadTime: 1200,
default: function Dashboard() {
return '[Dashboard Page]'
}
},
'./pages/Settings': {
size: '90KB',
loadTime: 400,
default: function Settings() {
return '[Settings Page]'
}
},
}
// --- Симуляция динамического import() ---
function dynamicImport(path) {
return new Promise((resolve, reject) => {
const module = fakeModules[path]
if (!module) {
reject(new Error('Модуль не найден: ' + path))
return
}
console.log(' Начинаем загрузку ' + path + ' (' + module.size + ')...')
setTimeout(() => {
console.log(' Загружен ' + path + ' за ' + module.loadTime + 'мс')
resolve({ default: module.default })
}, module.loadTime)
})
}
// --- Реализация React.lazy() ---
function lazy(importFn) {
let status = 'idle'
let result = null
let promise = null
let cachedModule = null
return {
_getStatus: () => status,
_getModule: () => cachedModule,
load() {
if (status === 'success') return Promise.resolve(cachedModule)
if (promise) return promise // дедупликация: не загружаем дважды
status = 'pending'
promise = importFn().then(
(module) => {
status = 'success'
cachedModule = module.default
return module.default
},
(error) => {
status = 'error'
result = error
throw error
}
)
return promise
},
render(props) {
if (status === 'idle' || status === 'pending') {
throw promise || this.load() // Suspense поймает!
}
if (status === 'error') throw result
return cachedModule(props)
}
}
}
// --- Симуляция Suspense ---
async function Suspense(fallback, renderFn) {
console.log('Suspense: начинаем рендер')
while (true) {
try {
const result = renderFn()
console.log('Suspense: компонент готов →', result)
return result
} catch (promise) {
if (promise instanceof Promise) {
console.log('Suspense: компонент ещё загружается, показываем:', fallback)
await promise // ждём загрузки
console.log('Suspense: загрузка завершена, повторяем рендер...')
} else {
throw promise // настоящая ошибка — пробрасываем
}
}
}
}
// --- Тест 1: Ленивая загрузка одного компонента ---
async function test1() {
console.log('=== Тест 1: Ленивая загрузка HeavyChart ===')
const HeavyChart = lazy(() => dynamicImport('./HeavyChart'))
HeavyChart.load() // начинаем загрузку
const output = await Suspense(
'[Загрузка графика...]',
() => HeavyChart.render({ title: 'Продажи за месяц' })
)
console.log('Итог:', output)
}
// --- Тест 2: Кэширование (не загружает дважды) ---
async function test2() {
console.log('
=== Тест 2: Кэширование чанков ===')
const Settings = lazy(() => dynamicImport('./pages/Settings'))
console.log('Первая загрузка:')
await Settings.load()
console.log('Статус:', Settings._getStatus()) // success
console.log('
Вторая "загрузка" (должна использовать кэш):')
const start = performance.now()
await Settings.load()
const elapsed = Math.round(performance.now() - start)
console.log('Время:', elapsed + 'мс (из кэша, почти 0мс)')
console.log('Загрузка из кэша:', elapsed < 10)
}
// --- Тест 3: Параллельная загрузка нескольких страниц ---
async function test3() {
console.log('
=== Тест 3: Параллельная загрузка страниц ===')
const pages = {
dashboard: lazy(() => dynamicImport('./pages/Dashboard')),
settings: lazy(() => dynamicImport('./pages/Settings')),
}
const startAll = performance.now()
// Загружаем параллельно (как при preload)
await Promise.all([
pages.dashboard.load(),
pages.settings.load(),
])
console.log('Все страницы загружены параллельно за', Math.round(performance.now() - startAll) + 'мс')
console.log('(вместо последовательных', 1200 + 400, 'мс = 1600мс)')
console.log('Dashboard:', pages.dashboard.render({}))
console.log('Settings:', pages.settings.render({}))
}
async function runAll() {
await test1()
await test2()
await test3()
}
runAll()Создай приложение с React.Suspense и React.lazy. Загружай "тяжёлый" компонент по клику на кнопку. Пока компонент загружается, показывай индикатор загрузки.
Передай компонент LoadingFallback в fallback: <React.Suspense fallback={<LoadingFallback />}>. Suspense автоматически покажет fallback пока lazy-компонент загружается.