Code splitting и lazy loading в React: как уменьшить bundle и не сломать UX

Разбираем code splitting и lazy loading в React: React.lazy, Suspense, route-level и component-level splitting, prefetch, production-ошибки, performance trade-off и ответы для интервью.

10 марта 2026 г.20 минLexicon Team

Введение

Code splitting и lazy loading часто подают как очевидную оптимизацию: разбили bundle, добавили React.lazy, завернули в Suspense и получили быстрый фронтенд. В реальном проекте все сложнее. Можно уменьшить initial bundle и одновременно ухудшить route transitions, добавить мерцание интерфейса, умножить число сетевых запросов и превратить приложение в набор случайных skeleton-состояний.

Именно поэтому на собеседовании тема почти никогда не сводится к API lazy(() => import(...)). Интервьюер через нее проверяет, умеете ли вы думать о загрузке JavaScript как о части архитектуры экрана: что грузится сразу, что откладывается, где проходит граница между route-level и component-level splitting, как это влияет на UX и где optimization становится преждевременной.

Для смежного контекста полезно держать рядом разбор Suspense, React роутинг и оптимизацию React на middle-собеседовании.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью.

Подписаться

Что такое code splitting и что такое lazy loading

Эти термины часто смешивают, хотя это не одно и то же.

Code splitting

Это разбиение кода на несколько чанков вместо одного большого бандла. Обычно этим занимается сборщик: Vite, Webpack, Rollup или платформа поверх них.

Цель:

  • уменьшить initial download;
  • сократить parse/compile cost на старте;
  • не тащить редкий код в первый экран.

Lazy loading

Это стратегия отложенной загрузки. Она может применяться к:

  • JS-чанкам;
  • маршрутам;
  • виджетам;
  • картинкам;
  • данным;
  • сторонним SDK.

Практический вывод: code splitting отвечает на вопрос «как разрезан код», lazy loading — «когда конкретный кусок будет загружен».

Почему тема важна в production

Проблема больших bundle не только в мегабайтах. Большой стартовый JS бьет по нескольким местам сразу:

  • дольше скачивается по медленной сети;
  • дольше парсится и компилируется в браузере;
  • позже становится интерактивным первый экран;
  • чаще блокирует main thread.

На современных приложениях это особенно заметно, когда в стартовый бандл случайно попадают:

  • тяжелые графики;
  • редакторы текста;
  • админские модули;
  • код редких фич;
  • огромные таблицы;
  • аналитические SDK и не критичные интеграции.

Именно в таких местах code splitting реально оправдан. Но если начать резать код без модели экрана, можно попасть в противоположную проблему: старт стал легче, а переходы между экранами превратились в цепочку сетевых ожиданий.

Архитектура: где вообще резать приложение

Сильный инженерный ответ на тему code splitting почти всегда начинается не с React.lazy, а с вопроса: какие куски интерфейса пользователь должен получить сразу, а какие могут подождать.

Уровни разбиения

  1. App shell. Это root layout, базовая навигация, тема, провайдеры и критичный каркас интерфейса.

  2. Route-level chunks. Крупные экраны: dashboard, settings, analytics, onboarding, billing.

  3. Feature-level chunks. Редкие функциональные модули внутри экрана: chart builder, import wizard, PDF preview, code editor.

  4. Widget-level chunks. Вторичные тяжелые виджеты, которые не нужны для first meaningful interaction.

Поток загрузки

Нормальная стратегия обычно выглядит так:

  1. Сразу грузится shell и код первого сценария.
  2. Route chunk подгружается при переходе на экран.
  3. Вторичные тяжелые виджеты догружаются после того, как пользователь уже получил usable content.
  4. Редкие действия вроде открытия модалки с редактором или экспорта PDF грузят код только при реальном использовании.

Узкое место возникает, когда граница разбиения не совпадает с UX-границей. Если пользователь ждет критичный экран, а вы отложили половину его содержимого без нормального fallback, выигрыш в bundle size окажется сомнительным.

Эта модель хорошо стыкуется с тем, как проектируют route-уровни и nested layouts в статье про React routing.

Базовый пример: React.lazy и Suspense

Самый узнаваемый паттерн выглядит так:

import { Suspense, lazy } from "react";

