← React/Suspense и React.lazy: ленивая загрузка компонентов#278 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksМаршрут: старт с нуля

Suspense и React.lazy: ленивая загрузка компонентов

Проблема: всё в одном бандле

Когда вы создаёте React-приложение, Webpack (или Vite) по умолчанию собирает весь код в один JavaScript-файл. Если у вас страница настроек, страница статистики и страница профиля — весь их код загружается при открытии главной страницы, даже если пользователь никогда не перейдёт в настройки.

Последствия: большой начальный бандл → долгая загрузка → плохой UX и Core Web Vitals.

Решение: Code Splitting — разбиваем код на части и загружаем только то, что нужно сейчас.

React.lazy() + динамический import()

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>
  )
}

Теперь код каждой страницы загружается только при переходе на неё.

Named exports с lazy

React.lazy требует default export. Для named exports используйте обёртку:

// Если компонент экспортируется через named export:
// export { SettingsPage }  — без default

const SettingsPage = lazy(() =>
  import('./pages/Settings').then(module => ({
    default: module.SettingsPage  // приводим к default
  }))
)

Suspense для получения данных (React 18+)

В 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>

SuspenseList

Управляет порядком появления нескольких 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()

    Suspense и React.lazy: ленивая загрузка компонентов

    Проблема: всё в одном бандле

    Когда вы создаёте React-приложение, Webpack (или Vite) по умолчанию собирает весь код в один JavaScript-файл. Если у вас страница настроек, страница статистики и страница профиля — весь их код загружается при открытии главной страницы, даже если пользователь никогда не перейдёт в настройки.

    Последствия: большой начальный бандл → долгая загрузка → плохой UX и Core Web Vitals.

    Решение: Code Splitting — разбиваем код на части и загружаем только то, что нужно сейчас.

    React.lazy() + динамический import()

    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>
      )
    }

    Теперь код каждой страницы загружается только при переходе на неё.

    Named exports с lazy

    React.lazy требует default export. Для named exports используйте обёртку:

    // Если компонент экспортируется через named export:
    // export { SettingsPage }  — без default
    
    const SettingsPage = lazy(() =>
      import('./pages/Settings').then(module => ({
        default: module.SettingsPage  // приводим к default
      }))
    )

    Suspense для получения данных (React 18+)

    В 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>

    SuspenseList

    Управляет порядком появления нескольких 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-компонент загружается.

    Загружаем среду выполнения...
    Загружаем AI-помощника...