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 и работает в связке с приостановкой рендеринга
Code-splitting: lazy и Suspense

0

1

Войти