const AnalyticsPage = lazy(() => import("./pages/AnalyticsPage"));

export function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <AnalyticsPage />
    </Suspense>
  );
}

Что здесь реально происходит:

  • import("./pages/AnalyticsPage") создает отдельный чанк;
  • этот чанк не попадает в initial bundle;
  • при первом рендере страницы React понимает, что модуль еще не готов;
  • Suspense временно показывает fallback;
  • после загрузки чанка компонент рендерится как обычно.

Важно не путать этот механизм с оптимизацией «вообще всего». React.lazy решает только отложенную загрузку модуля. Он не решает автоматически:

  • качество fallback;
  • prefetch;
  • дублирование данных;
  • route flicker;
  • повторные загрузки соседних тяжелых модулей.

Route-level splitting против component-level splitting

Это один из самых частых вопросов на интервью, потому что именно здесь видны trade-off.

Route-level splitting

Подходит, когда экран крупный и логически самостоятельный:

  • /dashboard
  • /settings
  • /reports
  • /admin

Плюсы:

  • проще аргументировать границы;
  • хорошо ложится на навигацию пользователя;
  • уменьшает размер initial bundle без дробления UI на слишком много мелких частей.

Минусы:

  • переход на новый route может стать заметно медленнее без prefetch;
  • если экран очень тяжелый, один route chunk все равно может быть большим.

Component-level splitting

Подходит, когда тяжелый модуль внутри страницы не критичен для первого взаимодействия:

  • rich text editor;
  • map SDK;
  • charting library;
  • PDF viewer;
  • drag-and-drop builder.

Плюсы:

  • не блокирует весь экран;
  • позволяет показать shell и основные данные раньше;
  • лучше для вторичных feature islands.

Минусы:

  • больше сетевых round-trip;
  • выше риск фрагментированного UX;
  • проще перестараться и нарезать приложение на слишком мелкие чанки.

Таблица: как выбирать стратегию

СценарийRoute-level splittingComponent-level splittingОсновной критерий выбораОграничения
Отдельный экран аналитикиДаИногдапользователь попадает туда отдельной навигациейбез prefetch переход может стать тяжелым
Тяжелый график внутри dashboardИногдаДаграфик не критичен для first paintнужен качественный skeleton
Редкая модалка с редакторомНетДакод нужен только по действию пользователявозможна задержка на первом открытии
Админский разделДаИногдаотделяем редкий модуль от основной массы пользователейважна стратегия доступа и preload
Базовые кнопки и мелкие формыНетНеткод слишком мелкий для splittingnetwork overhead дороже выгоды
Огромный route c несколькими heavy widgetsДаДасначала режем экран, потом тяжелые внутренние блокинужна согласованная карта fallback-ов

Практический вывод: чаще всего выигрыш дает не максимальное дробление, а правильная комбинация крупного route split и точечного lazy loading тяжелых островов внутри экрана.

Кейс из production: dashboard с графиком и редактором

Представим экран, где пользователю нужно быстро увидеть summary, список метрик и фильтры, а график и редактор правил нужны не всегда.

import { Suspense, lazy } from "react";

const RevenueChart = lazy(() => import("./RevenueChart"));
const RulesEditorModal = lazy(() => import("./RulesEditorModal"));

export function DashboardPage() {
  const [isEditorOpen, setIsEditorOpen] = useState(false);

  return (
    <main>
      <DashboardHeader />
      <SummaryCards />
      <Filters />

      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      <button onClick={() => setIsEditorOpen(true)}>
        Редактировать правила
      </button>

      {isEditorOpen ? (
        <Suspense fallback={<ModalSkeleton />}>
          <RulesEditorModal onClose={() => setIsEditorOpen(false)} />
        </Suspense>
      ) : null}
    </main>
  );
}

Почему это обычно лучше, чем тянуть все сразу:

  • summary и фильтры доступны без ожидания тяжёлого charting-кода;
  • модалка редактора не попадает в стартовый путь вообще;
  • пользователь не платит стоимость за редкую функцию до момента, когда она реально нужна.

Но здесь есть реальный trade-off:

  • первое открытие модалки может ощущаться медленнее;
  • график может догружаться позже и давать layout shift;
  • если пользователь почти всегда открывает редактор сразу после входа, такая стратегия уже сомнительна.

