// Типичный ленивый компонент – поверхностное решение
const SomeComponent = lazy(() => import('./SomeComponent'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<SomeComponent />
</Suspense>
);
}
Большинство разработчиков React знакомы с React.lazy
и Suspense
– они позволяют отложить загрузку кода компонента до момента, когда он потребуется. Но на этом история не заканчивается. Глубже скрываются мощные паттерны для управления ресурсами приложения. Рассмотрим проблемы и решения, о которых умалчивают туториалы.
Проблематика: ленивая нагрузка вне React-компонентов
React.lazy
прекрасен для компонентов, но что насчет модулей, вызываемых кодом а не выводящих UI? Представьте:
// AccountSettingsModal.jsx
import { openDynamicModal } from './modalService'; // Загружается в основной бандл!
function AccountSettingsModal() { ... }
export default AccountSettingsModal;
Здесь модуль modalService
попадает в основной бандл, даже если модалку открывают. Нужно отложить загрузку modalService
вместе с компонентом модалки.
Ошибочный подход (и почему он не работает):
const AccountSettingsModal = lazy(() => import('./AccountSettingsModal'));
function showAccountModal() {
// Пытаемся использовать ленивый компонент "вручную"
renderDocument(
<Suspense fallback={null}>
<AccountSettingsModal />
</Suspense>
);
}
Проблемы:
lazy
требует React-дерево сSuspense
– вне контекста рендеринга он беспомощен- Вызов через
renderDocument
создает изолированное дерево – контекст приложения пропадает - Предзагрузка не тривиальна – кеширование Suspense работает непрозрачно
Решение: управление динамическим импортом вручную
Откажемся от React.lazy
в пользу прямого использования динамического импорта и состояния.
// modalPortal.js
let ModalPortal;
let modalLoadPromise = null;
export function loadModalSystem() {
if (!modalLoadPromise) {
modalLoadPromise = import('./ModalPortal').then(module => {
ModalPortal = module.ModalPortal;
});
}
return modalLoadPromise;
}
export function showModal(Component, props) {
loadModalSystem().then(() => {
ModalPortal.open(<Component {...props} />);
});
}
// В точке входа приложения
loadModalSystem(); // Предварительная загрузка "на фоне"
Теперь используем унифицированный портал:
// AccountSettingsModal.jsx - экспорт остается стандартным
export default function AccountSettingsModal({ userId }) { ... }
// Где-то в обработчике клика
import { showModal } from './modalPortal';
import foo from './utils'; // В основной бандл НЕ попадет
function handleSettingsClick() {
foo(); // Вызов обычной функции
showModal(AccountSettingsModal, { userId: 42 }); // Открываем с задержкой
}
Почему это работает лучше:
- Зависимости модалки (
ModalPortal
) загружаются динамически в месте использования - Код модалки и библиотек не попадает в основной бандл
- Предзагрузка запускается раньше, чем пользователь кликает (например, при Idle-времени)
- Работает с любым модулем, не только с React-компонентами
Suspense без React.lazy: для асинхронных данных внутри UI
Предположим, компонент UserDetails
загружает данные при монтировании. Без Suspense:
function UserDetails() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
fetchUser().then(user => {
setData(user);
setIsLoading(false);
});
}, []);
if (isLoading) return <Spinner/>;
if (!data) return null;
return <div>{data.name}</div>;
}
С React.Suspense
и ручным созданием "ресурса":
// userResource.js
let userCache = {};
function fetchUserResource(userId) {
if (!userCache[userId]) {
userCache[userId] = fetchUser(userId).then(user => {
return user;
});
}
// Возвращаем "обещание", но не данные!
return userCache[userId];
}
Компонент принудительно приостанавливается при отсутствии данных:
function UserDetails({ userId }) {
const user = userCache[userId]; // Получаем ссылку на промис
if (!user || user.status === 'pending') {
// "Подбрасываем" промис для Suspense
throw user || fetchUserResource(userId);
}
if (user.status === 'error') throw user.error;
return <div>{user.data.name}</div>;
}
// Используем
function App() {
return (
<Suspense fallback={<SkeletonProfile/>}>
<UserDetails userId="abc123" />
</Suspense>
);
}
Ключевые моменты реализации:
- Ресурсы кешируются на уровне приложения, а не компонента
- Suspense перехватывает выброшенный промис и управляет отрисовкой фолбэка
- Ошибки обрабатываются через ErrorBoundaries
Предзагрузка динамических модулей: стратегии и тактики
Наивная предзагрузка:
// Просто запускаем импорт в пустоту
const preload = () => import('./HeavyChartComponent');
Проблема: Полная загрузка, парсинг и компиляция кода в основном потоке во время взаимодействий. Используем link rel="preload"
:
const preloadModule = (path) => {
const link = document.createElement('link');
link.rel = 'modulepreload';
link.href = path;
document.head.appendChild(link);
};
preloadModule('/static/js/HeavyChartComponent.js');
При фактическом вызове import()
браузер повторно использовать уже загруженный (но не исполненный) модуль.
Упреждающая загрузка при гидратации:
function App() {
const [routerReady, setRouterReady] = useState(false);
useEffect(() => {
const onRouteChange = (path) => {
preloadModuleForRoute(path); // Карта прелоада на основе маршрута
};
router.listen(onRouteChange);
setRouterReady(true);
}, []);
if (!routerReady) return null;
return (
<Router>
{/* Компоненты маршрутов */}
</Router>
);
}
Именованные экспорты и динамика
lazy()
работает только с дефолтными экспортами. Для именованных явно указываем:
const MainChart = lazy(() =>
import('./AnalyticsCharts')
.then(module => ({ default: module.BarChart }))
);
Фейловые системы автоматизации:
// dynamicImport.js
export function dynamicImport(path, exportName = 'default') {
return import(/* webpackChunkName: "[request]" */ `../${path}`)
.then(module => ({
default: exportName === 'default' ? module.default : module[exportName]
}));
}
// Использование
const AdvancedTable = lazy(() => dynamicImport('tables/Advanced', 'AdvancedTable'));
Когда бить тревогу: антипаттерны динамики
Не злоупотребляем без необходимости:
// Потенциально проблемное решение
const Button = lazy(() => import('./ui/Button'));
Почти наверняка Button
требуется везде – эффективнее включить его в основной бандл.
Метрики для принятия решения:
- Количество использований компонента
- Размер модуля (меньше 5KB? Не заморачиваемся)
- Критичность для первого контента
- Возможность изменения независимо от основного кода
Собирая практики воедино
Динамическое разделение кода – важнее чем хитрое сжатие. Ключевые практики:
- Разделяйте на границах функциональных зон (маршруты, сложные виджеты, административные панели)
- Откладывайте библиотеки с большими polyfills (все, что содержит WASM, граф память >200KB)
- Кешируйте ручные ресурсы – избегайте дублирования загрузок
- Контролируйте контекст Suspense – изолируйте фолбэки для потоков данных
- Анализируйте темную материю – источник проблем в скрытых зависимостях динамически загружаемых модулей
Правильно внедренная стратегия ленивой загрузки сокращает время до первой цифровой отрисовки (FCP) на 30-80% для корпоративных SPA. Реальные выгоды – серийные пользователи не теряют время на повторные загрузки того же кода. Архитектура модулей становится управляемым активом, а не побочным продуктом.