{ "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Сборка не является одной операцией. Сначала резолвер строит граф модулей. Затем плагины и loaders преобразуют входы. Bundler раскладывает граф по chunks, минифицирует код и пишет output. Кэш может вернуть промежуточный результат до части этих шагов. Поэтому число из секундомера описывает не «скорость инструмента», а конкретный маршрут с конкретным состоянием.
\nРазмер тоже имеет несколько значений. Размер исходного input показывает вклад модуля в сборку. Размер output показывает файл на диске. Transfer size показывает объём после compression и HTTP-обмена. Эти величины нельзя подменять друг другом. Большой input может попасть в отложенный chunk, а небольшой модуль — блокировать первый экран.
\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 |
Кэш хранит результат, полученный при определённых условиях. Его ключ должен различать изменения, которые влияют на граф, transform или output. Для типового frontend-проекта это lockfile, нормализованная конфигурация, версия Node и bundler, исходный digest, entry и параметры режима. Состав полей зависит от инструмента. Нельзя скопировать ключ webpack в Vite и считать его полным.
\nНеполный ключ даёт опасный cache hit. Например, команда меняет alias или plugin, но имя cache namespace остаётся прежним. Bundler видит старый промежуточный результат и выпускает артефакт без нового правила. Постоянный флаг принудительной пересборки скрывает проблему, но не объясняет, что именно должно инвалидировать кэш.
\nСлишком широкий ключ создаёт обратную проблему. Если в него попадает абсолютный путь временной директории или случайный идентификатор job, каждый запуск выглядит новым. CI теряет повторяемость. Поэтому ключ должен быть детерминированным: одинаковые значимые входы дают одинаковое значение, а изменение значимого входа меняет его.
\nНиже учебный код. Он не запускает bundler и не измеряет реальный проект. Функция получает два уже записанных запуска, отбрасывает разные inputFingerprint и только затем считает разницу. Числа нужны для показа контракта, а не для заявления о production-эффекте.
\nfunction 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Время зависит от 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 — только симптом. Сравните два metafile или эквивалентных отчёта сборщика. В JSON-метафайле esbuild можно найти inputs и их вклад в outputs. Отсортируйте delta по каждому input. Новый крупный модуль, выросший старый модуль и две версии одной зависимости ведут к разным действиям.
\nЕсли delta появилась в библиотеке, найдите import path и проверьте tree-shaking. Если появились два пути к разным версиям пакета, проверьте lockfile и resolver. Если input не изменился, а output вырос, ищите plugin transform, target, minify и split. После исправления повторите сборку на том же fingerprint.
\nMetafile не измеряет браузерную скорость. Для пользовательского эффекта отдельно смотрите transfer size, compression, cache и timing критического ресурса. Source map помогает связать bundle с исходным модулем, но карта может быть большой и не должна случайно попасть в production delivery.
\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Сравнение готово, если другой инженер может восстановить два запуска по commit, lockfile, команде и окружению, увидеть одинаковый fingerprint и получить те же поля отчёта. В отчёте видны cold/warm state, серия времени, chunks, input delta и ограничения метрики.
\nДля кэша готовность дополнительно подтверждает отрицательный тест: изменение lockfile, конфигурации, runtime и одного исходного модуля меняет ключ или приводит к зафиксированному invalidation. После cache hit output соответствует тому же входу. Только тогда разницу времени можно обсуждать как свойство проверенного маршрута.
\n