open atlas
↑ К треку
Next.js с нуля до senior NEXT · 05 · 03

Анализ бандлов и бюджеты: first-load JS, раздувание от barrel-файлов и CI-гейты

First-load JS — число, которым надо владеть: barrel-импорты и расползание use client незаметно раздувают общие чанки. Читайте treemap анализатора, знайте цепочку КБ→мс главного потока→INP/LCP и держите помаршрутные бюджеты в CI: +300 КБ от иконок валят PR, а не полевые данные.

NEXT Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

PR на две строки добавляет галочку в шапку сайта: import { Check } from "@acme/icons" — внутренний пакет иконок компании, barrel-файл, реэкспортирующий 900 SVG-компонентов. Шапка — клиентский компонент в корневом layout, так что импорт попадает в чанк, общий для всех маршрутов. First-load JS вырастает со 128 КБ до 431 КБ. Никто не замечает: дифф — две строки, CI зелёный, M3-макбук ревьюера рендерит мгновенно. Ущерб проявляется в течение месяца, по мере накопления полевых данных: INP на среднем Android сползает со 180 мс до 320 мс, мобильный bounce подрастает на 6%, а SEO-отчёт фиксирует деградацию Core Web Vitals на каждой странице сайта. Финальное расследование — один билд с ANALYZE=true: на treemap весь набор иконок сидит в общем чанке и затмевает сам React. Фикс — одна строка. Урок — отсутствовавшая система: числом никто не владел, и число поехало. Бандлы не регрессируют большими драматичными коммитами — они регрессируют по две строки за раз, и только CI-бюджет замечает две строки.

Читаем числа: first-load JS и treemap анализатора

Каждый next build печатает таблицу, которая и важна: по маршруту — его собственный size и его First Load JS — весь JavaScript, который холодный посетитель скачивает, чтобы отрендерить маршрут: чанк самого маршрута плюс общие чанки (фреймворк, ваш общий код), перечисленные внизу как «First Load JS shared by all». Свежий create-next-app стартует примерно со 100 КБ gzip общего first-load — это ваш пол. Рост колонки маршрута бьёт по одной странице; рост общего числа бьёт по всем сразу — потому импорт иконок из Hook был инцидентом масштаба сайта, а не правкой шапки.

Таблица говорит, что что-то выросло; @next/bundle-analyzer говорит, что именно:

// next.config.mjs
import bundleAnalyzer from "@next/bundle-analyzer";

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === "true",
});

export default withBundleAnalyzer({
  // ваш существующий конфиг
});

ANALYZE=true next build строит интерактивные treemap-ы (клиентский, плюс серверный и edge-бандлы). Читайте клиентский и следите за единицами: stat — сырой размер модуля, parsed — то, что браузер реально парсит и исполняет после минификации, gzipped — то, что идёт по сети. Сетевая цена читается по gzip, цена главного потока — по parsed: чанк, жмущийся до 90 КБ, может быть 350 КБ parsed, и телефон платит цену parsed. Диагностический приём всегда один: найти неожиданно большой прямоугольник, посмотреть, в каком он чанке (общий или маршрутный), и проследить, какой импорт его туда затащил.

Что на самом деле раздувает бандлы

Большую часть реального раздувания дают три механизма. Первый — barrel-файлы: index.ts, реэкспортирующий целый пакет. Импорт одной иконки из barrel на 900 экспортов заставляет бандлер резолвить и анализировать каждый модуль за ним, и всё, что не доказуемо свободно от сайд-эффектов, переживает tree-shaking (удаление неиспользуемого кода при сборке) и оседает в чанке; библиотеки иконок и компонентов — классические +300 КБ с одного импорта. У Next есть прицельный фикс: optimizePackageImports в next.config, переписывающий barrel-импорты в прямые помодульные на билде, — включён по умолчанию для списка известно-огромных библиотек (lucide-react, @mui/icons-material, date-fns и компания), и его стоит дополнить вашим внутренним пакетом дизайн-системы: собственные barrel-ы никаких поблажек не получают.

Второй — расползание клиентских компонентов. 'use client' помечает границу, а не компонент: всё, что она импортирует — и всё, что импортируют те модули, — входит в клиентский граф и едет в браузер. Один 'use client', поставленный высоко (обёртка layout, файл провайдеров, заодно импортирующий утилиты), молча превращает серверное поддерево в отгружаемый JavaScript. Симптом в форме бандла: библиотеки, которые вы «используете только на сервере», всплывают в клиентском treemap, потому что какая-то клиентская граница транзитивно до них дотянулась. Третий — тяжеловесная зависимость, выбранная походя: графическая библиотека на маршруте, где хватило бы спарклайна, 70-килобайтная библиотека дат там, где справился бы Intl, lodash целиком. Аудитьте их по treemap от большего прямоугольника к меньшему — топ-5 обычно держит больше половины parsed-байтов.

Викторина

Таблица билда: собственный размер маршрута 4 КБ, но «First Load JS shared by all» прыгнул со 120 КБ до 410 КБ после релиза. В клиентском treemap самый большой общий чанк заполнен библиотекой иконок. Где байты вероятнее всего вошли?

Цепочка от килобайтов до Core Web Vitals

