12k
All articles

Стратегии разделения кода во фронтенд-приложении

Сравните стратегии разделения кода фронтенда: разделяйте маршруты и тяжёлые компоненты, загружайте код заранее при взаимодействии и оценивайте результат по сетевой диаграмме.

OpenReplay Team
OpenReplay Team
Стратегии разделения кода во фронтенд-приложении

Разделение кода (code splitting) разбивает JavaScript-бандл на отдельные чанки, которые браузер загружает только тогда, когда содержащийся в них код действительно нужен. В результате при первой загрузке пользователь платит только за первую страницу, а не за всё приложение.

Lighthouse сообщает о куче неиспользуемого JavaScript, вы уже обернули один компонент в React.lazy, но в продакшен по-прежнему уходит один большой входной чанк. Решение не в том, чтобы расставить вызовы lazy() повсюду. Нужно понимать, какие разделения стоит делать, чего стоит каждое из них и как проверить, что оно сработало.

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

Ключевые выводы

  • Разделение по маршрутам — первое, что стоит сделать практически в любом одностраничном приложении: каждый маршрут превращается в отдельный чанк, и посетитель загружает только тот маршрут, на который попал.
  • webpack 5 и Rolldown (бандлер в Vite 8) уже выносят код, общий для лениво загружаемых чанков, в общие чанки, поэтому собранный вручную vendor.js сегодня полезен в основном для стабильности кеша, а не для уменьшения размера.
  • Если запускать import() при наведении или получении фокуса, а не по клику, чанк загружается, пока пользователь ещё решает, кликать ли.
  • Уменьшение входного чанка само по себе ничего не доказывает. Доказательство — водопад сетевых запросов при троттлинге, в котором код для первого экрана загружается раньше, чем до изменений.
  • Вынос в отдельный чанк кода, необходимого первому экрану, не устраняет ожидание, а лишь откладывает его.

Почему единый бандл замедляет первую страницу?

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

У каждой из пяти стратегий разделения кода, описанных ниже, свой триггер, своя цена и свой способ проверки результата:

СтратегияКогда применятьЧего стоитКак проверить, что сработало
Разделение по маршрутамЛюбое SPA с несколькими маршрутамиFallback при каждой навигацииВходной чанк больше не содержит другие маршруты
Разделение по компонентамТяжёлый UI, скрытый за взаимодействием или ниже первого экранаСостояние загрузки и дополнительный запросЧанк загружается только при срабатывании триггера
Ручные vendor-чанкиСтабильные зависимости, которые нужно надолго кешироватьПоддержка конфигурации и возможное изменение порядка выполненияХеш не меняется между деплоями
Предзагрузка по взаимодействиюКлики с высокой вероятностьюИзредка загружается код, который никто не используетЗапрос стартует до клика
Не разделятьКод, необходимый первому экрануНетНе появляется новых цепочек запросов

Начинайте с разделения по маршрутам

Разделение по маршрутам — первое разделение, которое стоит сделать практически в любом одностраничном приложении. Каждый маршрут становится отдельным чанком, и посетитель загружает только тот маршрут, на который попал. Это требует минимум кода, а смена маршрута — естественный момент для кратковременного состояния загрузки. В статье Understanding React dynamic imports этот паттерн разобран на примере React.lazy и Suspense. Метафреймворки, такие как Next.js и SvelteKit, разделяют код по маршрутам автоматически.

Выносите тяжёлые компоненты за взаимодействие

Разделение на уровне компонентов подходит для тяжёлого UI, который не виден при первом рендере: модальное окно, редактор форматированного текста, виджет с графиками, блок комментариев ниже первого экрана. Цена — состояние загрузки плюс один сетевой запрос в тот момент, когда пользователю понадобилась функция.

Все основные фреймворки предоставляют один и тот же примитив поверх динамического import():

// React
import { lazy } from 'react';
const Chart = lazy(() => import('./Chart'));
// Next.js
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'));
// Vue
import { defineAsyncComponent } from 'vue';
const Chart = defineAsyncComponent(() => import('./Chart.vue'));

