{ "index": 27, "slug": "editorial-2027-04-practice-build-evolution", "title": "Сборка быстрее на 20%? Сначала докажите, что вход одинаковый", "excerpt": "Как сравнивать frontend-сборки по одному входу, не принимать cache hit за ускорение и находить причину роста bundle по данным артефакта.", "contentHtml": "

Новая frontend-сборка закончилась за 38 секунд вместо 47. Через день CI снова показывает 47 секунд. В другом запуске candidate оказался быстрее, но собирал только production entry, а baseline — два entry и source map. Цена ошибки — неверный выбор инструмента, потерянное время на миграцию и артефакт, который нельзя сравнить с опубликованным.

\n

Тезис: время и размер имеют смысл только для одинаковой работы. Сборщик получает исходный граф, lockfile, конфигурацию, runtime, entry points и состояние кэша. Если хотя бы один существенный вход отличается, результат нужно пометить как несопоставимый, а не объявлять победителя.

\n

Что именно сравнивает инженер

\n

Сборка не является одной операцией. Сначала резолвер строит граф модулей. Затем плагины и loaders преобразуют входы. Bundler раскладывает граф по chunks, минифицирует код и пишет output. Кэш может вернуть промежуточный результат до части этих шагов. Поэтому число из секундомера описывает не «скорость инструмента», а конкретный маршрут с конкретным состоянием.

\n

Размер тоже имеет несколько значений. Размер исходного input показывает вклад модуля в сборку. Размер output показывает файл на диске. Transfer size показывает объём после compression и HTTP-обмена. Эти величины нельзя подменять друг другом. Большой input может попасть в отложенный chunk, а небольшой модуль — блокировать первый экран.

\n
\"Последовательность
Секундомер запускается после фиксации условий. Если меняется вход или конфигурация, сравнение начинается заново.
\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Candidate быстрее в одном запускеУ него тёплый cacheСравнить hit/miss, directory и серию cold/warmРазвести режимы и повторить замер
Output меньше, но entry другойСобирается другая работаСверить entry, mode, flags и список chunksИсключить запуск или выровнять вход
Bundle вырос на 60 КБНовый input или duplicate dependencyСравнить metafile inputs и lockfileПроверить import, версии и split
Fingerprint совпал, результат старыйВ ключ не вошёл plugin или linked packageИзменить один вход и проверить invalidationРасширить ключ и проверить output после hit
\n

Кэш повторяет не проект, а функцию от входов

\n

Кэш хранит результат, полученный при определённых условиях. Его ключ должен различать изменения, которые влияют на граф, transform или output. Для типового frontend-проекта это lockfile, нормализованная конфигурация, версия Node и bundler, исходный digest, entry и параметры режима. Состав полей зависит от инструмента. Нельзя скопировать ключ webpack в Vite и считать его полным.

\n

Неполный ключ даёт опасный cache hit. Например, команда меняет alias или plugin, но имя cache namespace остаётся прежним. Bundler видит старый промежуточный результат и выпускает артефакт без нового правила. Постоянный флаг принудительной пересборки скрывает проблему, но не объясняет, что именно должно инвалидировать кэш.

\n

Слишком широкий ключ создаёт обратную проблему. Если в него попадает абсолютный путь временной директории или случайный идентификатор job, каждый запуск выглядит новым. CI теряет повторяемость. Поэтому ключ должен быть детерминированным: одинаковые значимые входы дают одинаковое значение, а изменение значимого входа меняет его.

\n

Учебная проверка сопоставимости

\n

Ниже учебный код. Он не запускает bundler и не измеряет реальный проект. Функция получает два уже записанных запуска, отбрасывает разные inputFingerprint и только затем считает разницу. Числа нужны для показа контракта, а не для заявления о production-эффекте.

\n
function compareBuildRuns({ baseline, candidate }) {\n  if (!baseline || !candidate) {\n    return { comparable: false, reason: 'нет двух запусков' };\n  }\n\n  if (baseline.inputFingerprint !== candidate.inputFingerprint) {\n    return { comparable: false, reason: 'входы сборки различаются' };\n  }\n\n  return {\n    comparable: true,\n    deltaMs: candidate.durationMs - baseline.durationMs,\n    deltaBytes: candidate.outputBytes - baseline.outputBytes,\n  };\n}\n\nconst baseline = {\n  inputFingerprint: 'src-42', durationMs: 420, outputBytes: 180000,\n};\nconst candidate = {\n  inputFingerprint: 'src-42', durationMs: 380, outputBytes: 176000,\n};\nconst changedInput = {\n  inputFingerprint: 'src-43', durationMs: 350, outputBytes: 174000,\n};\n\nconsole.log(compareBuildRuns({ baseline, candidate }));\n// { comparable: true, deltaMs: -40, deltaBytes: -4000 }\nconsole.log(compareBuildRuns({ baseline, candidate: changedInput }));\n// { comparable: false, reason: 'входы сборки различаются' }
\n

Первый вызов разрешает вычисление: учебный candidate завершился на 40 мс раньше и дал на 4000 байт меньше. Второй вызов останавливается до сравнения цифр. Более быстрое число не компенсирует другой исходный граф. В рабочем отчёте fingerprint должен быть связан с commit, lockfile, entry и версией окружения, а не с короткой строкой, которую никто не умеет восстановить.

\n

Почему одного запуска недостаточно

\n

Время зависит от cache state, нагрузки CPU, диска и фоновых процессов. Поэтому записывайте cold и warm отдельно. Для каждой серии сохраняйте несколько запусков и выбирайте заранее заданное представление: медиану для типичного времени или p95 для хвоста. Не смешивайте установку зависимостей с bundling, если вопрос касается только сборки.

\n

Сравнивайте не только duration. Запишите exit code, peak memory, число и имена chunks, output bytes, cache hit/miss и команду запуска. Если candidate быстрее, но потерял source map или собрал меньше entry, это не оптимизация. Это изменение результата.

\n

В webpack contenthash помогает увидеть, какой файл изменился после изменения содержимого. Deterministic module ids уменьшают случайные изменения имён. Эти настройки улучшают диагностику и кэширование, но не делают разные конфигурации одинаковыми. Их эффект нужно проверять на конкретном output.

\n

От общего роста bundle к конкретному input

\n

Размер bundle — только симптом. Сравните два metafile или эквивалентных отчёта сборщика. В JSON-метафайле esbuild можно найти inputs и их вклад в outputs. Отсортируйте delta по каждому input. Новый крупный модуль, выросший старый модуль и две версии одной зависимости ведут к разным действиям.

\n

Если delta появилась в библиотеке, найдите import path и проверьте tree-shaking. Если появились два пути к разным версиям пакета, проверьте lockfile и resolver. Если input не изменился, а output вырос, ищите plugin transform, target, minify и split. После исправления повторите сборку на том же fingerprint.

\n

Metafile не измеряет браузерную скорость. Для пользовательского эффекта отдельно смотрите transfer size, compression, cache и timing критического ресурса. Source map помогает связать bundle с исходным модулем, но карта может быть большой и не должна случайно попасть в production delivery.

\n

Порядок проверки

\n
  1. Запишите вопрос сравнения: время bundling, размер output или скорость критического пути. Не смешивайте эти метрики.
  2. Зафиксируйте commit, lockfile, entry points, mode, flags, target, версии Node и bundler, runner и расположение кэша.
  3. Соберите baseline и candidate с одинаковой командой. Отдельно пометьте cold и warm state, сохраните raw output и exit code.
  4. Проверьте fingerprint и состав результата. Разный fingerprint, entry, chunk или режим означает «несопоставимо», даже если число лучше.
  5. Сравните серию запусков, chunks и input delta. Для роста найдите import path, dependency version или transform до изменения кода.
  6. После изменения повторите измерение на том же входе. Затем отдельно проверьте transfer и критический пользовательский маршрут.
\n

Ограничения

\n

Учебная функция не знает, какие поля использует ваш bundler. Fingerprint не доказывает корректность, если его строит неполный скрипт. Одинаковый runtime не устраняет различия диска, CPU и виртуализации. Число запусков не исправляет несопоставимый entry.

\n

Рост output не равен росту времени выполнения в браузере. Source map и metafile описывают артефакт, но не гарантируют cache hit у пользователя. Compression, CDN, service worker и код до первого экрана требуют отдельных наблюдений. Не называйте локальную разницу production-результатом без измерения соответствующего пути.

\n

Отрицательный путь должен быть явным. Если входы различаются, функция возвращает «несопоставимо». Если output изменился, а причина не найдена, не откатывайте код по одной цифре. Если cache hit дал старый артефакт, исправьте ключ или invalidation. Не оставляйте постоянный force.

\n

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

\n

Сравнение готово, если другой инженер может восстановить два запуска по commit, lockfile, команде и окружению, увидеть одинаковый fingerprint и получить те же поля отчёта. В отчёте видны cold/warm state, серия времени, chunks, input delta и ограничения метрики.

\n

Для кэша готовность дополнительно подтверждает отрицательный тест: изменение lockfile, конфигурации, runtime и одного исходного модуля меняет ключ или приводит к зафиксированному invalidation. После cache hit output соответствует тому же входу. Только тогда разницу времени можно обсуждать как свойство проверенного маршрута.

\n

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

\n" }