Бюджеты защищают только тогда, когда команда верит в причинную цепочку, — так сделайте её явной. Gzip-байты стоят сети; затем телефон парсит и исполняет parsed-байты в главном потоке. Средний Android исполняет JavaScript в разы медленнее ноутбука, на котором PR ревьюили: лишние 300 КБ parsed — это примерно лишняя секунда работы главного потока на таком устройстве. Секунда ложится в два места. Во время загрузки исполнение скриптов и гидрация конкурируют со всем остальным; копятся длинные задачи, и любой тап в этом окне ждёт — так вес JS деградирует INP (Interaction to Next Paint — время от нажатия до следующей отрисовки), чей «хороший» порог — 200 мс на p75. Ещё раньше сами байты конкурируют с LCP-картинкой и критическим CSS за канал, а на контенте, рендеримом скриптом, LCP вообще не может отрисоваться до конца исполнения — путь от размера чанка к проваленным 2,5 с LCP. Триаду замыкает CLS с порогом 0,1, но его причины живут в основном в двух предыдущих уроках. Резюме, достойное вики команды: gzip стоит сети, parsed стоит главному потоку, а в главном потоке живёт INP.

Почему это работает

Почему бюджетировать first-load JS, а не просто следить за баллами Lighthouse? Потому что балл — агрегат одного синтетического прогона: шумный, запаздывающий и спорный. First-load JS детерминирован на каждый билд, диффабелен по PR и атрибутируем до строки кода. Балл — лагающий индикатор для вице-президента; бюджет — опережающий, ловящий регрессию, пока виноватый импорт ещё лежит в диффе на ревью. Команды, гейтящие баллы, судятся с флаки-прогонами CI; команды, гейтящие байты, ревертят импорт.

Бюджеты в CI: чтобы у числа был хозяин

Бюджет — это число с прикрученным ломателем сборки. Механика проста — важно, какие числа. Гейтите помаршрутный first-load JS, а не суммарный выход билда: сумма легитимно растёт с каждым новым маршрутом, а вот рост first-load маршрута означает, что реальные люди на этой странице платят больше. Практичная стартовая поза: общий first-load ≤ 130 КБ gz, самый тяжёлый маршрут ≤ 200 КБ gz, плюс правило дельты на PR — любой маршрут, выросший больше чем на ~10 КБ, требует обоснования в описании PR. Инструменты: size-limit, нацеленный на собранные чанки; скрипт, разбирающий выходной JSON next build и сверяющий с закоммиченным budgets.json; артефакт анализатора, загружаемый на каждый PR, чтобы дифф treemap был в одном клике на ревью.

Две операционные детали отделяют работающие бюджеты от сгнивших. Храповик, а не мечта: ставьте стартовый бюджет в текущую реальность плюс ~5% и затягивайте по мере отыгрывания байтов — бюджет, выставленный в желаемое число с первого дня, красный вечно и игнорируется ко второму спринту. И делайте провал действенным: сообщение CI должно называть маршрут, дельту и главные новые модули (они есть в JSON анализатора), потому что «bundle check failed» учит людей поднимать бюджет, а «общий чанк +303 КБ: @acme/icons через Header.tsx» учит чинить импорт. Вместе эти две детали решают, станет ли бюджет инструментом дисциплины или бюрократической галочкой: храповик даёт ему доверие (его реально выполнить), действенное сообщение даёт зубы (починить проще, чем поднять планку). Инцидент из Hook, переигранный с этим гейтом, умирает на ревью красной галкой на PR в две строки — в этом весь смысл: бюджет конвертирует квартал деградировавших полевых данных в комментарий код-ревью на тридцать секунд.

Викторина

Команда ставит CI-бюджет на суммарный размер выхода билда, причём сразу в амбициозные 100 КБ при текущих 240 КБ общего first-load. Через полгода бандлы больше, чем когда-либо. Что пошло не так?

Вспомните перед уходом
  1. 01
    Что измеряет First Load JS, какие три главных раздувателя бандлов и как анализатор выявляет каждый?
  2. 02
    Проследите причинную цепочку от килобайтов бандла к проваленным Core Web Vitals и опишите конструкцию CI-бюджета, которая реально держится.
Итог

Весом бандла правит одно число из двух частей: First Load JS — собственный чанк маршрута плюс общие чанки, которые скачивает каждый маршрут, — и опасна именно общая часть, потому что её рост деградирует все страницы сразу; ровно так barrel-импорт пакета иконок на две строки в клиентском компоненте корневого layout становится инцидентом масштаба сайта. Таблица билда говорит, что что-то выросло; treemap @next/bundle-analyzer с ANALYZE=true говорит, что именно, — если читать правильные единицы: gzip — цена сети, parsed — цена главного потока, и чанк в 90 КБ gzip может стоить 350 КБ парсинга и исполнения. Постоянные раздуватели — barrel-файлы (контрятся прямыми импортами или optimizePackageImports, покрывающим популярные библиотеки по умолчанию, но требующим явного добавления ваших внутренних пакетов), расползание use client (граница отгружает весь свой транзитивный граф импортов, и один высокий ‘use client’ превращает серверное поддерево в клиентские байты) и тяжёлые зависимости, принятые не взвесив. Причинная цепочка, которую стоит защищать перед командой: gzip-байты конкурируют с LCP-картинкой за канал, parsed-байты становятся секундами главного потока на средних телефонах, длинные задачи в гидрацию заставляют тапы ждать — и INP сползает за порог 200 мс, пока LCP грозит 2,5 с. Защита — CI-бюджет на помаршрутный first-load JS: затянутый храповиком от текущей реальности с зазором, а не выставленный в мечту; с дельтами на PR на виду и сообщениями о провале, называющими маршрут, дельту и виноватый импорт, — чтобы регрессия умирала красной галкой на ревью, а не всплывала через месяц в полевых данных p75. Теперь, когда видишь тяжёлый общий First Load JS, — открываешь treemap с ANALYZE=true и ищешь самый крупный прямоугольник в клиентском бандле: импорт, затащивший его туда, почти всегда можно починить в том же PR, в котором он появился.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.