Чому React.lazy() потребує значення за замовчуванням та як завантажувати експорти з іменами
Зрозумійте, що отримує React.lazy() від динамічного імпорту, чому іменовані експорти його порушують, як перенаправити їх на значення за замовчуванням, та як у це вписуються Suspense та розділення коду.
Джерела: 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")
Це принципово відрізняється від оператора static import:
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() шукає значення default
Дизайн стає зрозумілішим, якщо подивитися, що передається до 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-завантаження має багато іменованих експортів, варто обгорнути цю схему у невеликий спільний хелпер, щоб перенаправлення знаходилося в одному місці. Також треба розміщувати кожен виклик lazy() у межах модуля, а не всередині тіла компонента; його оголошення під час рендеру створює новий ледачий компонент щоразу та скидає його стан.
Чому статичні імпорти ніколи не стикаються з цією проблемою
За допомогою статичного іменованого імпорту ви явно вказуєте, який експорт потрібен:
import { Dashboard } from "./Dashboard";
JavaScript самостійно вирішує питання прив’язки Dashboard. За допомогою стандартного імпорту ви просите саме стандартний експорт:
import Dashboard from "./Dashboard";
Однак ледачий завантажувач нічого не обирає:
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 керує періодом очікування, а default export — це те, що відображається в кінцевому результаті. Ще один важливий момент полягає у тому, що невдале завантаження частини коду призводить до відхилення Promise, а 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()
Замість того, щоб розуміти цей рядок як „повільне завантаження Dashboard“:
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для відображення стану завантаження та додавайте межу помилок для випадків невдалого завантаження частин.