Именно этот тип рассуждений хорошо выглядит на интервью: не «lazy loading всегда лучше», а «нужно соотнести стоимость старта и стоимость первого использования».

Production pitfalls: что чаще всего ломают

Ошибка 1. Режут слишком мелко

Симптомы:

  • initial bundle меньше, но в waterfall десятки мелких JS-запросов;
  • переходы между экранами стали дергаными;
  • на слабой сети пользователь чаще видит fallback, чем контент.

Последствия:

  • растет latency из-за лишних round-trip;
  • browser cache используется хуже, чем ожидалось;
  • perceived performance падает.

Как обнаружить заранее:

  • смотреть network waterfall;
  • сравнивать не только bundle size, но и route-to-interactive;
  • тестировать на медленной сети и слабом CPU.

Ошибка 2. Lazy loading критичного первого экрана

Симптомы:

  • пользователь попадает на страницу и сразу видит пустой skeleton;
  • headline, CTA и базовый контент запаздывают;
  • Core Web Vitals не улучшаются, а иногда ухудшаются.

Последствия:

  • падает perceived quality первого визита;
  • выигрыш в весе бандла не конвертируется в лучший UX.

Исправление:

  • shell и критичный above-the-fold контент держать в стартовом пути;
  • выносить в lazy только вторичные или редкие блоки;
  • использовать route-aware prefetch для ожидаемых переходов.

Ошибка 3. Плохой fallback

Симптомы:

  • интерфейс мерцает;
  • skeleton не похож на будущую структуру;
  • при каждом переходе пользователь теряет визуальный контекст.

Последствия:

  • приложение кажется нестабильным;
  • растет субъективное время ожидания;
  • сложнее диагностировать реальные проблемы переходов.

Исправление:

  • проектировать fallback как часть UX, а не как техническую заглушку;
  • не подменять весь экран глобальным спиннером без причины;
  • ставить Suspense границами, совпадающими с UX-островами.

Разбор производительности

В теме code splitting нельзя смотреть только на размер initial bundle. Важны минимум четыре группы метрик:

  1. Размер и количество чанков. Слишком большой стартовый chunk плохо. Слишком много мелких чанков тоже плохо.

  2. Время до usable content. Когда пользователь может реально начать сценарий, а не просто увидеть skeleton.

  3. Route transition latency. Сколько проходит между кликом по навигации и появлением рабочего экрана.

  4. Main thread cost. Даже после загрузки chunk браузер еще должен распарсить, скомпилировать и выполнить код.

Когда оптимизация оправдана:

  • стартовый экран перегружен редким кодом;
  • chunk analytics/admin/editor сильно утяжеляет initial load;
  • приложение используется на слабых устройствах;
  • route transitions профилируются как заметно медленные.

Когда optimization premature:

  • приложение маленькое;
  • редкий модуль весит мало;
  • вы не измеряли реальные переходы и только смотрите на абстрактный bundle report.

Нормальный инженерный подход здесь такой: сначала измерить bundle analyzer, network waterfall и route transition timings, затем проверить UX на слабом профиле устройства, и только после этого решать, где именно резать код. Параллельно стоит помнить о Concurrent Rendering, потому что responsiveness при переходах зависит не только от веса chunk, но и от того, как React обрабатывает обновления.

Прокачай React за 7 дней

20 вопросов и разборов по React Hooks.

Начать

Практики, которые реально работают

Сначала режьте по route, потом по тяжелым feature-модулям

Это обычно дает лучший баланс между простотой архитектуры и выигрышем по стартовой загрузке.

Префетчите ожидаемые переходы

Если пользователь с высокой вероятностью перейдет в соседний раздел, prefetch может убрать ощущение «пустого» lazy route. Но prefetch тоже стоит применять осознанно: он расходует сеть и может быть вреден на слабом соединении.

Не lazy-load'ьте все подряд

Крошечные утилитарные компоненты, базовые form controls и элементы стартового пути обычно не выигрывают от отдельного чанка.

Проектируйте fallback под структуру экрана

Skeleton полезнее глобального spinner почти во всех сценариях, где пользователь должен сохранить контекст layout.

