Головна / Статті

Завантаження, очікування, збої: як працюють межі ліньки, напруги та помилок

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

Видавець: REACTAPP.TOP9 хв читання1968 слів

Джерела: React Suspense, Lazy Loading & Error Boundaries — A Practical Guide

Екран у React може перебувати у трьох незручних станах: його код не завантажений, контент ще не готовий, або виникла помилка під час відображення. React.lazy відкладає завантаження коду, Suspense відображає замінник, поки щось очікується, а межа помилки замінює зламану піддеревину на альтернативний варіант. Як тільки ви зрозумієте, як вони взаємодіють між собою, ви зможете розумно розділяти пакети та розміщувати межі, які зберігають придатність решти сторінки.

Відкладення завантаження коду за допомогою 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

Існують два практичні правила. Оголошуйте ледачі компоненти на верхньому рівні модуля, ніколи всередині іншого компонента, адже кожне оновлення створює новий тип компонента та скидає його стан. Крім того, оскільки дані надходять асинхронно, хтось має вирішувати, що буде показано користувачеві тим часом. Це завдання Suspense.

Відображення замінника за допомогою Suspense

Suspense відображає замінник, поки будь-який нащадок перебуває у стані очікування, тобто ще не може бути відтворений. Обгорніть ледачий компонент та передайте елемент-замінник:

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

Одна межа для всього додатку рідко є бажаною. Межі можна вкладати одну в іншу, і кожен компонент у стані очікування опиняється під контролем найближчої межі над ним:

<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]

Обробка помилок за допомогою Error Boundaries

Очікування — це один із результатів; помилка — інший. Припустимо, компонент кидає помилку під час відображення:

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

Потім він шукає вгору найближчу межу типу Error Boundary:

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