279 lines
38 KiB
JavaScript
279 lines
38 KiB
JavaScript
import { createHash } from 'node:crypto';
|
||
|
||
function escapeHtml(value) {
|
||
return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||
}
|
||
|
||
const p = (text) => '<p>' + text + '</p>';
|
||
const h2 = (text) => '<h2>' + text + '</h2>';
|
||
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
|
||
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + escapeHtml(alt) + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((cell) => '<th scope="col">' + cell + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
|
||
|
||
function plainText(html) {
|
||
return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function bodyText(html) {
|
||
return plainText(html.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||
}
|
||
|
||
const REFERENCES = Object.freeze({
|
||
webpackCache: {
|
||
title: 'webpack 5 Guide: Caching',
|
||
url: 'https://webpack.js.org/guides/caching/',
|
||
version: 'webpack 5 guide, checked 31 July 2026',
|
||
},
|
||
webpackConfig: {
|
||
title: 'webpack 5 Configuration: cache',
|
||
url: 'https://webpack.js.org/configuration/cache/',
|
||
version: 'webpack 5 configuration reference, checked 31 July 2026',
|
||
},
|
||
esbuild: {
|
||
title: 'esbuild API: Metafile',
|
||
url: 'https://esbuild.github.io/api/#metafile',
|
||
version: 'esbuild API documentation, checked 31 July 2026',
|
||
},
|
||
vite: {
|
||
title: 'Vite Guide: Dependency Pre-Bundling',
|
||
url: 'https://vite.dev/guide/dep-pre-bundling.html',
|
||
version: 'Vite guide, checked 31 July 2026',
|
||
},
|
||
sourceMap: {
|
||
title: 'MDN: SourceMap HTTP header',
|
||
url: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/SourceMap',
|
||
version: 'MDN Web Docs, page modified 21 November 2025',
|
||
},
|
||
});
|
||
|
||
function sources(entries) {
|
||
return '<ul>' + entries.map(({ key, use, boundary }) => {
|
||
const reference = REFERENCES[key];
|
||
return '<li><a href="' + reference.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(reference.title) + '</a> — версия и дата: ' + escapeHtml(reference.version) + '. Применение: ' + escapeHtml(use) + ' Граница: ' + escapeHtml(boundary) + '</li>';
|
||
}).join('') + '</ul>';
|
||
}
|
||
|
||
export function compareBuildRuns(input) {
|
||
const baseline = input?.baseline;
|
||
const candidate = input?.candidate;
|
||
if (!baseline || !candidate || baseline.inputFingerprint !== candidate.inputFingerprint) return { comparable: false, reason: 'входы сборки различаются' };
|
||
const deltaMs = candidate.durationMs - baseline.durationMs;
|
||
const deltaBytes = candidate.outputBytes - baseline.outputBytes;
|
||
return { comparable: true, deltaMs, deltaBytes, faster: deltaMs < 0, smaller: deltaBytes < 0 };
|
||
}
|
||
|
||
export function makeDependencyCacheKey(input) {
|
||
const payload = [input?.lockfile, input?.config, input?.runtime, input?.sourceDigest].map((value) => String(value ?? '')).join('\n');
|
||
return createHash('sha256').update(payload).digest('hex').slice(0, 16);
|
||
}
|
||
|
||
export function summarizeBundleDiff(before, after) {
|
||
const names = new Set([...Object.keys(before?.inputs ?? {}), ...Object.keys(after?.inputs ?? {})]);
|
||
return [...names].map((name) => {
|
||
const previous = Number(before?.inputs?.[name]?.bytes ?? 0);
|
||
const current = Number(after?.inputs?.[name]?.bytes ?? 0);
|
||
return { name, before: previous, after: current, delta: current - previous };
|
||
}).filter((item) => item.delta !== 0).sort((left, right) => right.delta - left.delta);
|
||
}
|
||
|
||
function revision(meta, parts, referenceEntries) {
|
||
const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries);
|
||
const proseLength = bodyText(contentHtml).length;
|
||
if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength);
|
||
return Object.freeze({ ...meta, contentHtml, proseLength });
|
||
}
|
||
|
||
const practice = revision({
|
||
slug: 'editorial-2027-04-practice-build-evolution',
|
||
title: 'Сравнение frontend-сборок: зафиксировать вход до секунд',
|
||
categories: ['Frontend', 'Сборка'],
|
||
cover: '/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg',
|
||
excerpt: 'Как сравнить baseline и candidate так, чтобы разница времени и размера относилась к одному входу.',
|
||
readingMinutes: 15,
|
||
}, [
|
||
p('Проблема сравнения frontend-сборок обычно начинается с красивой цифры: новый инструмент собрал проект быстрее на локальной машине. Но у baseline мог быть холодный cache, другой lockfile, другой набор entry-файлов или неполный output. Цена неверного сравнения — перенос нескольких дней работы ради ускорения, которое исчезает при первом чистом запуске. Сначала фиксируем вход, потом измеряем секунды.'),
|
||
p('У сборки есть как минимум пять независимых частей: исходный граф, конфигурация, окружение, состояние cache и состав артефакта. Изменение любой части меняет смысл результата. Поэтому один замер duration не отвечает на вопрос «какой сборщик лучше». Он отвечает только на вопрос «сколько занял этот запуск при таких условиях». Таблица условий должна быть частью отчёта рядом с числом.'),
|
||
h2('Сравниваем не инструменты, а одинаковую работу'),
|
||
p('Baseline и candidate должны получить один commit, одинаковый lockfile, одинаковые entry и одинаковую команду выхода. Если одна сборка делает minify, а другая — development output, секунды нельзя складывать в общий вывод. Точно так же размер файла сравним только при одинаковом составе chunks, compression и правилах публикации. Иначе оптимизация одного слоя будет выглядеть как преимущество всего pipeline.'),
|
||
p('В webpack contenthash и deterministic module ids помогают сохранить стабильные имена и локализовать изменение, но это не делает два разных toolchain эквивалентными. В esbuild metafile можно посмотреть, какие input вошли в output и сколько байт они заняли. Оба документа подсказывают важный порядок: записать конфигурацию и состав результата, а не оставлять только итоговую цифру.'),
|
||
figure('/assets/editorial/2027/build-evolution-2027-configuration-timeline.svg', 'Последовательность сравнения frontend-сборки: один вход, одинаковая конфигурация, отдельный cache state, замер и состав артефакта.', 'Схема показывает, что секундомер включается после фиксации условий. Изменение входа возвращает сравнение к началу.'),
|
||
table('Минимальная карточка сравнения сборки', ['Слой', 'Зафиксировать', 'Пример различия', 'Почему это важно'], [
|
||
['Input', 'commit, lockfile, entry', 'feature branch вместо tag', 'меняется dependency graph'],
|
||
['Config', 'mode, flags, plugins', 'minify включён только у candidate', 'числа относятся к разным работам'],
|
||
['Environment', 'Node, OS, CPU', 'разные версии Node', 'разный runtime и cache'],
|
||
['Cache', 'cold/warm, directory, key', 'candidate повторный', 'ускорение может быть cache effect'],
|
||
['Output', 'chunks, source maps, compression', 'один bundle не учтён', 'размер и delivery несопоставимы'],
|
||
]),
|
||
h2('Учебное сравнение двух запусков'),
|
||
p('Функция ниже получает два уже записанных запуска. Она сначала проверяет inputFingerprint, затем считает разницу времени и размера. Значения учебного набора не являются результатом измерения проекта; они показывают формат карточки и ожидаемый результат. Если fingerprint различается, функция возвращает несопоставимость и не вычисляет победителя.'),
|
||
code(`import { compareBuildRuns } from './upgrade-2027-04.mjs';
|
||
|
||
const baseline = { inputFingerprint: 'src-42', durationMs: 420, outputBytes: 180000 };
|
||
const candidate = { inputFingerprint: 'src-42', durationMs: 380, outputBytes: 176000 };
|
||
const changedInput = { inputFingerprint: 'src-43', durationMs: 350, outputBytes: 174000 };
|
||
|
||
console.log(compareBuildRuns({ baseline, candidate }));
|
||
console.log(compareBuildRuns({ baseline, candidate: changedInput }));
|
||
// comparable: true, deltaMs: -40, deltaBytes: -4000
|
||
// comparable: false, reason: 'входы сборки различаются'`),
|
||
p('Первый результат говорит только о заданном учебном наборе: candidate быстрее на 40 мс и меньше на 4000 байт при одинаковом fingerprint. Второй запуск не превращается в преимущество, даже если его цифры лучше, потому что input другой. В реальном отчёте добавьте число повторов, медиану или p95, cold/warm state и полный список команд.'),
|
||
h2('Секунды требуют распределения, а не одного числа'),
|
||
p('Один запуск может попасть на сборку cache, фоновое обновление пакетов или шум CPU. Для локального сравнения отделяйте холодный и тёплый запуск, а в CI фиксируйте runner. Не смешивайте время установки зависимостей с временем bundling, если цель — сравнить именно сборку. Если пользовательский эффект — время до первого экрана, добавьте отдельный браузерный замер; build duration является только одной частью пути.'),
|
||
p('Размер также нужно разложить. Общий bundle вырос — это симптом, но причина может быть в новой библиотеке, переставшем работать tree-shaking, source map или duplicated dependency. Метafile и список chunks дают материал для следующего шага. Без него команда спорит о сборщике, хотя изменение находится в одном input-модуле.'),
|
||
h2('Действия по порядку'),
|
||
ol([
|
||
'Зафиксировать commit, lockfile, entry points, mode, flags, Node/OS и runner.',
|
||
'Очистить или явно сохранить cache state; отдельно записать cold и warm серии.',
|
||
'Запустить baseline и candidate на одинаковом наборе и сохранить raw output, duration и exit code.',
|
||
'Сравнить fingerprint, распределение времени, chunks и input bytes; несовпоставимый запуск исключить.',
|
||
'Сформулировать вывод с единицами и границей: «на этом наборе и runner»; затем проверить пользовательский путь отдельным замером.',
|
||
]),
|
||
h2('Ограничения и следующий шаг'),
|
||
p('Учебная функция не запускает сборщики и не устраняет шум измерений. Fingerprint должен включать все входы, которые влияют на output; короткий hash без методики легко создаёт ложное совпадение. Разные плагины могут менять дерево зависимостей, а одинаковый размер не означает одинаковое выполнение в браузере. Нельзя обещать эффект для пользователей по двум локальным числам.'),
|
||
p('Следующий шаг — сделать небольшой JSON-отчёт с условиями, серией запусков и ссылкой на metafile. Проверьте его на одном изменении, которое добавляет известный модуль. Готовность сравнения определяется тем, что другой инженер может повторить вход, увидеть различие и понять, к какому слою относится изменение.'),
|
||
], [
|
||
{ key: 'webpackCache', use: 'Contenthash, deterministic module ids и условия сравнения взяты из руководства webpack 5.', boundary: 'Документация не даёт результатов конкретного проекта и не заменяет серию измерений на одном runner.' },
|
||
{ key: 'esbuild', use: 'Metafile используется как JSON-источник inputs, outputs и bytesInOutput для разбора артефакта.', boundary: 'Формат метафайла описывает состав сборки, но не пользовательскую скорость загрузки.' },
|
||
{ key: 'webpackConfig', use: 'Режимы memory/filesystem cache учитываются как отдельная часть условий запуска.', boundary: 'Страница не определяет cache state конкретного CI и не сравнивает инструменты между собой.' },
|
||
]);
|
||
|
||
const mechanism = revision({
|
||
slug: 'editorial-2027-04-mechanism-build-evolution',
|
||
title: 'Кэш сборки: ключ определяет, что именно вы повторяете',
|
||
categories: ['Frontend', 'Сборка'],
|
||
cover: '/assets/editorial/2027/build-evolution-2027-comparable-conditions-matrix.svg',
|
||
excerpt: 'Почему cache hit не равен корректному результату и какие входы должны участвовать в ключе оптимизации.',
|
||
readingMinutes: 16,
|
||
}, [
|
||
p('Проблема cache в сборке появляется в двух противоположных видах. Кэш всегда промахивается, и команда считает сборку медленной. Или кэш попадает, но после изменения lockfile, конфигурации или linked package остаётся старый output. Цена одинаково неприятна: либо CI тратит время на повторную работу, либо браузер и разработчик видят результат, который не соответствует исходникам.'),
|
||
p('Кэш не хранит абстрактное «состояние проекта». Он хранит результат конкретной функции от входов. Если ключ не включает lockfile, релевантную конфигурацию, runtime и исходный граф, система не может понять, что результат устарел. Если ключ включает лишние шумные поля, повторяемость исчезает. Поэтому механизм нужно обсуждать как контракт ключа, значения и правила invalidation.'),
|
||
h2('Что входит в ключ'),
|
||
p('Vite описывает несколько источников invalidation pre-bundling: lockfile, patches, релевантные поля конфигурации и <code>NODE_ENV</code>. Для 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 неизвестна, сначала расширьте диагностику ключа, а не включайте постоянный <code>force</code>.'),
|
||
], [
|
||
{ 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 <code>SourceMap</code> 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 && /<table>/.test(item.contentHtml) && /<figure>/.test(item.contentHtml) && /<pre><code>/.test(item.contentHtml) && /<ol>/.test(item.contentHtml) && !/(synthetic-plan-hand-off|productionEffect|future-only|plan\/scenario|source cutoff|not-collected|not-attempted|future owner|развитие автора)/i.test(body);
|
||
});
|
||
const compared = compareBuildRuns({ baseline: { inputFingerprint: 'a', durationMs: 10, outputBytes: 20 }, candidate: { inputFingerprint: 'a', durationMs: 9, outputBytes: 19 } });
|
||
const cacheChanged = makeDependencyCacheKey({ lockfile: 'a', config: 'b', runtime: 'c', sourceDigest: 'd' }) !== makeDependencyCacheKey({ lockfile: 'z', config: 'b', runtime: 'c', sourceDigest: 'd' });
|
||
const diffFound = summarizeBundleDiff({ inputs: { a: { bytes: 1 } } }, { inputs: { a: { bytes: 2 } } }).length === 1;
|
||
const fixtureOk = compared.comparable && compared.faster && cacheChanged && diffFound;
|
||
return Object.freeze({ passed: checks.filter(Boolean).length + (fixtureOk ? 1 : 0), total: checks.length + 1, accepted: checks.every(Boolean) && fixtureOk, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) });
|
||
}
|
||
|
||
if (process.argv.includes('--verify-fixture')) {
|
||
const result = verifyRevisionsAgainstFixture();
|
||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||
if (!result.accepted) process.exitCode = 1;
|
||
}
|
||
|
||
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');
|