Меряйте переходы, а не только старт

Иногда initial load улучшается, но навигация между рабочими экранами деградирует. Такой обмен не всегда выгоден продукту.

Сочетайте splitting с нормальной картой данных

Если route split сделали, а данные все равно дублируются при каждом переходе, выигрыш будет частичным. Здесь помогает думать на уровне экранной архитектуры, а не только сборщика.

Частые ошибки

Путать lazy loading с «оптимизацией в целом»

Отложенная загрузка JS не исправляет тяжелые ререндеры, плохой data flow и неудачный layout.

Считать, что меньше bundle всегда означает лучше UX

Иногда вы уменьшили стартовый файл, но увеличили число сетевых пауз в реальных сценариях.

Игнорировать cache behavior

Пользователь не каждый раз приходит с нуля. Иногда разумнее оставить крупный стабильный chunk, который хорошо кэшируется, чем дробить его на часто меняющиеся куски.

Ставить один глобальный Suspense на все приложение

Так легко получить ситуацию, где любой отложенный модуль временно скрывает слишком большую часть интерфейса.

Как отвечать на интервью

Сильный короткий ответ на вопрос про code splitting и lazy loading обычно строится так:

  1. Code splitting — это разбиение кода на чанки, lazy loading — стратегия их отложенной загрузки.
  2. Главная цель — уменьшить стоимость initial load и не тащить редкий код в первый экран.
  3. На практике сначала думаю о route-level splitting, потом о тяжелых внутренних модулях вроде chart, editor или map.
  4. React.lazy и Suspense помогают реализовать lazy loading, но сами по себе не гарантируют хороший UX.
  5. Главные риски в production: слишком мелкие чанки, плохие fallback, отсутствие prefetch и ухудшение route transition latency.

Если спрашивают глубже, полезно добавить:

  • нельзя смотреть только на размер bundle, надо мерить transition и main thread;
  • не весь код стоит резать, иногда overhead сети дороже выигрыша;
  • fallback должен сохранять контекст интерфейса;
  • splitting должен совпадать с пользовательским сценарием, а не только со структурой файлов.

Такой ответ показывает, что вы понимаете тему не как трюк сборщика, а как часть frontend-архитектуры.

Подготовься к React-собеседованию на реальных performance-вопросах

Разберем code splitting, Suspense, ререндеры, routing и performance trade-off в формате mock-интервью с разбором сильных и слабых ответов.

Начать тренировку

FAQ

Что лучше: split по route или по компонентам

Обычно начинают с route-level splitting, потому что это более естественная и понятная граница. Component-level splitting добавляют точечно для тяжелых редких модулей.

Нужно ли всегда использовать React.lazy вместе с Suspense

Для lazy-компонентов через React.lazy — да, нужен Suspense boundary, который определит fallback на время загрузки модуля.

Почему lazy loading не всегда ускоряет приложение

Потому что часть стоимости просто переносится с initial load на момент первого использования. Если пользователь почти сразу открывает лениво загружаемый модуль, выигрыш может быть спорным.

Где чаще всего применяют lazy loading в production

В route-level экранах, админских разделах, модалках с тяжелым функционалом, графиках, редакторах, карте, PDF preview и других редких, но дорогих модулях.

Что важнее на интервью: знать синтаксис или понимать trade-off

Trade-off. Синтаксис lazy(() => import(...)) запоминается быстро, а вот объяснить, почему конкретный split улучшит UX, без инженерной модели не получится.

Итоги

Code splitting и lazy loading полезны не потому, что «так делают все React-приложения», а потому что они позволяют платить стоимость JavaScript тогда, когда она действительно нужна пользователю. В этом и есть главная инженерная идея: не грузить редкий тяжелый код в первый экран без причины.

Но зрелое применение темы всегда упирается в компромисс. Можно уменьшить initial bundle и одновременно испортить переходы, добавить waterfall-загрузку и ухудшить UX. Поэтому правильный подход выглядит так: сначала понимать сценарий пользователя и границы экрана, потом проектировать split, потом измерять bundle, сеть и transition latency. Именно такой ответ обычно звучит как опыт работы с production, а не как пересказ документации.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью.

Подписаться

Автор

Lexicon Team

Читайте также