Загрузка, ожидание, сбои: как функции lazy, Suspense и Error Boundaries работают вместе
Понять, как React.lazy, Suspense и границы обработки ошибок разделяют код, отображают альтернативные варианты и предотвращают сбои, а также где разместить каждую из этих границ для улучшения пользовательского опыта.
Источники: React Suspense, Lazy Loading & Error Boundaries — A Practical Guide
Экран в React может находиться в трёх некомфортных состояниях: код не загружен, контент ещё не готов или процесс отрисовки завершился с ошибкой. React.lazy откладывает загрузку кода, Suspense отображает заменитель до тех пор, пока что-то не будет готово, а элемент Error Boundary заменяет сломавшуюся часть дерева на альтернативный вариант. Как только вы поймёте, как они взаимодействуют друг с другом, вы сможете разумно делить бандлы и расставлять такие границы, которые сохранят работоспособность остальной части страницы.
Откладывание загрузки кода с помощью React.lazy
Режим отложенной загрузки означает, что код компонента загружается только тогда, когда React впервые нуждается в его отрисовке, а не как часть начального пакета. Разница видна в операции импорта: статический import Dashboard from "./Dashboard" сразу загружает модуль, тогда как использование lazy() для динамического импорта позволяет инструменту сборки выделять отдельный чанк. (В примере второй импорт показан только для сравнения.)
import { lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
Instead of:
import Dashboard from "./Dashboard";
Что даёт режим отложенной загрузки
Представьте приложение с несколькими отдельными разделами:
Application
├── Home
├── Products
├── Dashboard
├── Analytics
├── Admin
└── Payment
Пользователи, попадающие на главную страницу, пока не нуждаются в этих разделах:
Analytics
Admin
Payment
Их загрузка всё равно увеличивает размер начального файла для скачивания. Благодаря отложенной загрузке остальные чанки загружаются по мере необходимости:
Initial Load
↓
Home + common code
↓
User opens Dashboard
↓
Download Dashboard chunk
↓
Render Dashboard
Как работает отложенная загрузка внутри
lazy() принимает функцию, возвращающую обещание, которое почти всегда представляет собой динамический вызов import(). Целевой модуль должен иметь значение по умолчанию в виде компонента. Последовательность действий выглядит следующим образом (диаграмма носит концептуальный характер, а не является кодом):
const Dashboard = lazy(() => import("./Dashboard"));
Conceptually:
lazy()
↓
dynamic import()
↓
JavaScript chunk requested
↓
Promise pending
↓
Component becomes available
↓
React renders component
Существуют два практических правила. Компоненты с режимом lazy следует объявлять на верхнем уровне модуля, никогда не внутри другого компонента, иначе при каждом отрисовывании будет создаваться новый тип компонента с сбрасываемым состоянием. Поскольку данные приходят асинхронно, кто-то должен решать, что будет отображаться пользователю в это время. Именно для этого и существует Suspense.
Отображение заменителя с использованием Suspense
Suspense отображает заменитель, пока какой-либо из потомков находится в режиме ожидания, то есть пока он не может быть отрисован. Необходимо обернуть компонент с режимом lazy и передать элемент-заменитель:
import { lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<p>Loading dashboard...</p>}>
<Dashboard />
</Suspense>
);
}
Пока загружается блок данных, React находит ближайшую границу и отображает заменительный контент:
Dashboard
↓
Not ready
↓
Suspense
↓
"Loading dashboard..."
Когда обещание выполнено, React снова пытается отрисовать элемент, и настоящий контент заменяет заменительный:
Dashboard loaded
↓
React retries rendering
↓
Dashboard displayed
Suspense не отвечает за процесс загрузки; lazy() (или библиотека с поддержкой Suspense) инициирует эту работу. Suspense лишь указывает, где может появиться состояние ожидания и каким оно будет выглядеть.
Использование lazy и Suspense вместе
В большинстве реальных проектов эти инструменты используются вместе, причем часто с более развитым заменительным контентом, чем просто строка текста:
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
);
}
Полный цикл жизни первого посещения, снова в виде диаграммы:
User opens Dashboard
↓
React tries to render Dashboard
↓
Dashboard chunk isn't available
↓
Dynamic import starts
↓
Suspense fallback shown
↓
Dashboard chunk loads
↓
React retries rendering
↓
Dashboard displayed
Скелетные элементы вместо индикаторов загрузки
Простой текстовый заменитель работает, но он ничего не сообщает пользователю о том, что будет показано далее:
<Suspense fallback={<p>Loading...</p>}>
Как правило, лучшим выбором является скелетный компонент, имитирующий окончательную разметку:
<Suspense fallback={<DashboardSkeleton />}>
Он занимает примерно нужное правое пространство, снижая смещение элементов интерфейса и делая ожидание кажущимся более коротким:
┌─────────────────────────────┐
│ Dashboard │
├─────────────────────────────┤
│ ░░░░░░░ ░░░░░░░ │
│ ░░░░░░░ ░░░░░░░ │
│ │
│ ░░░░░░░░░░░░░░░░░░ │
└─────────────────────────────┘
Вложенные границы ожидания
Одна граница для всего приложения редко бывает желательной. Границы могут быть вложенными, и каждый компонент, находящийся в состоянии ожидания, обрабатывается ближайшей к нему верхней границей:
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<ProductsSkeleton />}>
<Products />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</Suspense>
Вместо того чтобы вся страница превращалась в одно сообщение об загрузке,
Entire page → Loading...
каждый раздел может отображаться независимо:
Header ✅
Products Loading...
[Product Skeleton]
Recommendations Loading...
[Recommendation Skeleton]
Обработка сбоев с помощью границ ошибок
Ожидание — это один из возможных исходов; сбой — другой. Предположим, что компонент выбрасывает исключение во время отрисовки:
function Dashboard() {
throw new Error("Dashboard crashed!");
}
Необработанная ошибка отрисовки приводит к демонтированию всего корня React, в результате чего появляется пустая страница. Граница ошибок — это компонент, который перехватывает ошибки, возникающие у его потомков, и вместо этого отображает альтернативный интерфейс:
Dashboard
↓
Rendering Error
↓
Error Boundary
↓
Fallback UI
Использование такой границы ошибок похоже на обертку любого другого компонента, и пользователь видит сообщение «Что-то пошло не так», а не пустой экран:
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
Написание границы ошибок
Границы ошибок по-прежнему являются классовыми компонентами; в самом React нет эквивалента для хуков. Важны два метода: статический метод getDerivedStateFromError выполняется во время отрисовки и возвращает состояние, переключающее на альтернативный интерфейс. Метод componentDidCatch вызывается позже вместе с информацией об ошибке и объектом info.componentStack, который используется для логирования или отправки отчетов.
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = {
hasError: false,
};
}
static getDerivedStateFromError() {
return {
hasError: true,
};
}
componentDidCatch(error, info) {
console.error(error);
console.error(info.componentStack);
}
render() {
if (this.state.hasError) {
return <h2>Something went wrong.</h2>;
}
return this.props.children;
}
}
Его используют точно так же, как и в предыдущем примере:
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
В этой минимальной версии отсутствует функция сброса; кнопка «Попробовать снова» должна установить значение hasError обратно в false.
Как React находит границу
Определяется граница вокруг компонента профиля,
<ErrorBoundary>
<Profile />
</ErrorBoundary>
где компонент выбрасывает ошибку:
function Profile() {
throw new Error("Profile failed");
}
React отрисовывает элементы дерева до момента возникновения ошибки:
ErrorBoundary
↓
Profile
↓
💥 Error
Затем он ищет вверх ближайшую границу обработки ошибок:
Profile
↑
ErrorBoundary
Эта граница переходит в режим обработки ошибок,
{
hasError: true
}
и отрисовывает запасной вариант. Граница не может перехватить собственные ошибки, только ошибки своих дочерних компонентов; ошибка внутри запасного варианта передается следующей границе наверх.
Ошибки, которые может перехватить граница
Границы обрабатывают ошибки, возникающие во время отрисовки React, в методах жизненного цикла и в конструкторах потомковых компонентов. Классическим примером является попытка получить значение свойства у объекта undefined во время отрисовки:
function Profile() {
const user = undefined;
return <h1>{user.name}</h1>;
}
Они также перехватывают ошибки, возникающие при отклоненном задержанном импорте. Если, например, часть кода не удается загрузить после того, как развертывание удалило старый файл, ошибка появляется в ближайшей границе обработки ошибок, а не в элементе Suspense.
Ошибки, которые не фиксируются границами
Часто задают на собеседованиях: границы не видят ошибок в обработчиках событий, поскольку они выполняются вне процесса отрисовки.
function Button() {
const handleClick = () => {
throw new Error("Click failed");
};
return (
<button onClick={handleClick}>
Click
</button>
);
}
Обрабатывайте такие ситуации там, где они возникают, с помощью обычной обработки ошибок внутри соответствующих обработчиков:
try {
// operation
} catch (error) {
// handle error
}
То же самое касается асинхронных обратных вызовов, таких как таймеры или цепочки обещаний, которые выбрасывают ошибки после завершения отрисовки:
setTimeout(() => {
throw new Error("Async error");
}, 1000);
Если возникает ошибка, требующая использования запасного варианта границы, необходимо поймать её и сохранить в состоянии, а затем выбросить при следующей отрисовке.
Сочетание всех трёх элементов
В производственных решениях объект Suspense оборачивается в границу обработки ошибок:
<ErrorBoundary>
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
</ErrorBoundary>
При ленивом определении панели управления,
const Dashboard = lazy(() => import("./Dashboard"));
совокупный алгоритм охватывает все возможные сценарии. Первый вариант развития событий можно интерпретировать как «код отсутствует»: в этом случае React начинает импорт и отображает шаблон-костяк; после этого происходит либо успешная отрисовка, либо переход к границе обработки ошибок.
Dashboard
│
▼
Need the code?
│
┌────────┴────────┐
│ │
YES NO
│ │
Render normally dynamic import()
│
Pending
│
▼
Suspense
│
▼
Skeleton UI
│
Loaded
│
▼
Render again
│
┌───────┴───────┐
│ │
Success Error
│ │
▼ ▼
Dashboard Error Boundary
│
▼
Error UI
Разделение по маршрутам
Маршруты являются наиболее естественными точками разделения, поскольку каждая страница представляет собой самодостаточный экран. Каждую страницу следует объявлять лениво:
const Home = lazy(() => import("./pages/Home"));
const Products = lazy(() => import("./pages/Products"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
затем необходимо обернуть роутер в границу обработки ошибок, чтобы при навигации на незагруженную страницу отображался индикатор загрузки:
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
Что не стоит разделять
Задержанная загрузка небольшого компонента имеет обратный эффект:
const Button = lazy(() => import("./Button"));
Каждый компонент, загружаемый с задержкой, требует запроса и создания состояния загрузки, чего никогда не окупает кнопка. Хорошими кандидатами являются большие панели управления, разделы аналитики и администрирования, редакторы разметки, карты, сложные диаграммы или библиотеки сторонних разработчиков, процессы оплаты и редко посещаемые маршруты. Необходимо разделять код, который требует много ресурсов или используется редко, а не всё подряд.
Целенаправленное размещение границ
Размещение элементов отложенной загрузки определяет, что исчезнет
Граница, расположенная высоко в иерархии, означает, что любой медленный компонент заменит большую область своим альтернативным вариантом:
<Suspense fallback={<FullPageLoader />}>
<EntireApplication />
</Suspense>
Если границу разместить вокруг медленной части, заголовок и футер останутся видимыми и функциональными во время загрузки:
<Header />
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
<Footer />
Размещение границы обработки ошибок определяет, что сохранится
Та же логика применима и к сбоям. Одна граница на уровне корня превращает любую ошибку в экран с ошибкой на всей странице:
<ErrorBoundary>
<EntireApplication />
</ErrorBoundary>
Изоляция независимых разделов позволяет ограничить влияние сбоя конкретной областью:
<App>
<Header />
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
<ErrorBoundary>
<Recommendations />
</ErrorBoundary>
<Footer />
</App>
Если рекомендации не сработают, весь остальной функционал будет продолжать работать:
Header ✅
Dashboard ✅
Recommendations ❌
Footer ✅
Граница на уровне корня остается полезной в качестве крайней меры вокруг более детальных границ.
Ментальная модель в одном изображении
Lazy Loading
│
▼
Code needed?
│
▼
Suspense
│
┌──────┴──────┐
│ │
Loading Ready
│ │
Fallback ▼
Render
│
┌─────┴─────┐
│ │
Success Error
│ │
▼ ▼
UI Error Boundary
│
▼
Error UI
Основные выводы
React.lazyс динамическимimport()разделяет код на части, загружаемые при первой отрисовке; модуль должен иметь значение по умолчанию.Suspenseне загружает ничего; он определяет, где будет отображаться запасной вариант, пока потомки находятся в состоянии ожидания.- Границы ошибок ловят ошибки отрисовки, жизненного цикла и конструктора, включая неудачные загрузки с использованием
lazy, но не ошибки обработчиков событий или более поздние асинхронные ошибки.
Suspense не обрабатывает ошибки, поэтому его следует использовать вместе с Error Boundary.