{ "index": 213, "slug": "editorial-2022-02-practice-performance-budget", "title": "Бюджет производительности: как не спрятать регрессию за общим PASS", "excerpt": "Как описать один пользовательский путь, разделить его бюджет на именованные части и остановить изменение, которое превышает допуск, даже если общий результат ещё выглядит зелёным.", "contentHtml": "

Проблема начинается с маленького изменения: на странице оформления следующий шаг появляется позже, чем ожидает пользователь. В отчёте при этом стоит зелёный PASS: общий размер страницы и суммарное время не вышли за предел. Команда добавляет виджет, стиль и обработчик. Каждый коммит проходит проверку, но через несколько релизов один участок пути забирает весь запас.

\n

Цена ошибки — не абстрактная «медленная страница». Пользователь дольше ждёт перехода, повторяет действие или закрывает вкладку. Инженер ищет причину среди уже принятых изменений. Если budget проверяет только сумму, он не отвечает на главный вопрос: какой компонент потратил запас и что нужно остановить?

\n

Вывод статьи простой: бюджет производительности должен описывать конкретный пользовательский путь, условия измерения и несколько именованных ограничений. Общая сумма полезна как дополнительный сигнал. Она не должна отменять провал отдельной части.

\n

Что именно ограничивает бюджет

\n

Performance budget — это набор заранее названных лимитов для метрик, связанных с производительностью. В него можно включить размер страницы или скриптов, число HTTP-запросов и время загрузки в заданном сетевом сценарии. Это не универсальная норма: лимит имеет смысл только вместе с путём, данными и условиями измерения.

\n

В учебном примере входом служат маршрут /training/checkout/review и сценарий anonymous-cart-with-one-item. Это условный маршрут, а не результат запуска настоящего сайта. Другой товар, авторизация, локаль или feature flag могут изменить набор ресурсов, поэтому их нельзя молча смешивать с тем же budget.

\n

Рядом с числами запишите версию сборки, сеть, состояние cache, устройство или профиль CPU, способ запуска и источник метрики. Если условие не измерялось, его нельзя восстанавливать по одному итоговому числу.

\n
Контракт бюджета для одного пользовательского пути
Часть контрактаПример значенияЧто проверяетЧего не доказывает
Route и scenario/training/checkout/review, одна корзинаСравнивает один и тот же входКачество всех страниц
ConditionsСборка, сеть, cache и устройство записаныПоказывает сопоставимость замеровПоведение другой среды
Componentsdocument, style, script, renderНаходит участок, который забрал запасАвтоматически не объясняет причину
Limit и toleranceДопуск у каждого имениДаёт правило остановкиУниверсальную норму продукта
TotalВторичный предел суммыЗамечает общий ростРазрешение на провал компонента
\n

Почему одна сумма даёт ложный PASS

\n

Возьмём четыре части с лимитами 26, 17, 30 и 22 условные единицы. Их сумма равна 95. В baseline значения — 24, 15, 26 и 20. Candidate меняет только script: 26 становится 34. Итог candidate равен 93, поэтому total проходит. Но для script allowed равен 32: limit 30 плюс tolerance 2. Именованный компонент должен получить FAIL.

\n

Это перенос затрат. Остальные части не стали дешевле из-за тяжёлого скрипта. Если проверять только сумму, рост в одном месте можно случайно компенсировать уменьшением в другом. Для пользователя это не всегда равноценная замена: лишний JavaScript может задержать обработчик, а уменьшение картинки не уберёт работу на основном потоке.

\n
\"Схема
Учебная схема разделяет именованные проверки и общий предел. Она не показывает реальный trace, браузерный запуск или полевую метрику.
\n

Воспроизводимый контракт сравнения

\n

До сравнения candidate проверьте не только значения, но и сам вход. Код работает с frozen plain objects в памяти. Поля documentTicks, styleTicks, scriptTicks и renderTicks — условные единицы, а не названия браузерных метрик.

\n
const contract = Object.freeze({\n  route: '/training/checkout/review',\n  scenario: 'anonymous-cart-with-one-item',\n  conditions: Object.freeze({\n    network: 'slow-3g',\n    cache: 'cold',\n    device: 'mobile',\n  }),\n  components: Object.freeze({\n    documentTicks: Object.freeze({ limit: 26, tolerance: 1 }),\n    styleTicks: Object.freeze({ limit: 17, tolerance: 1 }),\n    scriptTicks: Object.freeze({ limit: 30, tolerance: 2 }),\n    renderTicks: Object.freeze({ limit: 22, tolerance: 1 }),\n  }),\n  totalTicks: Object.freeze({ limit: 95, tolerance: 3 }),\n});\n\nconst baseline = {\n  route: contract.route,\n  scenario: contract.scenario,\n  conditions: { ...contract.conditions },\n  values: {\n    documentTicks: 24,\n    styleTicks: 15,\n    scriptTicks: 26,\n    renderTicks: 20,\n  },\n};\n\nconst candidate = {\n  ...baseline,\n  values: { ...baseline.values, scriptTicks: 34 },\n};\n\nfunction hasExactKeys(value, expectedKeys) {\n  if (!value || typeof value !== 'object') return false;\n  const actualKeys = Object.keys(value);\n  return actualKeys.length === expectedKeys.length\n    && expectedKeys.every((key) => actualKeys.includes(key));\n}\n\nfunction sameRecord(left, right) {\n  const keys = Object.keys(right);\n  return hasExactKeys(left, keys)\n    && keys.every((key) => left[key] === right[key]);\n}\n\nfunction checkBudget(snapshot, expected) {\n  const names = Object.keys(expected.components);\n  if (snapshot.route !== expected.route\n    || snapshot.scenario !== expected.scenario\n    || !sameRecord(snapshot.conditions, expected.conditions)) {\n    return { status: 'INVALID', reason: 'measurement-contract-mismatch' };\n  }\n\n  if (!snapshot.values || !hasExactKeys(snapshot.values, names)\n    || !names.every((name) =>\n      Number.isFinite(snapshot.values[name]) && snapshot.values[name] >= 0\n    )) {\n    return { status: 'INVALID', reason: 'invalid-component-values' };\n  }\n\n  const components = names.map((name) => {\n    const rule = expected.components[name];\n    const allowed = rule.limit + rule.tolerance;\n    const value = snapshot.values[name];\n    return { name, value, allowed, pass: value <= allowed };\n  });\n  const total = names.reduce((sum, name) => sum + snapshot.values[name], 0);\n  const totalAllowed = expected.totalTicks.limit\n    + expected.totalTicks.tolerance;\n  const totalPass = total <= totalAllowed;\n\n  return {\n    status: components.every((item) => item.pass) && totalPass\n      ? 'PASS'\n      : 'FAIL',\n    components,\n    total,\n    totalAllowed,\n    totalPass,\n  };\n}\n\nconsole.log(checkBudget(candidate, contract));\n// status: 'FAIL', scriptTicks: 34 > 32, totalPass: true
\n

Функция вернёт FAIL по scriptTicks: 34 больше allowed 32. Сумма равна 93 и меньше totalAllowed 98, но totalPass: true не меняет общего verdict. Если route, scenario, conditions или состав полей расходятся, результатом будет INVALID, а не ложная регрессия.

\n

Проверка не измеряет браузер. Она проверяет инвариант контракта: известные неотрицательные числа сравниваются по отдельности и затем суммируются. В рабочем проекте нужно отдельно получить эти числа, сохранить источник и связать каждое поле с владельцем измерения.

\n

Симптомы и ограниченное действие

\n
Диагностика регрессии бюджета
СимптомГипотезаПроверкаДействие
Total проходит, component падаетРост скрыт внутри суммыСравнить каждую часть с её allowedОстановить изменение и найти владельца части
Два запуска дают разные выводыНе совпали сеть, cache, CPU или данныеСопоставить conditions и routeПовторить в фиксированных условиях или разделить сценарии
Число выросло, причина неизвестнаСохранён результат без составаПроверить размер chunk, запросы и источникДобавить детализацию, а не повышать лимит вслепую
Провал виден только у одной локалиСценарии смешаны в одном jobЗапустить тот же route отдельноВключить локаль в scenario или выделить budget
Старый snapshot проходит после смены сборщикаBaseline получен в другой средеСравнить версию и формат данныхСоздать baseline с причиной замены
После каждого FAIL повышают лимитЛимит используют для удаления сигналаПроверить ресурс и обоснованиеУстранить рост или зафиксировать исключение
\n

Как выбрать источник измерения

\n

Размер ресурсов и число запросов удобно проверять на этапе сборки. Они рано показывают состав артефакта и помогают остановить тяжёлую зависимость. Но одинаковый вес не гарантирует одинаковое восприятие: критический ресурс может прийти позже, а порядок загрузки меняет полезный результат.

\n

Для времени навигации браузер предоставляет Navigation Timing. Через PerformanceNavigationTiming можно получить запись текущего документа и разобрать интервалы ответа сервера, DOMContentLoaded и load. Эти значения описывают навигацию; они не превращают условные ticks из примера в реальные миллисекунды.

\n

Для отдельных ресурсов используется PerformanceResourceTiming. Он даёт временную шкалу загрузки и сведения о размере ресурса. Для cross-origin ресурсов подробные поля могут быть недоступны без Timing-Allow-Origin. Отсутствие данных нельзя принять за нулевую задержку.

\n

Не называйте сумму размеров «временем до интерактивности» и не называйте один синтетический snapshot полевой метрикой. В budget укажите класс источника, окно наблюдения, сегмент устройств и способ агрегации.

\n

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

\n
  1. Выберите один путь, связанный с действием пользователя, и запишите route, scenario и ожидаемый результат.
  2. Назначьте источник каждого числа: размер артефакта, browser timing, synthetic run или field data. Не смешивайте эти классы.
  3. Зафиксируйте версию сборки, сеть, cache, устройство, данные и feature flags.
  4. Разделите budget на части, которыми можно управлять отдельно: document, style, script, render или другой состав для этого пути.
  5. Снимите baseline и сохраните его рядом с conditions. Не называйте один запуск истиной без диапазона и повторов.
  6. Сначала проверяйте каждую часть, затем total. В отчёте печатайте оба verdict.
  7. Прогоните положительный и отрицательный сценарии: один компонент должен превысить свой allowed при зелёном total.
  8. Для каждого FAIL назначьте действие: уменьшить ресурс, убрать запрос, отложить код, изменить scenario или пересмотреть контракт с причиной.
\n

Ограничения

\n

Budget не исправляет медленную страницу. Он превращает выбранное ожидание в сигнал. Плохой route, неверный baseline или неописанные conditions дадут точный ответ на неверный вопрос.

\n

Лимиты зависят от продукта. Экран с фотографиями, форма оплаты и текстовая статья имеют разный состав ресурсов. Одного числа для всех страниц недостаточно. Разделяйте budgets по типу пути, но не создавайте отдельный лимит для каждого случайного варианта: сигнал распадётся и станет необслуживаемым.

\n

Лабораторные данные помогают сравнивать изменения в одинаковой среде. Полевые данные показывают разброс устройств, сетей и поведения посетителей. Учебный код не содержит ни тех, ни других данных. Он проверяет только логику контракта.

\n

Иногда рост оправдан функцией. Тогда измените budget вместе с причиной, владельцем и способом повторной проверки. Не повышайте лимит только для зелёного статуса.

\n

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

\n

Контракт готов, если другой инженер может ответить на четыре вопроса: какой путь проверяется, в каких условиях, какая часть превысила допуск и какое действие следует выполнить. Автоматическая проверка должна завершаться FAIL, когда один компонент превышает свой allowed, даже если total остаётся внутри общего предела.

\n

Минимальный набор таков: baseline и candidate имеют одинаковые route, scenario и conditions; состав полей совпадает; каждое значение неотрицательно и конечно; результат печатает component verdict и total verdict; положительный пример проходит; отрицательный пример падает на scriptTicks. Это проверяет механизм, но не заявляет эффект на реальном трафике.

\n

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

\n" }