{ "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-ответ. После этого решение уже предметное: изменить импорт, выровнять зависимость, вернуть настройку сборщика или проверить доставку. Если цепочка не сходится, честный результат — не менять код наугад.
Сборщик читает 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-метаданные и явно фиксируйте версию сборщика и схему полей.
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 будет ошибочно принят за два.
Ниже — самостоятельный пример без зависимости от конкретного проекта. Функция берёт два объекта с полем 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.
В каждом output esbuild хранит собственное поле inputs. Вложенное bytesInOutput показывает вклад входного файла в этот output. Там же могут быть imports, exports и entryPoint. Эта связь отвечает на главный вопрос: новый input увеличил initial-файл, lazy chunk, общий chunk или вообще не тот артефакт, который измеряет команда.
Проверяйте путь по такой последовательности:
inputs найдите новые и выросшие пути, затем отсортируйте их по delta.bytesInOutput, имя output и его entryPoint, если поле есть.outputs[*].imports восстановите связи между output и отделите initial-файл от импортируемого chunk.Полезно хранить рядом с 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 | Вернуть совместимую настройку и пересобрать |
| Одна библиотека имеет два пути | Две версии или разные условия resolver | Lockfile, реальные пути и package exports | Свести версии только после проверки совместимости |
| Metafile прежний, HTTP-ответ тяжелее | Изменились minify, compression или headers | Raw, gzip/Brotli, response headers и cache | Исправлять delivery, не импорт |
| Выросла карта | Source map создаётся или публикуется иначе | Каталог, SourceMap и сетевой запрос | Разделить политику debug-артефактов и production |
| Diff нестабилен между машинами | Разные пути, runtime или lockfile | Fingerprint окружения и относительность путей | Вернуть одинаковые условия до сравнения |
Рассмотрим экран поиска, где график нужен только после нажатия кнопки. Если сборщик сохраняет границу 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, кэшу и измерению браузерного пути. Именно эта граница не даёт исправить не тот слой.
inputs/outputs, поля bytes, bytesInOutput, связи импортов и оговорка о путях.import().