Ленивый компонент React рендерится внутри границы Suspense, которая показывает fallback, пока загружается чанк. В Next.js next/dynamic объединяет React.lazy() и Suspense в один вызов, во Vue есть defineAsyncComponent. В Svelte import() используется напрямую вместе с условным рендерингом.

Для контента ниже первого экрана запускайте загрузку, когда элемент приближается к области видимости. Создайте один Intersection Observer, отключите его после первого пересечения и отрендерите ленивый компонент. Паттерны работы с observer разобраны в статье Lazy loading in JavaScript.

Что бандлер уже разделяет за вас

Настройки splitChunks по умолчанию в webpack 5, а также Rollup (Rolldown в Vite 8) автоматически выносят код, общий для нескольких лениво загружаемых чанков, в общий чанк. По умолчанию webpack делает это только для асинхронных чанков (chunks: 'async') и только если размер нового чанка превышает минимальный порог. Встроенная cache group defaultVendors уже охватывает node_modules. Обзор текущего положения дел у каждого бандлера можно найти в статье The current state of JavaScript bundlers.

Поскольку бандлер уже извлекает общий код, единый собранный вручную vendor.js в основном дублирует работу webpack или Rolldown. Главная оставшаяся причина определять vendor-чанки вручную — стабильность кеша. Если редко меняющиеся зависимости сохраняют один и тот же хеш содержимого между деплоями, вернувшимся посетителям не придётся загружать их повторно.

// webpack 5
module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        framework: {
          test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
          name: 'framework',
          chunks: 'all',
        },
      },
    },
  },
};

Здесь необходим chunks: 'all'. Без него код фреймворка, импортируемый входной точкой, останется во входном чанке, и хеш входного чанка будет меняться при каждом деплое.

В Vite 7 и более ранних версиях то же самое задавалось через build.rollupOptions.output.manualChunks. Vite 8 собирает проект с помощью Rolldown. В нём объектная форма manualChunks больше не поддерживается, а функциональная объявлена устаревшей в пользу групп codeSplitting:

// Vite 8 (Rolldown)
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [{ name: 'vendor', test: /node_modules[\\/](react|react-dom)[\\/]/ }],
        },
      },
    },
  },
});

Делайте группы узкими. Документация Rolldown предупреждает, что группы, заданные вручную, могут изменить порядок выполнения модулей, что важно для модулей с побочными эффектами.

Начинайте загрузку при наведении, а не по клику

Предзагрузка по взаимодействию запускает import(), когда указатель наводится на кнопку или кнопка получает фокус, а не при клике, — так чанк загружается, пока пользователь ещё принимает решение. Загрузка перекрывается с паузой между намерением и действием, поэтому fallback показывается лишь на мгновение или не показывается вовсе.

import { lazy, Suspense, useState } from 'react';

const loadEditor = () => import('./RichTextEditor');
const RichTextEditor = lazy(loadEditor);

function ReplyBox() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onMouseEnter={loadEditor} onFocus={loadEditor} onClick={() => setOpen(true)}>
        Reply
      </button>
      {open && <Suspense fallback={<p>Loading editor…</p>}><RichTextEditor /></Suspense>}
    </>
  );
}

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

Подсказки вроде webpackPrefetch — это магические комментарии webpack. Turbopack в Next.js понимает часть webpack-совместимых магических комментариев, но в его документации перечислены лишь комментарии для управления импортом, такие как webpackIgnore, а webpackPrefetch среди них нет; другие бандлеры, как правило, их вообще не читают. Прежде чем полагаться на такой комментарий, сверьтесь с документацией своего бандлера. Вызов import() из обработчика событий работает с любым бандлером.

Измеряйте водопад запросов, а не размер бандла

