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

Почему React.lazy() требует значение по умолчанию и как загружать экспорты с именами

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

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

Источники: React.lazy() Looks Simple Until the Export Is Wrong

React.lazy() — один из самых кратких API в React: он оборачивает динамический импорт, отображает результат внутри Suspense, и бандлер разделяет этот компонент на отдельный чанк. Проблема заключается в условии, скрытом за синтаксисом: импортируемый модуль должен выдавать компонент в качестве своего значимого экспорта. Если изменить файл на импорт с указанием имени, приложение выдаст ошибку, возникшую глубоко внутри React, которая не указывает на реальную причину. В этой статье объясняется, какое значение на самом деле передается через lazy(), почему импорты с указанием имени нарушают его работу, два способа решения проблемы и привычка отладки, полезная не только для этого API.

Типичный вызов выглядит так:

const Dashboard = lazy(() => import("./Dashboard"));

Ничто в этом не кажется рискованным, и именно поэтому сбой становится запутанным, когда он происходит.

Удачный сценарий: значимый экспорт

Начните с файла Dashboard.jsx, в котором определяется компонент и он экспортируется как значение по умолчанию.

const Dashboard = () => {
  return <div>Dashboard</div>;
};
export default Dashboard;

В файле, использующем этот компонент, импортируйте функцию lazy из библиотеки React и укажите на соответствующий модуль.

import { lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard"));

Чаще всего никто не задумывается о том, что происходит между этими двумя строками; React просто загружает компонент при его первой отрисовке. Интересной является выражение внутри функции-стрелки.

import("./Dashboard")

Это принципиально отличается от оператора статического импорта:

import Dashboard from "./Dashboard";

Первый вариант представляет собой динамический импорт, и результатом динамического импорта является объект типа Promise. Ожидание его позволяет лучше представить получаемый результат:

const module = await import("./Dashboard");

Возвращается не компонент Dashboard. Это объект пространства имён модуля, у которого свойства представляют собой экспорты модуля. Поскольку файл заканчивается этой строкой:

export default Dashboard;

результирующий модуль имеет примерно такую структуру:

{
  default: Dashboard
}

Именно такую структуру и предназначен к обработке функционал React.lazy(). Одно небольшое изменение, которое нарушает это

Теперь предположим, что команда предпочитает именованные экспорты. Ранее файл выглядел так:

const Dashboard = () => {
  return <div>Dashboard</div>;
};
export default Dashboard;

Он переписывается так, чтобы экспортировать компонент по имени:

export const Dashboard = () => {
  return <div>Dashboard</div>;
};

Вызов функции lazy остаётся абсолютно прежним:

const Dashboard = lazy(() => import("./Dashboard"));

Всё выглядит нормально. Компонент существует, файл существует, и путь верный. Обычный статический импорт того же файла будет работать без проблем:

import { Dashboard } from "./Dashboard";

Изменилась структура объекта модуля.

Сравнение двух структур модулей

Экспорт по умолчанию, написанный вот так:

export default Dashboard;

приводит к модулю, который выглядит примерно так:

{
  default: Dashboard
}

Именованный экспорт, написанный вот так:

export const Dashboard = ...

приводит к модулю с такой структурой вместо этого:

{
  Dashboard: Dashboard
}

React.lazy() читает компонент из одного определенного места:

module.default

Он никогда не смотрит на именованное свойство:

module.Dashboard

Следовательно, сами именованные экспорты не являются ошибкой. Проблема заключается исключительно в том, что модуль больше не соответствует структуре, от которой зависит lazy(). Во время разработки React обычно сообщает, что элемент lazy привел к undefined или что тип элемента недействителен, что описывает симптомы, а не отсутствие ключа default.

Почему lazy() ищет значение по умолчанию

Дизайн становится более понятным, когда вы посмотрите, что передаётся в lazy().

lazy(() => import("./Dashboard"))

React здесь не получает компонент. Он получает функцию загрузчика, которая возвращает Promise, который в конечном итоге разрешается до модуля. Затем React берёт свойство default этого модуля и рассматривает его как компонент. Цепочка выглядит так:

lazy()
  ↓
loader function
  ↓
import("./Dashboard")
  ↓
Promise
  ↓
module
  ↓
module.default
  ↓
React component

Когда модуль содержит элемент default, у React есть всё необходимое:

{
  default: Dashboard
}

Когда модуль содержит только элемент с именем, у React нет значения default, которое можно было бы прочитать:

{
  Dashboard: Dashboard
}

Именно здесь происходит несоответствие. Чтение значения default — это разумная конвенция: это единственное имя экспорта, по которому все модули могут договориться, без необходимости для React знать что-либо о вашем номенклатурном формате.

Решение первое: перейти на стандартный экспорт

Если компонент является основным содержимым файла, самое простое решение — экспортировать его в качестве стандартного.

const Dashboard = () => {
  return <div>Dashboard</div>;
};
export default Dashboard;

После этого первоначальный вызов будет работать так, как предусмотрено:

const Dashboard = lazy(() => import("./Dashboard"));

Для файлов, содержащих один основной компонент, это является чистым и стандартным решением.

Решение второе: сохранить именованный экспорт и перенаправить его

Иногда изменение способа экспорта невозможно или не желательно, например, когда кодбаза требует использования именованных экспортов по правилам линт-инструмента или когда файл экспортирует несколько компонентов. Именованная версия может остаться прежней:

export const Dashboard = () => {
  return <div>Dashboard</div>;
};

Вы всё ещё можете использовать lazy(); вам просто нужно указать, какой экспорт считать по умолчанию. Загрузчик добавляет метод .then() к динамическому импорту и возвращает новый объект, в котором module.Dashboard находится под ключом default.

const Dashboard = lazy(() =>
  import("./Dashboard").then((module) => ({
    default: module.Dashboard,
  }))
);

В первый раз это может показаться странным, но логика проста:

Load the module
      ↓
Get the Dashboard export
      ↓
Put it under "default"
      ↓
Give it back to React.lazy()

Другими словами, этот объект:

{
  Dashboard: Dashboard
}

преобразуется в этот перед тем, как он попадёт к React:

{
  default: Dashboard
}

Теперь React.lazy() получает именно тот формат данных, который он ожидает. Если в проекте используется функция lazy-loading для множества экспортов с именами, целесообразно обернуть эту логику в небольшой общий хелпер, чтобы все правила перенаправления находились в одном месте. Также следует размещать каждый вызов lazy() на уровне модуля, а не внутри тела компонента; объявление такого вызова во время рендеринга приводит к созданию нового ленивого компонента каждый раз и сбросу его состояния.

Почему статические импорты никогда не сталкиваются с этой проблемой

При использовании статического импорта с указанием имени вы явно указываете, какой экспорт вам нужен:

import { Dashboard } from "./Dashboard";

JavaScript самостоятельно определяет связь с Dashboard. При использовании импорта по умолчанию вы запрашиваете именно значение по умолчанию:

import Dashboard from "./Dashboard";

Однако функция lazy-loader ничего не выбирает:

lazy(() => import("./Dashboard"))

Он передаёт React всё объект модуля, и React должен что-то из него выбрать. Именно поэтому параметр default export имеет значение только в режиме lazy.

Как Suspense вписывается в процесс

React.lazy() редко используется без Suspense:

<Suspense fallback={<div>Loading...</div>}>
  <Dashboard />
</Suspense>

Такое сочетание становится понятным, если помнить, что компонент не доступен сразу. Браузеру может потребоваться загрузить отдельный фрагмент JavaScript, прежде чем React сможет отрисовать Dashboard, и в течение этого времени React нуждается в чём-то ещё для отображения. Параметр fallback обеспечивает это. Полная последовательность выглядит следующим образом:

React needs Dashboard
        ↓
Dashboard hasn't been loaded
        ↓
Dynamic import starts
        ↓
Suspense shows fallback
        ↓
JavaScript chunk loads
        ↓
Module resolves
        ↓
React gets module.default
        ↓
Dashboard renders

Если рассматривать это как последовательность, функция lazy() кажется гораздо менее загадочной: состояние Suspense покрывает период ожидания, а элемент, экспортируемый по умолчанию, отображается в конце. Связанным моментом является то, что при неудачном загрузке чанка обещание отклоняется, и Suspense не справляется с таким случаем; для обработки ошибок необходимо использовать границу ошибок вокруг компонента, реализованного с использованием lazy.

Разделение кода — настоящая выгода

Основная причина использования динамических импортов — разделение кода. Без подобного вызова:

lazy(() => import("./Dashboard"))

код панели управления, скорее всего, включался бы в JavaScript, необходимый для запуска приложения. С использованием динамического импорта бандлер может поместить его в отдельный чанк. Концептуально результат представляет собой первоначальный бандл:

Initial bundle
├── React
├── App
└── Home

и чанк, который загружается позже:

Dashboard chunk
└── Dashboard

Код панели управления загружается только тогда, когда пользователь действительно в этом нуждается. Хорошими примерами могут служить:

  • Панели управления администратора
  • Страницы аналитики
  • Экраны настроек
  • Большие редакторы
  • Маршруты, которые пользователи редко посещают
  • Тяжелые компоненты с большим количеством зависимостей

Это не означает, что каждый компонент должен быть загружаемым по мере необходимости. Каждое разделение добавляет сетевой запрос и состояние загрузки, поэтому небольшая кнопка не получает преимуществ от собственного фрагмента кода. Цель не в том, чтобы сделать всё загружаемым по мере необходимости; цель — избавить начальный пакет от кода, который пользователю пока не нужен.

Ошибки из внутренних компонентов React не всегда являются ошибками React

Когда возникает исключение из внутренних компонентов React, возникает соблазн подозревать сам React. В трейсе ошибки могут быть такие строки:

lazyInitializer
resolveLazy
beginWork

Естественным выводом является то, что что-то внутри React сломалось. Прежде чем идти по этому пути, проверьте спецификацию API, к которому вы обращаетесь. Ключевой вопрос заключается в том, что функция React.lazy() ожидает получить от своего загрузчика. В упрощенной форме ответ звучит так:

Promise<{
  default: Component
}>

Сравните это с тем, что на самом деле предоставляет модуль. Если файл содержит следующее:

export const Dashboard = ...

тогда результирующее значение фактически будет таким:

{
  Dashboard: Dashboard
}

Если поставить всё рядом, причина становится очевидной. React не сломан; экспорты модуля и спецификация загрузчика просто не совпадают.

Ментальная модель для понимания вызовов lazy()

Вместо того чтобы воспринимать эту строку как «загрузка панели управления с задержкой»:

const Dashboard = lazy(() => import("./Dashboard"));

полезнее рассматривать её как небольшую цепочку операций:

When Dashboard is needed
        ↓
run the dynamic import
        ↓
get the module
        ↓
take its default export
        ↓
use that as the component

Эта модель позволяет быстро диагностировать подобные проблемы. Она также объясняет, почему первый из следующих файлов работает сразу:

// Dashboard.jsx
export default function Dashboard() {
  return <div>Dashboard</div>;
}

в то время как второй требует дополнительной обработки:

// Dashboard.jsx
export const Dashboard = () => {
  return <div>Dashboard</div>;
};

когда он загружается с помощью этого вызова:

lazy(() => import("./Dashboard"));

Более общий урок отладки

Этот небольшой деталь React несёт в себе более широкий урок. Когда API ведёт себя неожиданно, стоит отойти от написанной вами синтаксиса и изучить значение, которое на самом деле передаётся через API. Такой вызов кажется тривиальным:

lazy(() => import("./Dashboard"))

На самом деле это цепочка преобразований:

dynamic import
      ↓
Promise
      ↓
module object
      ↓
default export
      ↓
React component

Как только эта цепочка становится видимой, понять причину ошибки проще, и этот же подход эффективен во всем JavaScript. Вместо того чтобы спрашивать, почему библиотека выдает ошибку, следует узнать, что именно вы передаёте функции и что она ожидает получить в ответ. Иногда вся разница заключается лишь в названии одного свойства:

default

Основные выводы

  • import() возвращает объект модуля, а не компонент, а React.lazy() загружает компонент из его свойства default.
  • Именованные экспорты работают нормально; их просто необходимо перенаправить с помощью .then((module) => ({ default: module.Name })) при задержанной загрузке.
  • Объявляйте ленивые компоненты в области видимости модуля, оборачивайте их в Suspense для отображения состояния загрузки и добавляйте границу обработки ошибок для случаев неудачной загрузки чанков.
  • Разделяйте тяжелые, редко используемые экраны, а не небольшие компоненты.
  • Когда ошибка возникает внутри библиотеки, сравните передаваемое значение с тем, которое ожидает API-контракт, прежде чем считать это багом библиотеки.