From 9d064f77dcd5b1e26a7a1a1cb0cd5546b863497c Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 20:12:56 +0300 Subject: [PATCH] =?UTF-8?q?editorial-213:=20=D1=83=D1=82=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=20=D0=B1=D1=8E=D0=B4=D0=B6=D0=B5=D1=82=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B8=D0=B7=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- editorial/agent-rewrites/213.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editorial/agent-rewrites/213.json b/editorial/agent-rewrites/213.json index dd3258e..8c6d7a4 100644 --- a/editorial/agent-rewrites/213.json +++ b/editorial/agent-rewrites/213.json @@ -3,5 +3,5 @@ "slug": "editorial-2022-02-practice-performance-budget", "title": "Бюджет производительности: как не спрятать регрессию за общим PASS", "excerpt": "Как описать один пользовательский путь, разделить его бюджет на именованные части и остановить изменение, которое превышает допуск, даже если общий результат ещё выглядит зелёным.", - "contentHtml": "

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

Затем зафиксируйте условия. Запишите версию сборки, тип сети, состояние кеша, устройство или профиль CPU, способ запуска и источник чисел. Если часть условий неизвестна, не подставляйте правдоподобное значение. Пометка «не измерялось» лучше, чем вывод о браузере по одному числу из учебного объекта.

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

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

\n

Представьте четыре части с пределами 26, 17, 30 и 22 условных единицы. Их допустимая сумма равна 95. Baseline содержит 24, 15, 26 и 20. Candidate меняет только script: 34 вместо 26. Сумма candidate равна 93. По total он проходит. По правилу для script он превышает предел 30 и должен получить FAIL.

\n

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

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

Учебная модель сравнения

\n

Ниже код намеренно работает с числами, а не с браузером. Единица ticks придумана для примера. Она показывает инвариант проверки: провал компонента нельзя замаскировать зелёной суммой.

\n
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. В рабочем проекте поля нужно связать с конкретным источником измерения, иначе они остаются внутренней условной шкалой.

\n

Симптомы и действия

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

Как выбрать измерение

\n

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

\n

Временные метрики нужно получать из инструмента, который действительно их измеряет. API Navigation Timing даёт события навигации. PerformanceResourceTiming помогает увидеть интервалы загрузки отдельных ресурсов. Для интеракций нужны отдельные данные о событиях и отрисовке. Не называйте сумму размеров «временем до интерактивности» и не называйте синтетический snapshot полевой метрикой.

\n

Сопоставимость важнее количества цифр. Один замер на быстром ноутбуке не устанавливает предел для всех телефонов. Среднее значение может скрыть хвост распределения. Если бюджет защищает конкретный путь, проверяйте тот же путь, тот же набор данных и тот же класс условий. Если это невозможно, добавьте в результат причину несопоставимости и остановите автоматическое сравнение.

\n

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

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

Ограничения

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n

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

\n\n

Эти источники описывают инструменты и понятие бюджета. Они не подтверждают числа из учебного примера и не дают готовый лимит для конкретного продукта.

" + "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" }