Уменьшение входного чанка само по себе ничего не доказывает. Свидетельство того, что разделение помогло, — водопад сетевых запросов при троттлинге, в котором код для первого экрана загружается раньше, чем до изменений.

  1. Запустите визуализатор бандла для своего бандлера, чтобы убедиться, что содержит каждый чанк. Для webpack в руководстве по Webpack Bundle Analyzer показано, как находить тяжёлые модули и код, который страница не использует.
  2. Откройте панель Network в Chrome DevTools, включите фильтр JS и выберите профиль медленной мобильной сети.
  3. Сравните результаты до и после. Проверьте, когда завершается загрузка последнего чанка, необходимого для первого рендера, и не появились ли новые последовательные цепочки запросов.

Цепочка выглядит как лестница: запрос каждого чанка начинается только после завершения предыдущего. Такой паттерн возникает из-за вложенных ленивых импортов.

Когда разделение кода всё замедляет?

Разделение кода даёт обратный эффект в трёх предсказуемых случаях.

Слишком много мелких чанков. Каждый чанк — отдельный запрос. Выделение мелких компонентов добавляет запросы и сетевые round trip, которые обходятся дороже сэкономленных байтов.

Разделение критического пути. Вынос в отдельный чанк кода, необходимого первому экрану, не устраняет ожидание, а лишь сдвигает его. Теперь браузер должен загрузить и выполнить входной чанк, дойти до динамического import() и только после этого запросить чанк, который отрисовывает контент.

Спиннер на месте контента. Fallback, заменяющий контент, который раньше появлялся мгновенно, может создавать ощущение более медленного приложения, даже если общее время загрузки сократилось.

Сопровождайте каждую ленивую границу error boundary. Запрос чанка может завершиться ошибкой, а вкладка, открытая до деплоя, может запросить файлы с хешами, которых больше не существует. Записи пользовательских сессий (session replay) в приложениях с разделением кода показывают то, чего не покажет ни один отчёт о бандле: пользователей, застрявших на fallback-экранах Suspense, или получивших ChunkLoadError после деплоя посреди сессии.

Какие разделения оставить

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

Часто задаваемые вопросы

Работает ли React.lazy с именованными экспортами?

Не напрямую. React.lazy требует, чтобы промис загрузчика разрешался модулем, в котором компонент является экспортом по умолчанию, поэтому именованный экспорт нужно переназначить: lazy(() => import('./Charts').then((m) => ({ default: m.BarChart }))). Чанк при этом выделяется так же. Можно также реэкспортировать компонент как экспорт по умолчанию из небольшого файла-обёртки — это сохраняет вызов lazy коротким.

Как восстановиться, если ленивый чанк не загрузился после деплоя?

Перезагрузите страницу, чтобы браузер получил новый входной чанк и актуальные URL чанков. React сохраняет первый промис ленивого загрузчика и никогда не вызывает загрузчик повторно, поэтому одного лишь повторного рендера для новой попытки недостаточно. При ошибке динамического импорта Vite генерирует на window событие 'vite:preloadError', и вызов event.preventDefault() в обработчике подавляет ошибку. Ограничьте число автоматических перезагрузок, чтобы не попасть в бесконечный цикл.

В чём разница между webpackPrefetch и webpackPreload?

webpackPrefetch загружает чанк в период простоя браузера после того, как завершилась загрузка родительского чанка, — для кода, который вероятно понадобится позже, например для следующего экрана. webpackPreload загружает чанк одновременно с родительским, со средним приоритетом, — для кода, который родительский чанк запросит сразу. Оба являются магическими комментариями webpack, генерирующими link-подсказки. Preload используется реже, поскольку злоупотребление им может ухудшить производительность.

Предотвращает ли Vite водопады чанков автоматически?

Частично. Когда асинхронный чанк зависит от общего чанка, Vite переписывает динамический импорт, добавляя шаг предзагрузки, чтобы оба чанка загружались параллельно, а не последовательно. Кроме того, Vite генерирует ссылки modulepreload для входных чанков и их прямых импортов. Это покрывает только статические зависимости. Ленивый компонент, который при рендере снова вызывает import(), всё равно создаёт последовательный запрос, заметный в водопаде.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.