Code splitting в React: lazy и Suspense на практике
Практический разбор code splitting в React: как с помощью React.lazy и Suspense разбить тяжелый стартовый bundle, ускорить первую отрисовку и улучшить пользовательский опыт.
Когда приложение растет, стартовый bundle становится громоздким, а главная страница “тормозит”. Пользователь видит белый экран, пока загружается весь код, хотя ему нужен лишь один экран. Здесь помогает code splitting разбиение кода на части, которые подгружаются по требованию.
Разберем реальную задачу: вынести отдельный роут в отдельный чанк, загрузить его через React.lazy
, а пока компонент подгружается показать заполнитель с помощью Suspense
. Все без лишних абстракций, только рабочие фрагменты.
Почему большой bundle тормозит
Большой bundle заставляет браузер загрузить и распарсить мегабайты скриптов, даже если пользователь видит только одну страницу. Последствия:
- длительное время до интерактивности (TTI);
- избыточный трафик на мобильных устройствах;
- повышенное потребление памяти.
Главный вопрос: что можно отложить? Все, что не требуется сразу, и есть цель code splitting. В React-приложениях обычно откладывают роуты, тяжелые визуальные компоненты и отдельные библиотеки (например, датапикер).
Динамический импорт как фундамент
import()
синтаксис ECMAScript, позволяющий загружать модуль асинхронно. Webpack автоматически создает отдельный чанк для каждого такого импорта.
// utils.js синхронный импорт (плохой для больших модулей)
import { heavyChart } from "./charts";
// лучше динамический импорт
const loadChart = () => import("./charts");
loadChart().then(({ heavyChart }) => {
heavyChart.render();
});
Синхронный импорт | Динамический импорт | |
---|---|---|
Когда происходит | При первом запуске | При вызове import() |
Размер начального чанка | Большой | Минимальный |
Возможность кэшировать | Да, но сразу все | Да, каждый чанк отдельно |
Динамический импорт задает точку, где код может быть отложен. В React его обычно оборачивают в React.lazy
.
React.lazy: объявляем асинхронный компонент
React.lazy
принимает функцию, возвращающую промис от import()
. Компонент загружается только при первом использовании.
import React, { Suspense } from "react";
// Отложенный компонент "Dashboard"
const Dashboard = React.lazy(() => import("./pages/Dashboard"));
export default function App() {
return (
<div>
<Suspense fallback={<div>Загрузка…</div>}>
<Dashboard />
</Suspense>
</div>
);
}
React.lazy
простой способ включить code splitting без изменения сборки. Он работает лишь с default export
. Если модуль экспортирует несколько имен, оберните их в небольшую прокси-функцию.
Suspense как заполнитель
Suspense
обертка, отображающая fallback
(заполнитель), пока дочерний lazy-компонент не готов. Здесь удобно поставить спиннер, скелетон-элемент или предзагруженные стили.
<Suspense fallback={<Skeleton title="Dashboard" rows={5} />}>
<Dashboard />
</Suspense>
Пользователь сразу видит полезный UI, а не пустой экран. React “приостанавливает” рендер, не бросая ошибок, пока промис не выполнится.
Lazy loading в роутере: маршруты как отдельные чанки
Самый популярный кейс отложить каждый роут в отдельный чанк. В react-router-dom
v6 это выглядит так:
import { BrowserRouter, Routes, Route } from "react-router-dom";
import React, { Suspense } from "react";
const Home = React.lazy(() => import("./pages/Home"));
const Settings = React.lazy(() => import("./pages/Settings"));
const NotFound = React.lazy(() => import("./pages/NotFound"));
export default function AppRouter() {
return (
<BrowserRouter>
<Suspense fallback={<div>Загрузка...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Каждая страница попадает в свой чанк и загружается только при переходе. При желании можно добавить предзагрузку (<link rel="preload" as="script" href="...">
), чтобы ускорить навигацию.
Тонкая настройка: предзагрузка и шаблоны заполнителей
Чтобы пользователь почти не ждал, есть два подхода:
- Prefetch добавить атрибут
webpackPrefetch: true
к импорту. Браузер загрузит чанк в фоне, когда сеть свободна.const Settings = React.lazy(() => import(/* webpackPrefetch: true */ "./pages/Settings") );
- Skeleton UI вместо простого “Загрузка…” показывать скелетон-элемент, похожий на конечный UI. Это снижает восприятие задержки.
Для крупных приложений удобно собрать “матрицу” состояний загрузки в отдельный файл, чтобы не дублировать код.
FAQ / чеклист
- Обернуть весь роутер в один
Suspense
? Часто хватает глобального заполнителя, но иногда отдельным роутам нужен свой скелетон. - Забыли про
default export
?React.lazy
упадет с ошибкой. Добавьтеexport default Component;
или используйтеReact.lazy(() => import("./mod").then((m) => ({ default: m.Named })));
- Чанки загружают свои стили? Настройте
MiniCssExtractPlugin
с опциейchunks: true
. - Перегрузка сети предзагрузкой? Делайте
webpackPrefetch
только для часто посещаемых страниц. - Кешировать чанки? Установите
output.filename: '[name].[contenthash].js'
в Webpack, чтобы браузер хранил уже загруженные файлы.
Если приложение растет и первая отрисовка замедляется, code splitting проверенный способ избавиться от тяжелых стартовых бандлов.
Комбинация React.lazy
и Suspense
внедряется без сложных настроек, а небольшая предзагрузка делает переходы практически незаметными.
Для дополнительной оптимизации больших списков рассмотрите виртуализацию списков, а для тонкой настройки рендера изучите мемоизацию компонентов.