Главная / Статьи

Загрузка, ожидание, сбои: как функции lazy, Suspense и Error Boundaries работают вместе

Понять, как React.lazy, Suspense и границы обработки ошибок разделяют код, отображают альтернативные варианты и предотвращают сбои, а также где разместить каждую из этих границ для улучшения пользовательского опыта.

Издатель: REACTAPP.TOP9 мин чтения1968 слов

Источники: 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.
  • Разделяйте структуру на уровне маршрутов и вокруг тяжелых, редко используемых функций, а не мелких компонентов.
  • Местоположение Error Boundary определяется с учетом UX: размещайте оба типа вокруг значимых, независимых разделов.