import { createHash } from 'node:crypto'; function escapeHtml(value) { return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); } const p = (text) => '
' + text + '
'; const h2 = (text) => '' + escapeHtml(text) + '';
const ol = (items) => '| ' + cell + ' | ').join('') + '
|---|
| ' + cell + ' | ').join('') + '
NODE_ENV. Для linked dependency важен способ разрешения пакета и необходимость повторной оптимизации. Это пример хорошей инженерной границы: в документации названы не только кнопка «force», но и данные, по которым система принимает решение.'),
p('В webpack 5 cache может быть memory или filesystem. Эти режимы различаются временем жизни и стоимостью сериализации. Даже корректный ключ не спасёт, если два job используют одну директорию с разными правами или разными версиями Node. Состояние cache нужно видеть в отчёте: hit/miss, key, location, runtime и причина invalidation.'),
figure('/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg', 'Матрица ключа frontend-кэша: lockfile, конфигурация, runtime и исходный digest образуют вход, а изменение любого слоя инвалидирует результат.', 'Диаграмма отделяет входы ключа от результата cache. Красная ветка означает, что неполный ключ нельзя считать доказательством корректного повтора.'),
table('Состав ключа и последствия пропуска', ['Вход', 'Что меняется', 'Признак устаревания', 'Контроль'], [
['Lockfile', 'версии и граф зависимостей', 'новый package version', 'hash lockfile'],
['Config', 'plugins, target, aliases', 'другой output', 'нормализованный config digest'],
['Runtime', 'Node, bundler, platform', 'разное поведение cache', 'версия и ABI окружения'],
['Source', 'код и linked package', 'изменённый модуль', 'commit/source digest'],
['Cache location', 'общая или локальная область', 'чужой результат', 'namespace и права'],
]),
h2('Локальный ключ с прозрачными входами'),
p('В примере используется SHA-256 и фиксированный порядок четырёх значений. Результат — короткий идентификатор, который можно поместить в имя cache namespace. Учебный код не знает, какие поля нужны конкретному bundler: он показывает главное правило — каждый источник изменения должен иметь явное место в ключе, а delimiter не должен позволять склеить разные наборы в одну строку.'),
code(`import { makeDependencyCacheKey } from './upgrade-2027-04.mjs';
const base = {
lockfile: 'lock-v1',
config: 'target=es2022;minify=true',
runtime: 'node-24',
sourceDigest: 'src-001',
};
console.log(makeDependencyCacheKey(base));
console.log(makeDependencyCacheKey({ ...base, lockfile: 'lock-v2' }));
// 16-символьный key
// другой key после изменения lockfile`),
p('Ожидаемый результат важнее конкретных hex-значений: замена lockfile меняет key, а повтор одного объекта даёт тот же key. Перед реальным использованием нормализуйте конфигурацию и закрепите кодировку. Не добавляйте timestamp, случайный UUID или абсолютный путь, если они не являются частью результата: такие поля превратят каждый запуск в промах.'),
h2('Cache hit не измеряет качество результата'),
p('Попадание в cache говорит, что найден результат с совпавшим ключом. Оно не говорит, что ключ полный, output опубликован, source map соответствует bundle или браузер получил свежий файл. Для dev-server это особенно заметно: Vite может жёстко кэшировать resolved dependency requests, а локальная правка linked package потребует явного re-bundle. В отчёте отделяйте cache state от проверки содержимого.'),
p('Промах тоже не всегда ошибка. Изменение lockfile должно инвалидировать dependency cache. Слишком агрессивное reuse иногда дешевле, чем сложная логика восстановления, если сборка короткая. Решение зависит от стоимости работы и риска устаревшего результата. Назовите обе величины: секунды cache miss и ущерб от неправильного hit.'),
h2('Действия по порядку'),
ol([
'Выписать все поля, которые меняют dependency graph, transform, target или состав output.',
'Нормализовать значения и собрать deterministic key; исключить случайные и абсолютные поля.',
'Проверить hit и miss на изменении lockfile, конфигурации, runtime и одного исходного модуля.',
'Сохранить key, cache location, режим cold/warm и причину invalidation в техническом отчёте.',
'Проверить содержимое output и source map после hit; одного совпавшего key недостаточно.',
]),
h2('Ограничения и следующий шаг'),
p('Функция делает hash строки и не знает о том, как bundler нормализует config, разрешает symlink или читает lockfile. Разный порядок полей может дать лишний miss, а забытый plugin — неправильный hit. Кэш файловой системы зависит от прав, диска и версии сериализации. Нельзя переносить key между toolchain без проверки семантики входов.'),
p('Следующий шаг — добавить тест invalidation для каждого входа и отдельный тест на source map и output после cache hit. В CI выводите первые символы key, но не секреты и содержимое приватного source. Если причина miss неизвестна, сначала расширьте диагностику ключа, а не включайте постоянный force.'),
], [
{ key: 'vite', use: 'Источники invalidation Vite dependency pre-bundling и поведение force/linked dependency.', boundary: 'Руководство относится к Vite dependency optimizer и не является универсальным контрактом любого bundler.' },
{ key: 'webpackConfig', use: 'Режимы memory и filesystem cache webpack 5 используются для различения времени жизни результата.', boundary: 'Справочник не знает cache directory, права и runtime конкретного CI.' },
{ key: 'webpackCache', use: 'Стабильные output names и детерминированные ids используются как пример отделения результата от случайности.', boundary: 'Руководство не доказывает корректность неполного cache key в другом проекте.' },
]);
const field = revision({
slug: 'editorial-2027-04-field-build-evolution',
title: 'Разбор bundle: найти источник роста без гадания',
categories: ['Frontend', 'Производительность'],
cover: '/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg',
excerpt: 'Полевой маршрут от выросшего JavaScript-файла к конкретному input, chunk и проверке source map.',
readingMinutes: 15,
}, [
p('Проблема bundle-анализа начинается с общей цифры: JavaScript-артефакт вырос на 180 КБ. Если сразу удалить большую библиотеку, можно убрать не ту причину. Рост мог появиться из-за новой точки входа, отключённого tree-shaking, дубликата зависимости или включённой source map. Цена неверного действия — регресс функциональности и новый спор о том, какая оптимизация вообще дала эффект.'),
p('Полевой разбор должен свести изменение output к input. Для этого сохраняем два metafile или отчёта сборки, нормализуем пути и считаем delta bytes для каждого входа. Затем проверяем, в какой output попал input и почему. Только после этого выбираем действие: убрать импорт, изменить split, проверить dependency version или оставить рост как осознанную стоимость.'),
h2('Сначала ищем изменение, а не виновника'),
p('esbuild metafile содержит inputs и outputs, включая количество байт input, попавших в output. Это удобнее, чем смотреть только на размер файла: можно увидеть, что добавилось, исчезло или изменилось. Но bytes в metafile — размер вклада в артефакт, а не обязательно размер передачи по сети и не время выполнения. Для пользовательской скорости нужны отдельные browser measurements.'),
p('Source map помогает связать сжатый или преобразованный JavaScript с исходным модулем. При этом карта сама может быть большой и не должна случайно попасть в production response. HTTP SourceMap header и annotation имеют правила, по которым DevTools находит карту. Проверяйте, что путь доступен только в нужной среде и что карта соответствует именно этому bundle.'),
figure('/assets/editorial/2027/build-evolution-2027-evidence-handoff-loop.svg', 'Цикл разбора роста bundle: сравнить metafile, найти input с delta, проверить chunk и source map, затем повторить сборку.', 'Схема ведёт от общего симптома к конкретному input. Изменение кода выполняется после проверки состава артефакта, а не по размеру одного файла.'),
table('Матрица разбора роста bundle', ['Наблюдение', 'Где искать', 'Проверка', 'Действие'], [
['Новый большой input', 'metafile.inputs', 'кто импортирует модуль', 'разделить или удалить импорт'],
['Старый input вырос', 'delta bytes и chunk', 'tree-shaking и настройки minify', 'проверить export и plugin'],
['Дубликат зависимости', 'пути разных версий', 'resolver и lockfile', 'свести версию или alias'],
['Source map выросла', 'output и response', 'включён ли dev artifact', 'разделить delivery и debug'],
['Metafile стабилен', 'browser resource timing', 'gzip/brotli, cache, transfer', 'измерить пользовательский путь'],
]),
h2('Учебный diff двух metafile'),
p('Функция принимает минимальный фрагмент esbuild-подобного JSON: имя input и его bytes. Она объединяет имена из двух сборок, считает delta и сортирует рост сверху. Так инженер получает список конкретных файлов для code search. Данные ниже учебные; в рабочем отчёте рядом с diff сохраняйте commit, command, target и output name.'),
code(`import { summarizeBundleDiff } from './upgrade-2027-04.mjs';
const before = { inputs: {
'src/app.js': { bytes: 12000 },
'src/table.js': { bytes: 8000 },
'node_modules/date.js': { bytes: 5000 },
} };
const after = { inputs: {
'src/app.js': { bytes: 12000 },
'src/table.js': { bytes: 11000 },
'node_modules/date.js': { bytes: 5000 },
'node_modules/chart.js': { bytes: 42000 },
} };
console.log(summarizeBundleDiff(before, after));
// chart.js +42000; table.js +3000`),
p('Результат даёт два адреса: новая chart.js и выросшая table.js. Это ещё не решение. Для chart.js нужно найти entry/import и проверить split; для table.js — посмотреть, почему изменился export или transform. Если общий output вырос меньше суммы input delta из-за компрессии и tree-shaking, это нормально: diff направляет исследование, но не заменяет финальный artefact и браузерный замер.'),
h2('От bytes к пользовательскому эффекту'),
p('Большой input может не попасть в первый экран, а маленький модуль — блокировать критический маршрут. Поэтому после статического diff смотрите chunk graph и network resource timing. Transfer size зависит от compression и cache; decoded body size — другой показатель. Cross-origin ресурс может вернуть нулевой transferSize без Timing-Allow-Origin. Эти ограничения нужно написать рядом с числом, иначе bytes начинают выглядеть как latency.'),
p('В source map ищите исходный модуль, но проверяйте соответствие commit. Старая карта при новом bundle создаёт ложную навигацию в DevTools и увеличивает время разбора следующей ошибки. Для production обычно ограничивают доступ к картам или публикуют их в отдельном хранилище с контролем прав. Это уже часть delivery contract, а не косметика сборки.'),
h2('Действия по порядку'),
ol([
'Сохранить два metafile для одинакового input и убедиться, что output names и mode сопоставимы.',
'Посчитать delta по inputs и outputs, затем найти import path, chunk и owner изменившегося модуля.',
'Проверить lockfile, duplicate versions, tree-shaking, plugin transform и minify настройки.',
'Сверить source map с commit и отдельно проверить, не попала ли debug-карта в пользовательскую доставку.',
'Измерить transfer, decoded size, cache и время загрузки критического маршрута; только потом оценивать эффект оптимизации.',
]),
h2('Ограничения и следующий шаг'),
p('Metafile показывает структуру конкретного bundler и не знает о поведении браузера, compression, CDN и cache. Delta bytes не является p95 и не гарантирует изменение FCP. Source map может быть недоступна или намеренно скрыта, поэтому связь с исходником иногда требует другого артефакта. Статический diff также не видит работу runtime и dynamic import до его выполнения.'),
p('Следующий шаг — добавить автоматический budget по критическим output и список разрешённых изменений. Для каждого превышения pipeline должен печатать top inputs, commit и команду воспроизведения. Тогда фраза «bundle вырос» превращается в короткий проверяемый маршрут: какой файл добавился, в какой chunk попал и какой пользовательский ресурс изменился.'),
], [
{ key: 'esbuild', use: 'Metafile JSON и поля inputs/outputs/bytes используются для вычисления diff состава bundle.', boundary: 'Документация предупреждает, что текстовый analyze предназначен людям; JSON не измеряет браузерную доставку.' },
{ key: 'sourceMap', use: 'Правило SourceMap header и связь DevTools с исходным кодом используются для проверки карты.', boundary: 'MDN не подтверждает доступность карты и не измеряет размер или скорость конкретного ресурса.' },
{ key: 'webpackCache', use: 'Contenthash и стабильная структура output используются как контекст повторяемого артефакта.', boundary: 'Руководство webpack не описывает esbuild metafile и пользовательский performance budget.' },
]);
export const revisions = Object.freeze([practice, mechanism, field]);
export function verifyRevisionsAgainstFixture() {
const checks = revisions.map((item) => {
const body = bodyText(item.contentHtml);
return body.length >= 5000 && body.length <= 15000 && /