8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"index": 213,
|
||
"slug": "editorial-2022-02-practice-performance-budget",
|
||
"title": "Бюджет производительности: как не спрятать регрессию за общим PASS",
|
||
"excerpt": "Как описать один пользовательский путь, разделить его бюджет на именованные части и остановить изменение, которое превышает допуск, даже если общий результат ещё выглядит зелёным.",
|
||
"contentHtml": "<p>Проблема начинается с маленького изменения: на странице оформления следующий шаг появляется позже, чем ожидает пользователь. В отчёте при этом стоит зелёный PASS: общий размер страницы и суммарное время не вышли за предел. Команда добавляет виджет, стиль и обработчик. Каждый коммит проходит проверку, но через несколько релизов один участок пути забирает весь запас.</p>\n<p>Цена ошибки — не абстрактная «медленная страница». Пользователь дольше ждёт перехода, повторяет действие или закрывает вкладку. Инженер ищет причину среди уже принятых изменений. Если budget проверяет только сумму, он не отвечает на главный вопрос: какой компонент потратил запас и что нужно остановить?</p>\n<p>Вывод статьи простой: бюджет производительности должен описывать конкретный пользовательский путь, условия измерения и несколько именованных ограничений. Общая сумма полезна как дополнительный сигнал. Она не должна отменять провал отдельной части.</p>\n<h2>Что именно ограничивает бюджет</h2>\n<p>Performance budget — это набор заранее названных лимитов для метрик, связанных с производительностью. В него можно включить размер страницы или скриптов, число HTTP-запросов и время загрузки в заданном сетевом сценарии. Это не универсальная норма: лимит имеет смысл только вместе с путём, данными и условиями измерения.</p>\n<p>В учебном примере входом служат маршрут <code>/training/checkout/review</code> и сценарий <code>anonymous-cart-with-one-item</code>. Это условный маршрут, а не результат запуска настоящего сайта. Другой товар, авторизация, локаль или feature flag могут изменить набор ресурсов, поэтому их нельзя молча смешивать с тем же budget.</p>\n<p>Рядом с числами запишите версию сборки, сеть, состояние cache, устройство или профиль CPU, способ запуска и источник метрики. Если условие не измерялось, его нельзя восстанавливать по одному итоговому числу.</p>\n<table><caption>Контракт бюджета для одного пользовательского пути</caption><thead><tr><th scope=\"col\">Часть контракта</th><th scope=\"col\">Пример значения</th><th scope=\"col\">Что проверяет</th><th scope=\"col\">Чего не доказывает</th></tr></thead><tbody><tr><td>Route и scenario</td><td><code>/training/checkout/review</code>, одна корзина</td><td>Сравнивает один и тот же вход</td><td>Качество всех страниц</td></tr><tr><td>Conditions</td><td>Сборка, сеть, cache и устройство записаны</td><td>Показывает сопоставимость замеров</td><td>Поведение другой среды</td></tr><tr><td>Components</td><td><code>document</code>, <code>style</code>, <code>script</code>, <code>render</code></td><td>Находит участок, который забрал запас</td><td>Автоматически не объясняет причину</td></tr><tr><td>Limit и tolerance</td><td>Допуск у каждого имени</td><td>Даёт правило остановки</td><td>Универсальную норму продукта</td></tr><tr><td>Total</td><td>Вторичный предел суммы</td><td>Замечает общий рост</td><td>Разрешение на провал компонента</td></tr></tbody></table>\n<h2>Почему одна сумма даёт ложный PASS</h2>\n<p>Возьмём четыре части с лимитами 26, 17, 30 и 22 условные единицы. Их сумма равна 95. В baseline значения — 24, 15, 26 и 20. Candidate меняет только <code>script</code>: 26 становится 34. Итог candidate равен 93, поэтому total проходит. Но для <code>script</code> allowed равен 32: limit 30 плюс tolerance 2. Именованный компонент должен получить FAIL.</p>\n<p>Это перенос затрат. Остальные части не стали дешевле из-за тяжёлого скрипта. Если проверять только сумму, рост в одном месте можно случайно компенсировать уменьшением в другом. Для пользователя это не всегда равноценная замена: лишний JavaScript может задержать обработчик, а уменьшение картинки не уберёт работу на основном потоке.</p>\n<figure><img src=\"/assets/editorial/2022/performance-budget-allocation-2022.svg\" alt=\"Схема бюджета пути: document, style, script и render имеют отдельные лимиты, а общий total проходит при провале script\" loading=\"lazy\" /><figcaption>Учебная схема разделяет именованные проверки и общий предел. Она не показывает реальный trace, браузерный запуск или полевую метрику.</figcaption></figure>\n<h2>Воспроизводимый контракт сравнения</h2>\n<p>До сравнения candidate проверьте не только значения, но и сам вход. Код работает с frozen plain objects в памяти. Поля <code>documentTicks</code>, <code>styleTicks</code>, <code>scriptTicks</code> и <code>renderTicks</code> — условные единицы, а не названия браузерных метрик.</p>\n<pre><code>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</code></pre>\n<p>Функция вернёт FAIL по <code>scriptTicks</code>: 34 больше allowed 32. Сумма равна 93 и меньше totalAllowed 98, но <code>totalPass: true</code> не меняет общего verdict. Если route, scenario, conditions или состав полей расходятся, результатом будет <code>INVALID</code>, а не ложная регрессия.</p>\n<p>Проверка не измеряет браузер. Она проверяет инвариант контракта: известные неотрицательные числа сравниваются по отдельности и затем суммируются. В рабочем проекте нужно отдельно получить эти числа, сохранить источник и связать каждое поле с владельцем измерения.</p>\n<h2>Симптомы и ограниченное действие</h2>\n<table><caption>Диагностика регрессии бюджета</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Гипотеза</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Total проходит, component падает</td><td>Рост скрыт внутри суммы</td><td>Сравнить каждую часть с её allowed</td><td>Остановить изменение и найти владельца части</td></tr><tr><td>Два запуска дают разные выводы</td><td>Не совпали сеть, cache, CPU или данные</td><td>Сопоставить conditions и route</td><td>Повторить в фиксированных условиях или разделить сценарии</td></tr><tr><td>Число выросло, причина неизвестна</td><td>Сохранён результат без состава</td><td>Проверить размер chunk, запросы и источник</td><td>Добавить детализацию, а не повышать лимит вслепую</td></tr><tr><td>Провал виден только у одной локали</td><td>Сценарии смешаны в одном job</td><td>Запустить тот же route отдельно</td><td>Включить локаль в scenario или выделить budget</td></tr><tr><td>Старый snapshot проходит после смены сборщика</td><td>Baseline получен в другой среде</td><td>Сравнить версию и формат данных</td><td>Создать baseline с причиной замены</td></tr><tr><td>После каждого FAIL повышают лимит</td><td>Лимит используют для удаления сигнала</td><td>Проверить ресурс и обоснование</td><td>Устранить рост или зафиксировать исключение</td></tr></tbody></table>\n<h2>Как выбрать источник измерения</h2>\n<p>Размер ресурсов и число запросов удобно проверять на этапе сборки. Они рано показывают состав артефакта и помогают остановить тяжёлую зависимость. Но одинаковый вес не гарантирует одинаковое восприятие: критический ресурс может прийти позже, а порядок загрузки меняет полезный результат.</p>\n<p>Для времени навигации браузер предоставляет Navigation Timing. Через <code>PerformanceNavigationTiming</code> можно получить запись текущего документа и разобрать интервалы ответа сервера, DOMContentLoaded и load. Эти значения описывают навигацию; они не превращают условные <code>ticks</code> из примера в реальные миллисекунды.</p>\n<p>Для отдельных ресурсов используется <code>PerformanceResourceTiming</code>. Он даёт временную шкалу загрузки и сведения о размере ресурса. Для cross-origin ресурсов подробные поля могут быть недоступны без <code>Timing-Allow-Origin</code>. Отсутствие данных нельзя принять за нулевую задержку.</p>\n<p>Не называйте сумму размеров «временем до интерактивности» и не называйте один синтетический snapshot полевой метрикой. В budget укажите класс источника, окно наблюдения, сегмент устройств и способ агрегации.</p>\n<h2>Порядок внедрения</h2>\n<ol><li>Выберите один путь, связанный с действием пользователя, и запишите route, scenario и ожидаемый результат.</li><li>Назначьте источник каждого числа: размер артефакта, browser timing, synthetic run или field data. Не смешивайте эти классы.</li><li>Зафиксируйте версию сборки, сеть, cache, устройство, данные и feature flags.</li><li>Разделите budget на части, которыми можно управлять отдельно: document, style, script, render или другой состав для этого пути.</li><li>Снимите baseline и сохраните его рядом с conditions. Не называйте один запуск истиной без диапазона и повторов.</li><li>Сначала проверяйте каждую часть, затем total. В отчёте печатайте оба verdict.</li><li>Прогоните положительный и отрицательный сценарии: один компонент должен превысить свой allowed при зелёном total.</li><li>Для каждого FAIL назначьте действие: уменьшить ресурс, убрать запрос, отложить код, изменить scenario или пересмотреть контракт с причиной.</li></ol>\n<h2>Ограничения</h2>\n<p>Budget не исправляет медленную страницу. Он превращает выбранное ожидание в сигнал. Плохой route, неверный baseline или неописанные conditions дадут точный ответ на неверный вопрос.</p>\n<p>Лимиты зависят от продукта. Экран с фотографиями, форма оплаты и текстовая статья имеют разный состав ресурсов. Одного числа для всех страниц недостаточно. Разделяйте budgets по типу пути, но не создавайте отдельный лимит для каждого случайного варианта: сигнал распадётся и станет необслуживаемым.</p>\n<p>Лабораторные данные помогают сравнивать изменения в одинаковой среде. Полевые данные показывают разброс устройств, сетей и поведения посетителей. Учебный код не содержит ни тех, ни других данных. Он проверяет только логику контракта.</p>\n<p>Иногда рост оправдан функцией. Тогда измените budget вместе с причиной, владельцем и способом повторной проверки. Не повышайте лимит только для зелёного статуса.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Контракт готов, если другой инженер может ответить на четыре вопроса: какой путь проверяется, в каких условиях, какая часть превысила допуск и какое действие следует выполнить. Автоматическая проверка должна завершаться FAIL, когда один компонент превышает свой allowed, даже если total остаётся внутри общего предела.</p>\n<p>Минимальный набор таков: baseline и candidate имеют одинаковые route, scenario и conditions; состав полей совпадает; каждое значение неотрицательно и конечно; результат печатает component verdict и total verdict; положительный пример проходит; отрицательный пример падает на <code>scriptTicks</code>. Это проверяет механизм, но не заявляет эффект на реальном трафике.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://web.dev/articles/performance-budgets-101\" target=\"_blank\" rel=\"noopener noreferrer\">web.dev: Performance budgets 101</a> — определяет budget как набор лимитов и приводит размеры страницы, время загрузки, число запросов и ограничения для ресурсов как варианты метрик. Страница не подтверждает условные числа этой статьи.</li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/PerformanceResourceTiming\" target=\"_blank\" rel=\"noopener noreferrer\">MDN: PerformanceResourceTiming</a> — описывает временную шкалу загрузки ресурсов, сведения о размере и ограничение cross-origin timing без <code>Timing-Allow-Origin</code>.</li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/Performance_API/Navigation_timing\" target=\"_blank\" rel=\"noopener noreferrer\">MDN: Navigation timing</a> — описывает navigation timing entries текущего документа и границы API. Источники не превращают учебные <code>ticks</code> в browser measurement.</li></ul>"
|
||
}
|