{ "index": 25, "slug": "editorial-2027-04-field-build-evolution", "title": "Рост frontend bundle: как найти конкретный input и не чинить симптом", "excerpt": "Общий размер JavaScript-артефакта показывает симптом, но не причину. Разбираем сопоставимый diff metafile, связь input с output и проверку реальной доставки в браузер.", "contentHtml": "

После изменения frontend-сборки команда видит в отчёте новый большой файл и сразу предлагает удалить самую заметную библиотеку. Это симптом, а не диагноз: такой ход часто промахивается мимо причины. Рост мог появиться из-за нового entry point (точки входа), дубликата зависимости, другой ветки разрешения пакета, изменившегося tree-shaking или публикации source map. В результате можно сломать рабочий экран, а initial-загрузка останется прежней.

Разберём воспроизводимый сценарий: baseline и candidate собраны из разных состояний проекта, а candidate стал тяжелее. Цель диагностики — не найти «виновный пакет», а показать цепочку input → import → output/chunk → HTTP-ответ. После этого решение уже предметное: изменить импорт, выровнять зависимость, вернуть настройку сборщика или проверить доставку. Если цепочка не сходится, честный результат — не менять код наугад.

Что именно измеряет bundle-анализ

Сборщик читает entry point и проходит граф импортов. Затем он преобразует модули, удаляет часть недостижимого кода, объединяет совместимые модули и записывает один или несколько output-файлов. В esbuild JSON-метаданные сборки называются metafile. В них есть inputs и outputs: первые описывают входные файлы и их исходный размер, вторые — созданные артефакты, их размер и вклад входов.

inputs[path].bytes — не размер загруженного браузером файла. Это размер входного файла, участвовавшего в анализе. outputs[path].bytes — размер созданного output до сетевого сжатия. Для вывода о пользовательской загрузке дополнительно понадобятся фактический HTTP-ответ, gzip или Brotli, cache headers, service worker и порядок запросов. Один показатель нельзя подменять другим.

Ещё одна граница проходит между текстовой сводкой и JSON. Формат вывода команды анализа удобен для человека, но его не стоит использовать как API для автоматического diff. Для инструмента сравнения берите JSON-метаданные и явно фиксируйте версию сборщика и схему полей.

\"Цепочка
Сначала связываем delta с конкретным input и output, затем проверяем, что пользователь действительно получил в ответе. Общий размер файла — только начало расследования.

Сначала сделайте два отчёта сопоставимыми

Diff имеет смысл только для сборок, где изменён один понятный фактор. Перед запуском сохраните commit, lockfile, версию Node.js и bundler, команду, режим production/development, entry points, флаги и каталог output. Зафиксируйте также, были ли включены minify, source map, code splitting и анализ зависимостей.

Минимальная команда из документации esbuild выглядит так:

esbuild app.js --bundle --metafile=meta.json --outfile=out.js

Для candidate используйте ту же команду и меняйте только заявленное условие. Если baseline собирался с --outfile, а candidate с --outdir, сравнение файлов уже смешивает изменение состава и изменение режима записи. То же происходит при замене lockfile, resolver conditions или платформы без отметки в протоколе.

Пути в metafile по умолчанию относительные. Это помогает получать воспроизводимые отчёты на разных машинах. Если один отчёт содержит src/app.ts, а другой — абсолютный путь рабочей станции, нормализуйте пути до сравнения или пересоберите baseline. Иначе один input будет ошибочно принят за два.

Воспроизводимый diff по inputs

Ниже — самостоятельный пример без зависимости от конкретного проекта. Функция берёт два объекта с полем inputs, добавляет нули для новых и исчезнувших путей и сортирует изменения по модулю delta. Она не утверждает, что вход целиком оказался в output: следующий шаг обязан проверить раздел outputs.

function diffInputs(before, after) {\\n  const names = new Set([\\n    ...Object.keys(before.inputs || {}),\\n    ...Object.keys(after.inputs || {}),\\n  ]);\\n\\n  return [...names]\\n    .map((name) => {\\n      const beforeBytes = before.inputs?.[name]?.bytes ?? 0;\\n      const afterBytes = after.inputs?.[name]?.bytes ?? 0;\\n      return {\\n        name,\\n        before: beforeBytes,\\n        after: afterBytes,\\n        delta: afterBytes - beforeBytes,\\n      };\\n    })\\n    .filter((item) => item.delta !== 0)\\n    .sort((left, right) => Math.abs(right.delta) - Math.abs(left.delta));\\n}\\n\\nconst baseline = {\\n  inputs: {\\n    'src/app.ts': { bytes: 4200 },\\n    'src/search.ts': { bytes: 1800 },\\n    'node_modules/date-fns/index.js': { bytes: 900 },\\n  },\\n};\\nconst candidate = {\\n  inputs: {\\n    ...baseline.inputs,\\n    'node_modules/chart-lib/index.js': { bytes: 7600 },\\n  },\\n};\\n\\nconsole.log(diffInputs(baseline, candidate));\\n// [{ name: 'node_modules/chart-lib/index.js', before: 0,\\n//    after: 7600, delta: 7600 }]

Список с большой delta — это список кандидатов для проверки, а не готовый вердикт. Сверьте путь с lockfile и найдите первый импорт, который приводит к нему. Наличие файла в inputs говорит, что сборщик его читал; оно не говорит, что все его исходные байты попали в конкретный output после tree-shaking.

Свяжите input с output и chunk

В каждом output esbuild хранит собственное поле inputs. Вложенное bytesInOutput показывает вклад входного файла в этот output. Там же могут быть imports, exports и entryPoint. Эта связь отвечает на главный вопрос: новый input увеличил initial-файл, lazy chunk, общий chunk или вообще не тот артефакт, который измеряет команда.

Проверяйте путь по такой последовательности:

  1. В inputs найдите новые и выросшие пути, затем отсортируйте их по delta.
  2. В каждом output найдите тот же input и запишите bytesInOutput, имя output и его entryPoint, если поле есть.
  3. По outputs[*].imports восстановите связи между output и отделите initial-файл от импортируемого chunk.
  4. Сопоставьте путь с исходным импортом, lockfile и настройками resolver. Проверьте, не появилось ли две версии одной библиотеки.
  5. Соберите candidate после одного изменения и повторите diff. Исправление доказано только тогда, когда исчезла заявленная delta и пользовательский путь продолжил работать.

Полезно хранить рядом с diff короткую запись: «input chart-lib/index.js добавлен в search.js, output — search-ABC.js, initial не изменился, lazy-запрос вырос после нажатия кнопки». Такая запись воспроизводимее, чем фраза «bundle стал меньше».

Матрица симптомов и проверок

Как перейти от наблюдаемого симптома к проверяемому действию
СимптомРабочая гипотезаЧто проверитьОграниченное действие
Новый большой inputДобавился импорт или entry pointПервый импорт, output и entry pointРазделить загрузку или удалить импорт, если функция не нужна
Старый input выросИзменились export, plugin или transformНастройки сборки, формат модуля и diff lockfileВернуть совместимую настройку и пересобрать
Одна библиотека имеет два путиДве версии или разные условия resolverLockfile, реальные пути и package exportsСвести версии только после проверки совместимости
Metafile прежний, HTTP-ответ тяжелееИзменились minify, compression или headersRaw, gzip/Brotli, response headers и cacheИсправлять delivery, не импорт
Выросла картаSource map создаётся или публикуется иначеКаталог, SourceMap и сетевой запросРазделить политику debug-артефактов и production
Diff нестабилен между машинамиРазные пути, runtime или lockfileFingerprint окружения и относительность путейВернуть одинаковые условия до сравнения

Динамический import меняет место стоимости

Рассмотрим экран поиска, где график нужен только после нажатия кнопки. Если сборщик сохраняет границу code splitting, динамический import() может вынести модуль в отдельный файл. Initial-загрузка тогда не обязана увеличиться на весь график, но пользователь заплатит дополнительным запросом и выполнением кода при открытии графика.

const openChart = async () => {\\n  const { renderChart } = await import('./chart/render-chart.js');\\n  return renderChart();\\n};\\n\\ndocument.querySelector('#open-chart')\\n  .addEventListener('click', openChart);

В esbuild code splitting требует output directory и формат ESM. Без включённого splitting асинхронная семантика import() сохраняется, но импортированный код может оказаться в том же bundle. Поэтому одного наличия динамического импорта недостаточно: проверьте флаг, формат и фактический список output.

Изменение размера lazy chunk — не автоматически регресс. Если график редко открывают, меньший initial путь может быть предпочтительнее. Если его открывают сразу после первого экрана, дополнительный запрос может увеличить время до функции. Сравните оба пользовательских сценария: холодную загрузку страницы и переход по кнопке. В network panel запишите transfer size, статус cache, длительность запроса и ошибки выполнения.

Почему нельзя удалять код по имени

Tree-shaking удаляет недостижимые объявления, но его результат зависит от статических ES-модулей и побочных эффектов. В документации esbuild отдельно указано, что tree-shaking использует import/export, а CommonJS не даёт такой же статической информации. Поэтому «большой пакет» может быть не причиной, а следствием выбранного формата модуля или настройки package fields.

То же относится к дубликатам. Alias или принудительное сведение версий иногда уменьшает output, но может изменить API, side effects или поведение плагина. Сначала установите, какие два пути разрешаются и какие exports реально используются. Потом проверьте тестовый сценарий и только затем меняйте resolver.

Source map — отдельный артефакт от JavaScript. Заголовок HTTP SourceMap указывает браузерным инструментам, где искать карту для оптимизированного ресурса. В production нужно проверить не только наличие файла в каталоге, но и ссылку из ответа, права доступа и принятую в проекте политику раскрытия исходников. Рост карты не доказывает рост пользовательского JavaScript, а её публикация может расширить доступный набор исходных материалов.

Критерий готовности и границы метода

Диагностика готова, когда другой инженер получает два отчёта с одинаковыми условиями, diff с конкретным input, связь этого input с output/chunk, результат браузерной проверки и одно изменение, которое можно повторить. Для каждой цифры должно быть ясно, это входные bytes, raw output или сетевой transfer. Для каждого решения должна быть названа цена: дополнительный lazy-запрос, риск несовместимости версий, потеря отладки или изменение initial-критического пути.

Metafile не измеряет время выполнения JavaScript, стоимость парсинга и компиляции, порядок запросов, cache hit или работу service worker. Формат поля зависит от bundler и его версии; код, который жёстко ожидает только текущие поля, следует защищать проверкой схемы. Динамический импорт не гарантирует отдельный chunk без подходящей конфигурации. Сжатие может изменить порядок «самых больших» файлов после raw diff.

Остановите расследование и вернитесь к сборке, если baseline и candidate отличаются lockfile, entry points или режимом без документированной причины. Не называйте результатом «оптимизацию» удаление зависимости по названию. Если состав output не изменился, переходите к compression, HTTP, кэшу и измерению браузерного пути. Именно эта граница не даёт исправить не тот слой.

Проверяемые источники

" }