open atlas
↑ К треку
React с нуля до senior RCT · 05 · 04

Code splitting через React.lazy: грузим экран, а не приложение

React.lazy превращает динамический импорт в компонент, приостановленный до загрузки чанка. Режьте сначала по роутам, затем по тяжёлым компонентам за кликом; грейте по hover/viewport; не вкладывайте lazy в lazy — каждый уровень это полный round trip. Меряйте чанки, а не ощущения.

RCT Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Ревью конверсии чекаута вытащило странное число: мобильные пользователи на 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() корректен, редактор рендерится нормально. Какова самая вероятная причина?

Вспомните перед уходом
  1. 01
    Проследите всё, что происходит между первым рендером React.lazy-компонента и появлением его контента, и объясните, чем отличается второй рендер.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.