Code splitting через React.lazy: грузим экран, а не приложение
React.lazy превращает динамический импорт в компонент, приостановленный до загрузки чанка. Режьте сначала по роутам, затем по тяжёлым компонентам за кликом; грейте по hover/viewport; не вкладывайте lazy в lazy — каждый уровень это полный round trip. Меряйте чанки, а не ощущения.
Ревью конверсии чекаута вытащило странное число: мобильные пользователи на 4G ждали в медиане 6,8 секунды, прежде чем платёжная форма становилась интерактивной. Анализатор бандла всё объяснил — чекаут ехал внутри одного бандла приложения на 1,9 МБ (612 КБ gzip), куда входили админский дашборд, библиотека графиков для админки, markdown-редактор для ответов саппорта и три локали форматирования дат. Покупатели скачивали и парсили бэк-офис, чтобы заплатить за носки. Разрезание по роутам сократило путь чекаута до 240 КБ, а ожидание — до 2,1 с. Затем случилась гиперкоррекция: коллега, уверовав в ленивую загрузку, перевёл тридцать компонентов на React.lazy — включая панель настроек, лениво грузившую форму, лениво грузившую свой rich-text-редактор. На соединении с RTT 200 мс открытие настроек теперь стоило три последовательных round trip — запрос, парсинг, рендер, обнаружение следующего ленивого ребёнка, снова запрос — лестница спиннеров на 1,4 с, которую профилирование на офисном вайфае не показывало никогда. Сплиттинг — не бесплатная гранулярность; каждая граница — это сетевое ребро, которое нужно ставить осознанно.
Механика: компонент, приостановленный до прибытия своего кода
Прежде чем тянуться к React.lazy, стоит понять, что браузер делает при динамическом импорте: вспышка fallback в дев-режиме — это видимая поверхность сетевого round trip, и именно это знание определяет, куда ставить каждую границу.
React.lazy(() => import("./Editor")) возвращает компонент, который вы рендерите как любой другой. При первой попытке рендера срабатывает загрузчик: import() велит бандлеру вырезать модуль (и всё, что импортирует только он) в отдельный чанк, который в этот момент скачивается по сети. Пока промис в полёте, ленивый компонент приостанавливается — ближайшая граница Suspense показывает свой fallback, — а когда модуль резолвится, React рендерит его экспорт по умолчанию. Модуль скачивается и выполняется один раз; последующие рендеры синхронны.
import { lazy, Suspense } from "react";
const Editor = lazy(() => import("./Editor")); // нужен default export
// Обход для именованных экспортов: перекраиваем модуль в загрузчике
const Chart = lazy(() =>
import("./charts").then((m) => ({ default: m.RevenueChart }))
);
function ReplyPanel({ open }) {
return (
<Suspense fallback={<EditorSkeleton />}>
{open && <Editor />}
</Suspense>
);
}Два правила объявления спасают от самострелов. Объявляйте ленивые компоненты на верхнем уровне модуля — lazy(), созданный внутри рендера, порождает новый тип компонента на каждый рендер, и React размонтирует и заново монтирует поддерево (потеря стейта, повторная загрузка) при каждом апдейте родителя. И lazy резолвит только default export; для именованных экспортов переотображайте модуль в загрузчике, как выше. Более глубокий смысл: каждая граница lazy — промис, резолвящийся за сетевое время, поэтому её размещение — архитектурное решение, а не синтаксическое.
Где резать: сначала роуты, потом компоненты
Самый ценный разрез — роут: пользователи одного экрана не получают ни байта кода остальных, граница чанка совпадает с естественным состоянием загрузки (навигацией), и роутеры современных фреймворков делают это по умолчанию. Первый фикс из истории — отделение чекаута от админских графиков и markdown-библиотек — был чистым роут-сплиттингом и снял бандл с 612 до 240 КБ gzip. Якорь для бюджета: 300 КБ сжатого JS — это примерно 1 МБ кода на парсинг и исполнение, то есть секунды CPU на среднем телефоне уже после окончания скачивания; сплиттинг экономит время парсинга, а не только байты.
Покомпонентный сплиттинг окупается на вещах тяжёлых и условных: модалки, rich-text-редакторы, панели графиков, всё ниже сгиба или за кликом. Тест конкретен: откройте анализатор бандла и посмотрите фактический размер чанка. Ленивая загрузка дейтпикера на 6 КБ не покупает почти ничего, а стоит запроса плюс вспышки fallback; ленивая загрузка редактора на 280 КБ, который открывают 4% сессий, — ради этого всё и затевалось. Режим отказа — водопад: ленивый компонент, чей чанк после загрузки рендерит другой ленивый компонент, создаёт последовательные round trip, потому что второй запрос невозможно обнаружить, пока первый чанк не приехал и не отрендерился. При RTT 200 мс три вложенные границы — это 600+ мс чистой задержки до какой-либо работы рендера. Держите границы соседями (один Suspense, чанки качаются параллельно), а не предками.
Лениво загружаемая панель настроек рендерит лениво загружаемую форму, а та — лениво загружаемый редактор. На офисном вайфае всё в порядке; на мобильном с RTT 200 мс открытие настроек — лестница спиннеров на 1,4 с. Почему?
Прогрев по намерению: потратьте задержку до клика
Задержке ленивого чанка не обязательно начинаться в момент рендера. Динамический import() — просто вызов функции, прогревающий кеш модулей: вызовите его заранее, и к моменту, когда React приостановится, промис может быть уже зарезолвлен:
const loadEditor = () => import("./Editor"); // общий загрузчик = общий кеш
const Editor = lazy(loadEditor);
<button
onMouseEnter={loadEditor} // намерение: фора ~200-300 мс
onFocus={loadEditor}
onClick={() => setOpen(true)}
>
Ответить
</button>Зазор между hover и кликом — несколько сотен миллисекунд, на приличном соединении это зачастую вся загрузка чанка: вспышка fallback превращается в несобытие. Идея обобщается: прогревать чанки роутов, когда ссылка входит во viewport (IntersectionObserver), после первой отрисовки — для самого вероятного следующего роута, или через requestIdleCallback для низкоприоритетного прогрева. Браузерный эквивалент — <link rel="modulepreload"> для статически известных чанков. Компромисс: прогрев тратит трафик на предсказания — пере-прогрев на лимитированном мобильном соединении заново собирает монолит по одному спекулятивному чанку, поэтому привязывайте его к настоящим сигналам намерения (hover, focus, viewport), а не грейте всё подряд.
SSR и цикл измерения
Две эксплуатационные заметки закрывают тему. Серверный рендеринг: на сервере нет «потом» — классический React.lazy исторически не работал в SSR, отчего экосистема вырастила loadable-components; современный стриминговый SSR React 18+ плюс роутовый сплиттинг на уровне фреймворка решают это за вас, но ограничение гидратации остаётся: клиент должен скачать тот же чанк, прежде чем сможет гидратировать поддерево, так что медленный чанк — это серверный HTML, который видим, но мёртв; проектируйте fallback так, чтобы пользователи не кликали в неживой UI. Измерение: сплиттинг — игра в байты, и проверять его надо в байтах. Анализатор бандла (rollup-plugin-visualizer, webpack-bundle-analyzer) показывает, действительно ли разрез вынес библиотеку из входного чанка — один случайный статический import редактора где угодно во входном графе тихо склеивает чанк обратно, и ловится эта регрессия именно в анализаторе. Отслеживайте размер входного чанка в CI: эта метрика деградирует по одному невинному импорту за раз.
После перевода markdown-редактора на React.lazy размер входного бандла не изменился вовсе. Код lazy() корректен, редактор рендерится нормально. Какова самая вероятная причина?
- 01Проследите всё, что происходит между первым рендером React.lazy-компонента и появлением его контента, и объясните, чем отличается второй рендер.
- 02Команда хочет агрессивно лениво грузить всё после инцидента с размером бандла. Дайте рамку решения: где сплиттинг окупается, где вредит, и две ловушки для проверки на ревью.
Code splitting атакует цену, которую пользователь платит ещё до запуска вашего кода: скачивание плюс парсинг плюс исполнение, где 300 КБ gzip — это примерно мегабайт JavaScript, который CPU телефона должен скомпилировать. React.lazy — адаптер со стороны рендеринга: он оборачивает динамический импорт — превращённый бандлером в отдельно скачиваемый чанк — в компонент, который при первом рендере приостанавливается, показывает fallback ближайшего Suspense и рендерит default export модуля после загрузки и выполнения чанка; дальше это обычный синхронный компонент. Объявляйте ленивые компоненты на верхнем уровне модуля (инлайновый lazy() — новый тип на каждый рендер: ремаунт, потерянный стейт) и переотображайте именованные экспорты в default в загрузчике. Ставьте границы как сетевые рёбра, которыми они и являются: сначала роуты — именно это сняло чекаут из истории с 612 до 240 КБ и с 6,8 до 2,1 с, — затем компоненты, тяжёлые и условные по данным анализатора, и никогда — мелкие виджеты, где запрос и вспышка fallback стоят дороже байтов. Водопад — фирменный отказ: lazy, вложенный в lazy, сериализует обнаружение чанков, каждый уровень — полный round trip, невидимый на офисном вайфае и лестница на 1,4 с при RTT 200 мс — держите границы параллельными соседями. Остаточную задержку прячьте прогревом по намерению: общий import()-загрузчик на hover, focus или входе во viewport греет кеш модулей за несколько сотен миллисекунд до клика, часто резолвя промис до всякой приостановки. На сервере стриминговый SSR и роутеры фреймворков берут сплиттинг на себя, но гидратация всё равно ждёт клиентский чанк — видимый-но-мёртвый UI и есть ловушка. И проверяйте в байтах: один случайный статический импорт тихо склеивает чанк, так что анализатор и CI-бюджет на входной чанк — часть фичи. Теперь, когда откроешь анализатор бандла и увидишь, что входной чанк снова вырос, будешь знать точно, что искать — и какой вопрос задать к PR, который это вызвал.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.