React
Code-splitting: lazy и Suspense
Пользователь открывает приложение, чтобы посмотреть одну страницу, а браузер сначала скачивает мегабайты JavaScript - включая код админ-панели, редактора графиков и экспорта в PDF, которые он, возможно, не откроет никогда. Первый экран появляется на три секунды позже, чем мог бы. Решение придумали давно: грузить не всё сразу, а по требованию. В React это две связанные конструкции - React.lazy и Suspense, которые превращают огромный монолитный бандл в набор частей, подгружаемых ровно тогда, когда нужны.
- Любой крупный SPA: маршруты грузятся отдельными чанками, и открытие профиля не тянет код страницы настроек
- Редакторы и дашборды: тяжёлые модули (графики, карты, WYSIWYG) подгружаются только при первом обращении
- Next.js и подобные фреймворки: автоматический сплит по маршрутам встроен, dynamic import для тяжёлых клиентских компонентов
- Интернет-магазины: код оформления заказа не грузится, пока пользователь листает каталог
- Мобильный веб: уменьшение начального бандла напрямую улучшает метрики загрузки на медленных сетях
Предварительные знания
- Понимание, что сборщик собирает приложение в один или несколько файлов JavaScript (бандл)
- Динамический import() в JavaScript: возвращает промис с модулем
- Модель рендеринга React: компонент возвращает описание UI, которое React превращает в DOM
Как code-splitting пришёл в React
Динамический import() появился в стандарте ECMAScript и сразу дал сборщикам вроде Webpack возможность резать бандл на куски, загружаемые по требованию. Но самому React нужен был способ дождаться загрузки и показать что-то на это время. В React 16.6 (октябрь 2018) вышли React.lazy и Suspense для клиентского code-splitting: lazy оборачивает динамический import компонента, а Suspense объявляет границу, где показывается fallback, пока компонент грузится. Изначально Suspense умел только это. Позже, с приходом конкурентной модели и Server Components, та же концепция приостановки расширилась на ожидание данных, но базовый сценарий - ленивая загрузка кода - остался самым частым применением.
Зачем резать бандл
По умолчанию сборщик собирает весь код приложения в один бандл. Браузер должен скачать и распарсить его целиком, прежде чем покажет первый экран. Чем больше приложение, тем дольше эта пауза - и пользователь платит за код, который ему сейчас не нужен. Code-splitting разбивает бандл на части (чанки) и грузит их по требованию, оставляя в начальной загрузке только то, что нужно для первого экрана.
- Один бандл — Весь код в одном файле. Браузер качает всё сразу, включая страницы, которые пользователь может не открыть. Долгий первый экран.
- Разбитый на чанки — Код первого экрана грузится сразу, остальное - по требованию. Меньше начальной загрузки, быстрее первая отрисовка.
Самая выгодная граница для сплита - маршрут. Пользователь почти всегда видит за раз одну страницу, поэтому логично, чтобы код страницы настроек не попадал в загрузку, пока пользователь сидит в каталоге. Вторая частая граница - тяжёлые модули: редактор кода, библиотека графиков, экспорт в PDF. Они большие и нужны редко, так что им самое место в отдельном чанке.
Выигрыш не абстрактный. Уменьшение начального бандла напрямую улучшает метрики загрузки - время до интерактивности и до первой отрисовки контента. На медленных мобильных сетях разница между загрузкой 2 МБ и 400 КБ ощущается как разница между 'тормозит' и 'мгновенно'.
Почему сплит на уровне маршрутов обычно даёт наибольший выигрыш?
React.lazy и Suspense в связке
React.lazy принимает функцию, которая возвращает динамический import() компонента, и отдаёт компонент, чей код подгрузится только при первом рендере. Поскольку загрузка асинхронна, React нужно знать, что показать на это время. Эту роль берёт Suspense: он объявляет границу, и пока ленивый компонент внутри не готов, отображается его fallback.
Механика такая. При первом рендере Dashboard его код ещё не загружен - React 'приостанавливает' рендер этого поддерева и показывает fallback ближайшей границы Suspense. Когда чанк подгрузился, React возобновляет рендер и заменяет fallback на реальный компонент. Дальше код уже в памяти, и повторные показы Dashboard мгновенны.
Динамический import() должен указывать на модуль с дефолтным экспортом компонента - именно его React.lazy ожидает увидеть. Если компонент экспортируется именованно, оборачивают import так, чтобы вернуть объект с полем default.
Что происходит при первом рендере компонента, обёрнутого в React.lazy, если его чанк ещё не загружен?
Границы на практике
Главное практическое решение - где ставить границы Suspense. Это компромисс. Одна крупная граница вокруг всего приложения проста, но при загрузке любой части показывает один большой fallback и прячет весь экран. Множество мелких границ дают точечные плейсхолдеры, но при одновременной загрузке нескольких частей экран мельтешит спиннерами. Хорошая практика - граница на уровне маршрута плюс отдельные границы вокруг действительно тяжёлых независимых блоков.
- Граница на маршрут: каждый экран грузится своим чанком и имеет свой плейсхолдер-скелет
- Отдельная граница вокруг тяжёлого виджета (график, карта), чтобы остальная страница не ждала его
- fallback лучше делать скелетом разметки, а не голым спиннером - меньше скачка макета при появлении контента
- Не дробить слишком мелко: десяток границ на экране превращается в кашу из спиннеров
Загрузка чанка - сетевая операция, и она может упасть: пользователь офлайн, сервер отдал 404 после деплоя, оборвалось соединение. Suspense сам по себе не обрабатывает ошибки загрузки. Для этого рядом ставят error boundary, которая ловит сбой и показывает запасной UI с возможностью повтора.
Во фреймворках вроде Next.js App Router многое из этого уже встроено: сплит по маршрутам автоматический, а специальные файлы loading и error играют роль fallback Suspense и границы ошибок. Но под капотом это те же React.lazy, Suspense и error boundary - понимание базовой механики помогает читать и настраивать поведение фреймворка.
Динамический import чанка иногда падает с сетевой ошибкой после деплоя. Как корректно обработать этот случай?
Связь с другими темами
Этот урок про загрузку кода. Рядом стоят темы об ошибках и конкурентности:
- Error boundaries — Загрузка чанка может упасть, и error boundary ловит этот сбой, показывая запасной UI рядом с Suspense
- Конкурентный рендеринг — Suspense - часть конкурентной модели, где React умеет приостанавливать и возобновлять рендер поддерева
- Модель рендеринга — Объясняет, что значит 'приостановить рендер поддерева' и почему fallback показывается только в границе Suspense
Итог
- React.lazy оборачивает динамический import() компонента, превращая его в отдельный чанк, загружаемый по требованию
- Suspense объявляет границу с fallback, который показывается, пока ленивый компонент или его данные не готовы
- Сплит на уровне маршрутов - самый частый и эффективный приём: каждый экран грузится своим чанком
- Цель - уменьшить начальный бандл, чтобы первый экран появлялся быстрее, а редко используемый код грузился позже
- Границу Suspense стоит ставить осознанно: слишком крупная даёт большой пустой fallback, слишком мелкая - мельтешение спиннеров
Связанные уроки
- rc-10-render-mental-model — Чтобы понять, что Suspense приостанавливает рендер поддерева, нужна модель того, как React рендерит дерево
- rc-22-error-boundaries — lazy-загрузка может упасть с ошибкой сети, и error boundary - стандартная пара к Suspense для обработки сбоев
- rc-24-concurrent-intro — Suspense - часть конкурентной модели React и работает в связке с приостановкой рендеринга