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.
Проверьте себя
1. Что делает сборщик, встретив вызов await import('./editor.js')?
AВклеивает модуль в главный бандл, но выполняет его позже
BВыносит модуль в отдельный чанк, который скачивается в момент вызова import()
CПолностью удаляет модуль из сборки, если он не используется статически
DЗагружает модуль с сервера при каждом вызове, минуя кеш модулей
2. Какой блок НЕ стоит загружать лениво?
AМодальное окно, которое открывается по клику
BГлавный заголовок и картинку первого экрана (LCP-элемент)
CБиблиотеку графиков на вкладке «Аналитика»
DБлок комментариев в самом низу длинной страницы