{ "index": 213, "slug": "editorial-2022-02-practice-performance-budget", "title": "Бюджет производительности: как не спрятать регрессию за общим PASS", "excerpt": "Как описать один пользовательский путь, разделить его бюджет на именованные части и остановить изменение, которое превышает допуск, даже если общий результат ещё выглядит зелёным.", "contentHtml": "
Проблема начинается с маленького изменения: на странице оформления следующий шаг появляется позже, чем ожидает пользователь. В отчёте при этом стоит зелёный PASS: общий размер страницы и суммарное время не вышли за предел. Команда добавляет виджет, стиль и обработчик. Каждый коммит проходит проверку, но через несколько релизов один участок пути забирает весь запас.
\nЦена ошибки — не абстрактная «медленная страница». Пользователь дольше ждёт перехода, повторяет действие или закрывает вкладку. Инженер ищет причину среди уже принятых изменений. Если budget проверяет только сумму, он не отвечает на главный вопрос: какой компонент потратил запас и что нужно остановить?
\nВывод статьи простой: бюджет производительности должен описывать конкретный пользовательский путь, условия измерения и несколько именованных ограничений. Общая сумма полезна как дополнительный сигнал. Она не должна отменять провал отдельной части.
\nPerformance budget — это набор заранее названных лимитов для метрик, связанных с производительностью. В него можно включить размер страницы или скриптов, число HTTP-запросов и время загрузки в заданном сетевом сценарии. Это не универсальная норма: лимит имеет смысл только вместе с путём, данными и условиями измерения.
\nВ учебном примере входом служат маршрут /training/checkout/review и сценарий anonymous-cart-with-one-item. Это условный маршрут, а не результат запуска настоящего сайта. Другой товар, авторизация, локаль или feature flag могут изменить набор ресурсов, поэтому их нельзя молча смешивать с тем же budget.
Рядом с числами запишите версию сборки, сеть, состояние cache, устройство или профиль CPU, способ запуска и источник метрики. Если условие не измерялось, его нельзя восстанавливать по одному итоговому числу.
\n| Часть контракта | Пример значения | Что проверяет | Чего не доказывает |
|---|---|---|---|
| Route и scenario | /training/checkout/review, одна корзина | Сравнивает один и тот же вход | Качество всех страниц |
| Conditions | Сборка, сеть, cache и устройство записаны | Показывает сопоставимость замеров | Поведение другой среды |
| Components | document, style, script, render | Находит участок, который забрал запас | Автоматически не объясняет причину |
| Limit и tolerance | Допуск у каждого имени | Даёт правило остановки | Универсальную норму продукта |
| Total | Вторичный предел суммы | Замечает общий рост | Разрешение на провал компонента |
Возьмём четыре части с лимитами 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.
Это перенос затрат. Остальные части не стали дешевле из-за тяжёлого скрипта. Если проверять только сумму, рост в одном месте можно случайно компенсировать уменьшением в другом. Для пользователя это не всегда равноценная замена: лишний JavaScript может задержать обработчик, а уменьшение картинки не уберёт работу на основном потоке.
\nДо сравнения candidate проверьте не только значения, но и сам вход. Код работает с frozen plain objects в памяти. Поля documentTicks, styleTicks, scriptTicks и renderTicks — условные единицы, а не названия браузерных метрик.
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| Симптом | Гипотеза | Проверка | Действие |
|---|---|---|---|
| Total проходит, component падает | Рост скрыт внутри суммы | Сравнить каждую часть с её allowed | Остановить изменение и найти владельца части |
| Два запуска дают разные выводы | Не совпали сеть, cache, CPU или данные | Сопоставить conditions и route | Повторить в фиксированных условиях или разделить сценарии |
| Число выросло, причина неизвестна | Сохранён результат без состава | Проверить размер chunk, запросы и источник | Добавить детализацию, а не повышать лимит вслепую |
| Провал виден только у одной локали | Сценарии смешаны в одном job | Запустить тот же route отдельно | Включить локаль в scenario или выделить budget |
| Старый snapshot проходит после смены сборщика | Baseline получен в другой среде | Сравнить версию и формат данных | Создать baseline с причиной замены |
| После каждого FAIL повышают лимит | Лимит используют для удаления сигнала | Проверить ресурс и обоснование | Устранить рост или зафиксировать исключение |
Размер ресурсов и число запросов удобно проверять на этапе сборки. Они рано показывают состав артефакта и помогают остановить тяжёлую зависимость. Но одинаковый вес не гарантирует одинаковое восприятие: критический ресурс может прийти позже, а порядок загрузки меняет полезный результат.
\nДля времени навигации браузер предоставляет Navigation Timing. Через PerformanceNavigationTiming можно получить запись текущего документа и разобрать интервалы ответа сервера, DOMContentLoaded и load. Эти значения описывают навигацию; они не превращают условные ticks из примера в реальные миллисекунды.
Для отдельных ресурсов используется PerformanceResourceTiming. Он даёт временную шкалу загрузки и сведения о размере ресурса. Для cross-origin ресурсов подробные поля могут быть недоступны без Timing-Allow-Origin. Отсутствие данных нельзя принять за нулевую задержку.
Не называйте сумму размеров «временем до интерактивности» и не называйте один синтетический snapshot полевой метрикой. В budget укажите класс источника, окно наблюдения, сегмент устройств и способ агрегации.
\nBudget не исправляет медленную страницу. Он превращает выбранное ожидание в сигнал. Плохой route, неверный baseline или неописанные conditions дадут точный ответ на неверный вопрос.
\nЛимиты зависят от продукта. Экран с фотографиями, форма оплаты и текстовая статья имеют разный состав ресурсов. Одного числа для всех страниц недостаточно. Разделяйте budgets по типу пути, но не создавайте отдельный лимит для каждого случайного варианта: сигнал распадётся и станет необслуживаемым.
\nЛабораторные данные помогают сравнивать изменения в одинаковой среде. Полевые данные показывают разброс устройств, сетей и поведения посетителей. Учебный код не содержит ни тех, ни других данных. Он проверяет только логику контракта.
\nИногда рост оправдан функцией. Тогда измените budget вместе с причиной, владельцем и способом повторной проверки. Не повышайте лимит только для зелёного статуса.
\nКонтракт готов, если другой инженер может ответить на четыре вопроса: какой путь проверяется, в каких условиях, какая часть превысила допуск и какое действие следует выполнить. Автоматическая проверка должна завершаться FAIL, когда один компонент превышает свой allowed, даже если total остаётся внутри общего предела.
\nМинимальный набор таков: baseline и candidate имеют одинаковые route, scenario и conditions; состав полей совпадает; каждое значение неотрицательно и конечно; результат печатает component verdict и total verdict; положительный пример проходит; отрицательный пример падает на scriptTicks. Это проверяет механизм, но не заявляет эффект на реальном трафике.
Timing-Allow-Origin.ticks в browser measurement.