Code splitting и ленивая загрузка
Учимся резать бандл на куски так, чтобы пользователь скачивал код только той страницы, которую реально открыл.
Code splitting — разбиение бандла на несколько файлов (чанков), которые загружаются по мере надобности, а не все сразу при первом заходе.
Зачем это нужно
Представьте интернет-магазин. Есть каталог, есть корзина, есть личный кабинет, есть админка для сотрудников с тяжёлым редактором и графиками. Если всё это лежит в одном бандле, то посетитель, зашедший из поиска на карточку товара, скачивает и админку, и редактор, и библиотеку графиков — код, который он не откроет никогда. Он платит за него временем загрузки и торможением интерфейса.
Code splitting переворачивает логику: грузим критическое сейчас, всё остальное — когда понадобится. Обычно это самая эффективная оптимизация после удаления мёртвых зависимостей: типичное SPA легко теряет 40–60 % начального JS без единого удалённого фичи.
Динамический import(): весь фундамент
Магии тут нет — есть одна конструкция языка. Обычный import статичен: сборщик видит его и вклеивает модуль в бандл. Динамический import() — это функция, которая возвращает промис и сообщает сборщику: «дальше — граница чанка». Всё, что импортируется таким способом, уезжает в отдельный файл и скачивается в момент вызова.
// статический импорт: код попадёт в главный бандл
import { renderChart } from './chart.js';
// динамический импорт: код уедет в отдельный чанк и загрузится по требованию
const { renderChart } = await import('./chart.js');
Это стандарт языка, он работает и без сборщика. Vite, webpack и Rollup просто узнают эту конструкцию и создают под неё отдельный файл.
Разделение по маршрутам
Самая понятная и самая доходная граница — страница. Пользователь физически находится на одном маршруте, значит, код остальных ему сейчас не нужен. В React это lazy() плюс Suspense с запасным содержимым на время загрузки чанка:
import { lazy, Suspense } from 'react';
import { createBrowserRouter } from 'react-router-dom';
import Home from './pages/Home'; // первый экран — статически
import PageSkeleton from './ui/PageSkeleton';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Reports = lazy(() => import('./pages/Reports'));
const Admin = lazy(() => import('./pages/Admin'));
export const router = createBrowserRouter([
{ path: '/', element: <Home /> },
{
path: '/dashboard',
element: (
<Suspense fallback={<PageSkeleton />}>
<Dashboard />
</Suspense>
),
},
{
path: '/admin',
element: (
<Suspense fallback={<PageSkeleton />}>
<Admin />
</Suspense>
),
},
]);
Разберём по строкам. Home импортирован статически — это первый экран, его код нужен немедленно, откладывать нечего. Dashboard, Reports и Admin обёрнуты в lazy(): сборщик сделает из каждого отдельный чанк, а React запросит его при первом переходе на маршрут. Suspense обязателен: пока чанк едет по сети, надо что-то показать. И показывать надо скелет той же формы и высоты, что и будущий контент, иначе после загрузки страница «дёрнется» и вы испортите CLS.
Во фреймворках с файловой маршрутизацией (Next.js, Nuxt, SvelteKit) разделение по маршрутам включено по умолчанию — там ваша задача не сломать его случайным глобальным импортом тяжёлой библиотеки в общий layout.
Разделение по компонентам
Маршруты — крупный помол. Дальше находим внутри страницы то, что тяжёлое и не нужно сразу: модальные окна, редактор форматированного текста, карту, таблицу с экспортом в Excel, библиотеку графиков. Правило простое: если элемент появляется только после действия пользователя — его код должен появляться тогда же.
const openBtn = document.querySelector('#open-editor');
openBtn.addEventListener('click', async () => {
openBtn.disabled = true;
openBtn.textContent = 'Загружаем…';
// ~300 КБ редактора поедут по сети только сейчас
const { mountEditor } = await import('./editor/mount.js');
mountEditor(document.querySelector('#editor-root'));
openBtn.remove();
}, { once: true });
Тот же приём — для блоков ниже первого экрана: подключаем IntersectionObserver и вызываем import(), когда блок приближается к видимой области.
const slot = document.querySelector('#comments');
const io = new IntersectionObserver(async (entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
io.disconnect();
const { mountComments } = await import('./comments/mount.js');
mountComments(slot);
}
}, { rootMargin: '200px' }); // начинаем грузить за 200px до появления
io.observe(slot);
rootMargin: '200px' здесь — важная мелочь: мы начинаем загрузку заранее, чтобы к моменту, когда блок реально доедет до экрана, он уже был готов.
Что грузить сразу, а что по требованию
| Сразу (статический импорт) | По требованию (dynamic import) |
| Разметка и логика первого экрана, LCP-элемент | Модалки, дровера, всплывающие панели |
| Шапка, навигация, роутер | Редакторы, карты, графики, видеоплееры |
| Аналитика критичных событий (и то — лучше отложить) | Страницы, куда переходят меньшинство |
| Обработчики главного действия страницы | Экспорт в PDF/Excel, печать, шаринг |
Ленивая загрузка не должна тормозить
У code splitting есть обратная сторона: ленивый чанк — это дополнительный поход в сеть в самый неудобный момент, когда пользователь уже кликнул и ждёт. Лечится предзагрузкой.
Первый приём — начать грузить чанк на наведение мыши или на focus, за сотни миллисекунд до клика:
const link = document.querySelector('a[href="/reports"]');
const preload = () => import('./pages/Reports.js');
link.addEventListener('mouseenter', preload, { once: true });
link.addEventListener('focus', preload, { once: true });
Второй — сказать браузеру заранее, что модуль понадобится, прямо в разметке:
<link rel="modulepreload" href="/assets/dashboard-a3f1c2.js">
Разница принципиальна: modulepreload скачивает и разбирает модуль с высоким приоритетом, а не исполняет его. То есть мы платим сетью заранее и не платим ожиданием потом.
Чанк, которого больше нет
Есть неочевидная авария, о которую спотыкаются почти все, кто впервые включает code splitting. Имена чанков содержат хеш содержимого: dashboard-a3f1c2.js. Вы выкатываете новую версию — хеши меняются, старые файлы с сервера исчезают. А у пользователя вкладка открыта со вчерашнего дня, он кликает по ссылке на дашборд, браузер идёт за старым именем файла и получает 404. Приложение молча ломается: import() отклоняется, Suspense висит вечно.
Лечится это перехватом ошибки загрузки и мягким предложением обновить страницу — новая версия документа подтянет новые имена чанков:
// Vite шлёт это событие, когда не смог подгрузить чанк
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault(); // не даём ошибке всплыть
showToast('Вышла новая версия приложения. Обновить страницу?', {
action: () => window.location.reload(),
});
});
Как это работает
Сборщик строит граф модулей. Каждый import() становится точкой разреза: за ней начинается отдельный чанк со своим хешем в имени. Общие модули (React, утилиты, дизайн-система), которые нужны нескольким чанкам, сборщик выносит в отдельный общий чанк — иначе они продублировались бы в каждом. В рантайме import() вставляет в документ тег <script type="module"> (или использует preload-очередь), дожидается загрузки и резолвит промис экспортами модуля. Повторный import() того же модуля сеть не дёргает: результат уже в кеше модулей.
Частые ошибки
- Лениво грузить первый экран. Обёрнутый в
lazy()герой-блок означает лишний раунд-трип перед отрисовкой главного контента — прямой удар по LCP. Первый экран всегда статически. - Дробить на слишком мелкие чанки. Сорок файлов по 3 КБ, зависящих друг от друга, дают цепочку запросов (waterfall) и оверхед на каждый. Режьте по смыслу, а не по файлам.
- Отсутствие внятного fallback. Пустой
<div/>вместо скелета — это скачок вёрстки после загрузки и испорченный CLS. - Объявлять
lazy()внутри компонента. На каждом рендере создаётся новый ленивый компонент, React размонтирует и монтирует его заново.lazy()вызывают только на верхнем уровне модуля. - Не обрабатывать ошибку загрузки чанка. После деплоя старые хеши файлов исчезают, и у пользователя с открытой вкладкой
import()падает. Ловите ошибку и предлагайте перезагрузить страницу. - Считать, что фреймворк «сам всё разделил». Один статический импорт библиотеки графиков в общем layout возвращает её во все чанки сразу.
Итоги
- Динамический
import()— единственный необходимый инструмент: он и стандарт языка, и сигнал сборщику разрезать бандл. - Первая и самая доходная граница разреза — маршруты; вторая — тяжёлые компоненты, которые появляются по действию пользователя или при прокрутке.
- Первый экран и LCP-элемент грузим статически, всегда.
- Компенсируйте задержку ленивого чанка предзагрузкой:
import()наmouseenter/focusи<link rel="modulepreload">. - Скелет-заглушка должна совпадать по размеру с будущим содержимым — иначе экономия на JS обернётся скачком CLS.