{ "index": 213, "slug": "editorial-2022-02-practice-performance-budget", "title": "Бюджет производительности: как не спрятать регрессию за общим PASS", "excerpt": "Как описать один пользовательский путь, разделить его бюджет на именованные части и остановить изменение, которое превышает допуск, даже если общий результат ещё выглядит зелёным.", "contentHtml": "
На странице оформления следующий шаг появляется позже, чем ожидал пользователь. В отчёте при этом стоит зелёный PASS: общий размер страницы и суммарное время не вышли за предел. Команда добавляет небольшой виджет, ещё один стиль и дополнительный обработчик. Каждый коммит проходит проверку. Через несколько релизов один участок пути забирает весь запас, а итоговая цифра продолжает скрывать это смещение.
\nЦена ошибки — не абстрактная «медленная страница». Пользователь дольше ждёт перехода, чаще повторяет действие или закрывает вкладку. Инженер тратит время на поиск причины среди уже принятых изменений. Если бюджет проверяет только сумму, он не отвечает на главный вопрос: какой компонент потратил запас и что именно нужно остановить?
\nТезис статьи простой: бюджет производительности должен описывать конкретный пользовательский путь, условия измерения и несколько именованных ограничений. Общая сумма полезна как дополнительный предохранитель. Она не должна отменять провал отдельной части.
\nБюджет — это набор заранее названных пределов. Он может ограничивать размер JavaScript, число запросов, время навигации или другую метрику, которая связана с выбранным сценарием. Число имеет смысл только вместе с путём и условиями. Лимит для каталога нельзя без проверки перенести на оформление заказа: у страниц разные данные, изображения и порядок действий.
\nСначала назовите вход. В учебном примере это маршрут /training/checkout/review и сценарий anonymous-cart-with-one-item. Это не production-маршрут и не результат настоящего запуска. Длинное имя нужно намеренно: другой товар, авторизация, локаль или feature flag могут создать другой набор ресурсов.
Затем зафиксируйте условия. Запишите версию сборки, тип сети, состояние кеша, устройство или профиль CPU, способ запуска и источник чисел. Если часть условий неизвестна, не подставляйте правдоподобное значение. Пометка «не измерялось» лучше, чем вывод о браузере по одному числу из учебного объекта.
\n| Часть контракта | Пример значения | Что проверяет | Чего не доказывает |
|---|---|---|---|
| Route и scenario | /training/checkout/review, одна корзина | Сравнивает один и тот же вход | Качество всех страниц |
| Условия | Сборка, сеть и cache записаны | Показывает сопоставимость замеров | Поведение другой среды |
| Компоненты | document, style, script, render | Находит участок, который забрал запас | Автоматически не объясняет причину |
| Допуск | Limit и tolerance у каждого имени | Даёт правило остановки | Универсальную норму для любого продукта |
| Total | Вторичный предел суммы | Замечает общий рост | Разрешение на провал компонента |
Представьте четыре части с пределами 26, 17, 30 и 22 условных единицы. Их допустимая сумма равна 95. Baseline содержит 24, 15, 26 и 20. Candidate меняет только script: 34 вместо 26. Сумма candidate равна 93. По total он проходит. По правилу для script он превышает предел 30 и должен получить FAIL.
\nТакой пример показывает перенос затрат. Остальные части не стали дешевле из-за того, что скрипт стал тяжелее. Если проверять только сумму, рост в одном месте можно компенсировать случайным уменьшением в другом. Для пользователя это не всегда равноценная замена: лишний JavaScript может задержать обработчик, а уменьшение изображения не вернёт время, потерянное на главном потоке.
\nНиже код намеренно работает с числами, а не с браузером. Единица ticks придумана для примера. Она показывает инвариант проверки: провал компонента нельзя замаскировать зелёной суммой.
const budget = {\n route: '/training/checkout/review',\n scenario: 'anonymous-cart-with-one-item',\n components: {\n documentTicks: { limit: 26, tolerance: 1 },\n styleTicks: { limit: 17, tolerance: 1 },\n scriptTicks: { limit: 30, tolerance: 2 },\n renderTicks: { limit: 22, tolerance: 1 },\n },\n totalTicks: { limit: 95, tolerance: 3 },\n};\n\nconst baseline = {\n documentTicks: 24,\n styleTicks: 15,\n scriptTicks: 26,\n renderTicks: 20,\n};\n\nconst candidate = {\n ...baseline,\n scriptTicks: 34,\n};\n\nfunction checkBudget(snapshot, contract) {\n const components = Object.entries(contract.components).map(\n ([name, rule]) => ({\n name,\n value: snapshot[name],\n allowed: rule.limit + rule.tolerance,\n pass: snapshot[name] <= rule.limit + rule.tolerance,\n }),\n );\n\n const total = Object.values(snapshot).reduce((sum, value) => sum + value, 0);\n const totalAllowed = contract.totalTicks.limit + contract.totalTicks.tolerance;\n\n return {\n components,\n total,\n totalPass: total <= totalAllowed,\n pass: components.every((item) => item.pass) && total <= totalAllowed,\n };\n}\n\nconsole.log(checkBudget(candidate, budget));\nВ этой модели scriptTicks равен 34, а его allowed равен 32. Total равен 93, а его allowed равен 98. Полный результат обязан быть FAIL. Код не измеряет FCP, LCP, INP, сетевую задержку или работу CPU. Он не вызывает Lighthouse и не заменяет browser trace. В рабочем проекте поля нужно связать с конкретным источником измерения, иначе они остаются внутренней условной шкалой.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Total проходит, component падает | Рост скрыт внутри суммы | Сравнить каждую именованную часть с её allowed | Остановить изменение и найти владельца части |
| Два запуска дают разные выводы | Не совпали сеть, cache, CPU или данные | Сопоставить conditions и route в обоих snapshots | Разделить сценарии или повторить измерение в фиксированных условиях |
| Число выросло, но причина неизвестна | Бюджет хранит результат без состава ресурсов | Проверить размер chunk, список запросов и источник метрики | Добавить детализацию, а не повышать лимит вслепую |
| Провал появляется только у одной локали | Сценарии смешаны в одном job | Запустить одинаковый route для каждой локали отдельно | Сделать локаль частью scenario или выделить отдельный budget |
| Старый snapshot проходит после смены сборщика | Baseline получен в другой версии среды | Сравнить версию сборки и формат измерения | Создать новый baseline и сохранить причину замены |
| Команда повышает лимит после каждого FAIL | Лимит используют как способ убрать сигнал | Проверить изменение ресурса и обоснование нового допуска | Сначала устранить рост или зафиксировать осознанное исключение |
Размер ресурсов и число запросов удобно проверять в сборке. Они дают ранний сигнал до открытия страницы в браузере. Такой сигнал отвечает на вопрос о составе артефакта, но не говорит, как быстро пользователь увидит или сможет использовать экран.
\nВременные метрики нужно получать из инструмента, который действительно их измеряет. API Navigation Timing даёт события навигации. PerformanceResourceTiming помогает увидеть интервалы загрузки отдельных ресурсов. Для интеракций нужны отдельные данные о событиях и отрисовке. Не называйте сумму размеров «временем до интерактивности» и не называйте синтетический snapshot полевой метрикой.
Сопоставимость важнее количества цифр. Один замер на быстром ноутбуке не устанавливает предел для всех телефонов. Среднее значение может скрыть хвост распределения. Если бюджет защищает конкретный путь, проверяйте тот же путь, тот же набор данных и тот же класс условий. Если это невозможно, добавьте в результат причину несопоставимости и остановите автоматическое сравнение.
\nБюджет не исправляет медленную страницу сам. Он только превращает выбранное ожидание в сигнал. Плохой маршрут, неверный baseline или неописанные условия дадут точный ответ на неверный вопрос.
\nЛимиты зависят от продукта. Экран с фотографиями, форма оплаты и текстовая статья имеют разный состав ресурсов. Одного числа для всех страниц обычно недостаточно. Разделяйте budgets по типу пути, но не создавайте отдельный лимит для каждого случайного варианта: так сигнал распадётся и станет необслуживаемым.
\nЛабораторные данные и данные реальных пользователей дополняют друг друга. Лабораторный запуск помогает сравнивать изменения в одинаковой среде. Полевые данные показывают разброс устройств, сетей и поведения. Учебный код из статьи не содержит ни тех, ни других данных. Он проверяет только логику контракта.
\nИногда рост оправдан функцией. Тогда измените бюджет вместе с описанием причины, владельцем и способом повторной проверки. Не повышайте лимит только для того, чтобы вернуть зелёный статус.
\nКонтракт готов, если другой инженер может открыть запись budget и ответить на четыре вопроса: какой путь проверяется, в каких условиях, какая часть превысила допуск и какое действие следует выполнить. Автоматическая проверка должна завершаться FAIL, когда один компонент превышает свой allowed, даже если total остаётся внутри общего предела.
\nМинимальный приёмочный набор выглядит так: baseline и candidate имеют одинаковые route, scenario и conditions; каждое значение неотрицательно и принадлежит известному полю; результат печатает component verdict и total verdict; положительный пример проходит; отрицательный пример из учебного кода падает на scriptTicks. Это проверяет механизм, но не заявляет production-эффект.
Эти источники описывают инструменты и понятие бюджета. Они не подтверждают числа из учебного примера и не дают готовый лимит для конкретного продукта.
" }