8 lines
24 KiB
JSON
8 lines
24 KiB
JSON
{
|
||
"index": 27,
|
||
"slug": "editorial-2027-04-practice-build-evolution",
|
||
"title": "Сборка быстрее на 20%? Сначала докажите, что вход одинаковый",
|
||
"excerpt": "Как сравнивать frontend-сборки по одному входу, не принимать cache hit за ускорение и находить причину роста bundle по данным артефакта.",
|
||
"contentHtml": "<p>Новая frontend-сборка закончилась за 38 секунд вместо 47. Через день CI снова показывает 47 секунд. В другом запуске candidate оказался быстрее, но собирал только production entry, а baseline — два entry и source map. Цена ошибки — неверный выбор инструмента, потерянное время на миграцию и артефакт, который нельзя сравнить с опубликованным.</p>\n<p><strong>Тезис:</strong> время и размер имеют смысл только для одинаковой работы. Сборщик получает исходный граф, lockfile, конфигурацию, runtime, entry points и состояние кэша. Если хотя бы один существенный вход отличается, результат нужно пометить как несопоставимый, а не объявлять победителя.</p>\n<h2>Сначала определить объект сравнения</h2>\n<p>Слово «быстрее» описывает разные вопросы. Можно сравнивать время полного production bundling, длительность инкрементальной пересборки после одного изменения, размер файлов на диске или объём первой загрузки в браузере. У этих вопросов разные входы и разные владельцы. Один секундомер не отвечает на все четыре.</p>\n<p>Для полного bundling граница замера должна быть явной: например, от запуска команды до успешного завершения записи output. Установка зависимостей, сбор логов и загрузка артефакта в хранилище не входят в этот интервал, если команда хочет оценить именно сборщик. Для пользовательского эффекта понадобятся отдельные измерения: размер ответа после compression, сетевые задержки, порядок загрузки chunks и время выполнения в браузере.</p>\n<table><caption>Какой вопрос задаём — такую метрику и фиксируем</caption><thead><tr><th scope='col'>Вопрос</th><th scope='col'>Метрика</th><th scope='col'>Что сохранить</th><th scope='col'>Ошибка сравнения</th></tr></thead><tbody><tr><td>Как долго создаётся production output?</td><td>duration от старта команды до exit code 0</td><td>команду, exit code, commit и состояние build cache</td><td>включить install или upload только в один запуск</td></tr><tr><td>Как быстро обновляется код после правки?</td><td>время incremental rebuild</td><td>один изменённый файл, число пересборок и режим watcher</td><td>сравнить warm rebuild с cold full build</td></tr><tr><td>Что отправляется клиенту?</td><td>raw bytes и transfer size</td><td>entry, chunks, compression и заголовки</td><td>назвать размер на диске объёмом ответа</td></tr><tr><td>Почему вырос bundle?</td><td>delta по output и inputs</td><td>metafile или эквивалентный отчёт с import path</td><td>искать причину только в общем числе байт</td></tr></tbody></table>\n<figure><img src='/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg' alt='Последовательность сравнения сборок: одинаковый вход, конфигурация, cache state, замер и проверка output' loading='lazy' /><figcaption>Секундомер запускается после фиксации условий. Если меняется вход или конфигурация, сравнение начинается заново.</figcaption></figure>\n<h2>Одинаковый вход — это контракт</h2>\n<p>Вход сборки — не только папка <code>src</code>. Для честной пары запусков зафиксируйте commit или digest исходных файлов, lockfile, entry points, режим, target, feature flags, версии Node.js и bundler, операционную среду, команду и рабочую директорию. Если plugin читает переменные окружения, шаблоны или файлы за пределами <code>src</code>, они тоже входят в контракт.</p>\n<p>Не следует обещать, что этот список универсален. Конкретный инструмент может учитывать дополнительные поля: конфигурацию resolver, патчи зависимостей, параметры минификатора, локальные плагины или содержимое системных каталогов. Практическое правило такое: меняем один предполагаемый вход, наблюдаем invalidation и записываем результат. Если изменение не отражается в ключе или output, значит, проверяемая модель кэша неполна.</p>\n<p>Для воспроизводимости полезен не короткий fingerprint вроде <code>src-42</code>, а запись, которую другой инженер может восстановить: ссылка на commit, digest lockfile, нормализованный конфиг, список entry и описание окружения. Сам fingerprint помогает связать записи, но не доказывает, что в него попали все значимые входы.</p>\n<h2>Кэш и content hash решают разные задачи</h2>\n<p>Кэш сборки отвечает на вопрос «можно ли повторно использовать промежуточный результат». Content hash в имени output отвечает на другой вопрос: «изменилось ли содержимое этого файла для клиента». Нельзя считать одинаковым cache hit и одинаковое имя файла. Первый относится к внутреннему маршруту сборщика, второе — к доставке артефакта.</p>\n<p>В webpack подстановка <code>[contenthash]</code> меняет имя output при изменении содержимого соответствующего asset. Это помогает браузеру оставить неизменившийся файл в кэше. В документации webpack отдельно показано, что runtime и module identifiers могут влиять на hashes нескольких chunks; поэтому изменение одного модуля не обязано менять только один файл. Вывод надо делать по фактическому output, а не по ожиданию.</p>\n<p>У Vite есть более узкий пример для dependency pre-bundling: файловый кэш хранится в <code>node_modules/.vite</code>, а повторный pre-bundling зависит, среди прочего, от lockfile, времени изменения patches, релевантных полей конфигурации и <code>NODE_ENV</code>. Это описание конкретного механизма Vite, а не готовая формула для webpack, esbuild или самописного кэша. Флаг <code>--force</code> полезен для диагностики, но постоянный force скрывает неверный ключ и убирает пользу повторного запуска.</p>\n<h2>Воспроизводимый мини-эксперимент</h2>\n<p>Сначала подготовьте два запуска, а не подставляйте числа в отчёт вручную. Baseline и candidate должны пройти одну и ту же команду на зафиксированной паре входов. Для каждого запуска сохраните время в миллисекундах, размер raw output, список entry и chunks, cache state, exit code и ссылку на артефакт. Код ниже только проверяет сопоставимость уже собранных записей; он не запускает bundler и не доказывает эффект в production.</p>\n<pre><code>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 if (baseline.entryFingerprint !== candidate.entryFingerprint) {\n return { comparable: false, reason: 'entry points различаются' };\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', entryFingerprint: 'web-entries-2',\n durationMs: 420, outputBytes: 180000,\n};\nconst candidate = {\n inputFingerprint: 'src-42', entryFingerprint: 'web-entries-2',\n durationMs: 380, outputBytes: 176000,\n};\nconst changedEntry = {\n inputFingerprint: 'src-42', entryFingerprint: 'web-entries-1',\n durationMs: 350, outputBytes: 174000,\n};\n\nconsole.log(compareBuildRuns({ baseline, candidate }));\n// { comparable: true, deltaMs: -40, deltaBytes: -4000 }\nconsole.log(compareBuildRuns({ baseline, candidate: changedEntry }));\n// { comparable: false, reason: 'entry points различаются' }</code></pre>\n<p>Первый вызов разрешает вычисление: учебный candidate завершился на 40 мс раньше и дал на 4000 байт меньше. Второй вызов останавливается до сравнения цифр. Более быстрое число не компенсирует другой набор entry. В рабочем отчёте тот же принцип стоит расширить на mode, target, flags, lockfile и cache state. Поля, которые команда считает значимыми, должны присутствовать в данных, а не только в устной договорённости.</p>\n<p>Добавьте отрицательные тесты. Изменение одного исходного модуля должно менять input fingerprint или приводить к зафиксированному invalidation. Замена lockfile, plugin или target должна либо изменить ключ, либо завершить проверку явным cache miss. Если тест не способен отличить старый output от нового, он проверяет только форму отчёта, а не корректность кэша.</p>\n<h2>Серия запусков вместо удачного числа</h2>\n<p>Время зависит от состояния build cache, файлового кэша операционной системы, нагрузки CPU, диска, виртуализации и фоновых процессов. Поэтому разделяйте cold build cache и warm build cache. «Cold» здесь означает отсутствие повторно используемого кэша сборщика, а не стерильное состояние всей машины: страницу ОС, частоту процессора и фоновые процессы одним удалением каталога не выровнять.</p>\n<p>Снимите несколько запусков в каждом режиме и заранее выберите правило агрегации. Медиана показывает типичный результат, p95 — хвост задержек; обе метрики требуют одинакового количества наблюдений и одинаковой процедуры. Не удаляйте неудачные запуски без причины: exit code, timeout и выброс должны остаться в raw log с объяснением, иначе среднее станет красивее, но расследование потеряет контекст.</p>\n<p>Сравнивайте не только duration. Запишите peak memory, число и имена chunks, output bytes, наличие source map, cache hit/miss и фактическую команду. Candidate, который быстрее потому, что потерял source map или один entry, не оптимизировал тот же маршрут. Сначала подтвердите равенство результата, затем обсуждайте выигрыш.</p>\n<h2>От общего роста bundle к конкретному input</h2>\n<p>Размер bundle — симптом, а не причина. В esbuild включите <code>metafile</code>: JSON содержит inputs и outputs, связи импортов, размер output и вклад input в этот output. Сохраните этот файл рядом с артефактом. Текстовая визуализация удобна человеку, но автоматическую проверку лучше строить по JSON-данным, чтобы формат отчёта не стал скрытым контрактом.</p>\n<p>Сопоставьте baseline и candidate по каждому output. Новый крупный input указывает на добавленную зависимость или import. Два пути к разным версиям одной библиотеки требуют проверки lockfile и resolver. Выросший input без изменения исходника направляет расследование к transform, target, minify или plugin. После каждого изменения повторяйте сравнение на том же наборе entry и проверяйте, что функциональный output остался эквивалентным.</p>\n<p>Для webpack похожую роль выполняют stats и анализ chunks. <code>contenthash</code> подсказывает, какие файлы изменились, но не объясняет, какой import занял байты и почему. Для любого инструмента сначала выберите машинно читаемый отчёт, затем сделайте маленькую таблицу delta: output, input, bytes before, bytes after, import path и предполагаемое действие.</p>\n<h2>Размер файла не равен пользовательской скорости</h2>\n<p>Raw output — размер файла до передачи. Transfer size зависит от gzip или Brotli, заголовков, CDN и того, был ли ресурс в кэше. Время выполнения зависит от JavaScript, CPU устройства и момента, когда браузер встречает критический код. Поэтому уменьшение bundle на 4 КБ может не изменить первый экран, а дополнительный chunk может ухудшить маршрут из-за новой сетевой границы.</p>\n<p>Проверьте отдельно тот путь, ради которого меняется сборка. В браузере сохраните URL и response headers, повторите маршрут с очищенным и заполненным HTTP-кэшем, зафиксируйте compression и timing. Не переносите локальную цифру bundling в формулировку «страница стала быстрее», пока не измерен браузерный сценарий на сопоставимых условиях.</p>\n<p>Source map и metafile служат диагностике и могут не входить в production delivery. Если baseline публикует карту, а candidate нет, raw output сравнивается не с тем же результатом. Сначала выровняйте policy артефакта, а затем отдельно решите, какие файлы доступны клиенту, а какие остаются в хранилище CI.</p>\n<h2>Порядок проверки в CI</h2>\n<ol><li>Сформулируйте один вопрос: время bundling, incremental rebuild, размер output или пользовательский critical path. Назначьте ему одну основную метрику.</li><li>Зафиксируйте commit, lockfile, entry points, mode, flags, target, версии Node.js и bundler, runner, рабочую директорию и build cache.</li><li>Соберите baseline и candidate одной процедурой. Отдельно пометьте cold и warm cache state, сохраните raw log, exit code и артефакт.</li><li>Проверьте input fingerprint, entry fingerprint и набор output. Разный вход, entry, режим, chunk или policy source map означает «несопоставимо».</li><li>Снимите серию запусков и выберите медиану или p95 до просмотра чисел. Не скрывайте timeout и не смешивайте failed run с успешными.</li><li>Для роста output сравните metafile, stats или эквивалент: найдите input, import path, версию зависимости и transform, которые объясняют delta.</li><li>После изменения повторите серию на том же входе. Затем проверьте transfer size и браузерный critical path отдельным измерением.</li><li>Оставьте в CI отрицательную проверку: изменение каждого значимого входа должно менять ключ или вызывать наблюдаемый cache miss, а output должен соответствовать новому входу.</li></ol>\n<h2>Ограничения применимости</h2>\n<p>Эта схема не выдаёт универсальный рейтинг bundlers. Она помогает сравнить два конкретных маршрута при заданном входе и окружении. Результат нельзя переносить на другой проект, если там другие entry points, plugins, target, версии зависимостей, CPU или policy артефактов.</p>\n<p>Fingerprint не доказывает полноту сам по себе. Если его строит неполный скрипт, одинаковая строка может скрыть другой конфиг или старый linked package. Одинаковый runtime не устраняет различия диска, памяти и виртуализации. Cold build cache не означает cold browser cache.</p>\n<p>Рост output не равен росту времени выполнения в браузере, а уменьшение raw bytes не гарантирует ускорения первого экрана. Source map и metafile описывают артефакт, но не подтверждают его корректную доставку. Для вывода о production-поведении нужны отдельные данные соответствующего маршрута.</p>\n<p>Отрицательный путь должен быть явным. Если входы или entry различаются, возвращайте «несопоставимо». Если cache hit дал старый артефакт, исправьте ключ или invalidation и повторите проверку. Если причина роста не найдена, не объявляйте регрессию по одной цифре и не включайте постоянный force как замену расследованию.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Сравнение готово, если другой инженер может восстановить оба запуска по commit, lockfile, команде и окружению, увидеть одинаковые input и entry fingerprints и получить тот же состав output. В отчёте видны cache state, серия времени, chunks, input delta, exit code и ограничения выбранной метрики.</p>\n<p>Для кэша готовность дополнительно подтверждает отрицательный тест: изменение lockfile, конфигурации, runtime, plugin и одного исходного модуля меняет ключ или приводит к зафиксированному invalidation. После cache hit output соответствует тому же входу. Только тогда разницу времени можно обсуждать как свойство проверенного маршрута, а не как обещание нового инструмента.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://webpack.js.org/guides/caching/' target='_blank' rel='noopener noreferrer'>webpack: Caching</a> — официальное руководство описывает <code>[contenthash]</code>, runtime chunk и deterministic module identifiers; эти настройки относятся к output webpack и не являются универсальным ключом build cache.</li><li><a href='https://esbuild.github.io/api/#metafile' target='_blank' rel='noopener noreferrer'>esbuild API: Metafile</a> — официальная документация описывает JSON-метафайл с inputs, outputs и вкладом input в output. Он помогает разобрать артефакт, но не измеряет browser delivery.</li><li><a href='https://vite.dev/guide/dep-pre-bundling.html' target='_blank' rel='noopener noreferrer'>Vite Guide: Dependency Pre-Bundling</a> — официальное руководство перечисляет источники invalidation файлового кэша dependency pre-bundling и отдельно описывает <code>--force</code>. Эти правила относятся к Vite dependency optimizer.</li></ul>"
|
||
}
|