Перф-регрессии: медленное сползание, которое никто не выкатывал нарочно
Расползание бандла, рост гидрации и распад мемоизации портят p75 без единого плохого релиза. Защита слоями: жёсткие байт-бюджеты в CI, синтетика на фиксированном устройстве, перцентили RUM по релизам. Бисекция — дифф релиза плюс дифф профиля; каждому роуту — бюджет с владельцем.
Никто не выкатывал медленный релиз. Приложение всё равно стало медленным. Когда команда наконец посмотрела, p75 INP главного грида прошёл путь от 140 мс до 480 мс за два квартала — и ни один деплой не сдвинул его больше чем на 6%. Археология нашла трёх виновников, и ни один не выглядел неправильным на ревью. Первый: бандл роута вырос на 180 кБ minified за четырнадцать спринтов — около 13 кБ на спринт, включая однострочный PR, поменявший import debounce from 'lodash/debounce' на import { debounce } from 'lodash' и молча втащивший всю библиотеку. Второй: серверная таблица стала клиентским компонентом ради одного hover-тултипа, утянув в бандл гидрации свой markdown-рендерер и библиотеку дат. Третий: рефакторинг добавил инлайновый объект style мемоизированному списку на 3 000 строк, и поддерево пять месяцев перерисовывалось на каждое нажатие клавиши — Profiler потом показал «до/после» как 4 мс против 41 мс на коммит. Три механизма, один класс отказа: регрессии, которые ползут, а не падают, шагами слишком мелкими, чтобы их почувствовал хоть один ревьюер.
Класс отказа: медленное сползание
Краш будит дежурного; рост бандла на 12 кБ не будит никого. Определяющее свойство перф-регрессий — они приходят шагами ниже порога человеческого восприятия: спринт добавляет зависимость здесь, клиентскую границу там, инлайновый prop где-то ещё — каждый дифф защитим, кумулятивная сумма — удвоенный p75. Ревью не ловит этот класс, потому что ни один ревьюер не держит в голове накопленный итог, а фраза «этот PR добавляет 1,8% к бандлу» невидима без инструмента, который печатает ровно это предложение. Типичные скорости бесконтрольного сползания, наблюдаемые по командам: 5–15 кБ minified роста бандла за спринт, и p75 INP, деградирующий из «хорошо» (меньше 200 мс) в территорию «плохо» (больше 500 мс) за два-три квартала без единого атрибутируемого релиза. Защита — не героизм; это машинерия, превращающая невидимые приращения в видимые отказы: бюджеты, валящие сборку; синтетика, диффающая релизы; перцентили, диффающие кольца.
Расползание бандла и рост гидрации
Импорт lodash — канонический механизм сползания: CommonJS-lodash не поддаётся tree-shaking (статическому удалению неиспользуемого кода бандлером), поэтому import { debounce } from 'lodash' грузит всю библиотеку — примерно 70 кБ minified, ~25 кБ gzip, — тогда как lodash/debounce несёт несколько кБ. Класс обобщается: чартовая библиотека ради одного спарклайна, библиотека дат там, где хватило бы Intl, набор полифиллов для браузеров, от которых вы отказались. Контрмера — жёстко падающий байтовый бюджет CI на каждую точку входа роута (size-limit или аналог): сборка падает при пробое бюджета, число — прямо в проверке PR. Бюджеты «только предупреждение» умирают от усталости к алертам за квартал — у каждой команды, гонявшей их, одна история: предупреждения копятся, нормализуются, игнорируются; регрессия всё равно уезжает. Бюджет обязан валить сборку, иначе он декорация.
Рост гидрации — React-специфичный вариант. В RSC-приложении пометка одного компонента 'use client' втягивает в клиентский бандл его и весь его граф импортов — каскад. Таблице из вступления был нужен один hover-тултип; переключение границы привело markdown-рендерер и библиотеку дат, +90 кБ работы гидрации — всё ради отслеживания одного булева. Паттерн лечения — изоляция границы: таблица остаётся на сервере, а из неё выделяется листовой клиентский компонент HoverHint на двадцать строк. Обнаружение — тот же байтовый бюджет, применённый к клиентскому бандлу роута: каскад проявляется как ступенька, которую сборка отказывается принять.
Однострочный PR меняет import debounce from 'lodash/debounce' на import { debounce } from 'lodash', и бандл роута растёт примерно на 70 кБ minified. Почему?
Распад мемоизации: тихая регрессия ререндеров
Мемоизация — контракт над ссылочной идентичностью, и рефакторинги ломают его, не ломая ничего видимого. Когда добавляете prop в мемоизированный компонент, спросите себя: создаётся ли это значение инлайново в каждом рендере? Если да — сравнение всегда провалится. Паттерн распада: мемоизированный список месяцами работает; рефакторинг добавляет style={{ paddingLeft: depth * 8 }} или инлайновый onSelect в месте вызова; каждое сравнение props теперь проваливается; поддерево перерисовывается на каждое нажатие. Ни один тест не падает — вывод идентичен. Ни одна ошибка не летит — перерисовка легальна. Единственные свидетели — Profiler и, со временем, перцентили INP. Дисциплина доказательств — данные Profiler «до/после»: таблица из вступления показывала коммиты по 4 мс с работающим memo и по 41 мс с распавшимся — на каждом нажатии каждого пользователя пять месяцев. Линт не ловит это полностью (он не знает, какие поддеревья дороги), поэтому защита слоёная: Profiler из React DevTools на код-ревью для изменений в горячих путях («покажи профиль коммита для этого рефакторинга») плюс RUM-атрибуция INP, помечающая роут постфактум. Распад memo — ещё и сильнейший аргумент за React Compiler: автомемоизация, переживающая рефакторинги, выводит человека из контракта.
Три слоя обнаружения и дисциплина бисекции
Ни один слой не ловит всё; у каждого есть слепая зона, которую закрывает следующий. CI-бюджеты видят байты до мёржа — мгновенно, на каждый PR, но слепы к рантайм-стоимости: распад memo не добавляет ни байта и проходит. Синтетические проверки (Lighthouse CI или аналог на фиксированном профиле устройства, на PR или ежечасно против продакшена) видят рантайм-стоимость на одном устройстве с фикстурными данными — они ловят ступеньку гидрации, но пропускают то, что проявляется только на реальном масштабе данных (3 000 настоящих строк против 20 в фикстуре) или на реальном дешёвом железе. Перцентили RUM по кольцам релиза видят истину — каждое устройство, каждый датасет, — но отстают на часы и требуют трафика; это слой, ловящий то, что упустили оба гейта, и доказывающий, что те откалиброваны. Когда RUM помечает регрессию, проскочившую гейты, бисекция — это два диффа: дифф релиза (какие коммиты уехали в регрессировавшем кольце — список короток, если деплоите ежедневно, что само по себе аргумент за частоту деплоев) и дифф профиля (синтетическая трасса или семплированный полевой профиль регрессировавшего роута до и после, называющий компонент или задачу, которая замедлилась). С кольцами релизов и ежедневными деплоями множество подозреваемых обычно меньше дюжины коммитов; без них — трёхнедельная охота.
▸Почему это работает
Почему бюджеты «только предупреждение» стабильно умирают, а жёстко падающие держатся? Потому что предупреждение оценивает регрессию в ноль: инженер, который её выкатывает, сегодня не платит ничего, а счёт достаётся тому, кто будет разбирать перцентили в следующем квартале. Жёсткий отказ переоценивает: цена ложится сейчас, на человека с максимумом контекста, который может выбрать дешёвое лечение (импорт до метода), пока дифф — одна строка. Исключения из бюджета всё равно случаются — легитимной фиче иногда нужны байты, — но они проходят через видимый бамп в конфиге с подписью ревьюера, превращая тихое сползание в записанное решение. Механизм тот же, что у ошибок типов: сделать будущую стоимость настоящей.
Контракт бюджета: числа и владелец
Работающий перф-бюджет — это контракт на роут из трёх чисел и имени: байты JS (например, 200 кБ minified на точку входа), цель LCP (2,5 с на p75), цель INP (200 мс на p75) — и команда-владелец. Пункт о владельце и делает это контрактом, а не дашбордом: при пробое роута тикет получает команда-владелец, а не центральная «перф-команда», владеющая всем и потому ничем. Две операционные нормы держат его честным. Храповик: когда роут уверенно бьёт свой бюджет, бюджет затягивается, фиксируя выигрыш, — иначе запас тихо проедается. Протокол исключений: повышение бюджета требует записанной подписи в конфиге, так что у каждого увеличения есть имя, дата и причина. Команды, живущие по этой схеме, описывают один и тот же исход: сползание перестаёт быть квартальной археологией и становится пятиминутным разговором на PR, пока контекст свеж.
Байтовые бюджеты CI зелёные, Lighthouse на PR с фикстурным датасетом зелёный, но RUM p75 INP роута грида прыгает на 60% в новом кольце релиза. Какой класс регрессий проскальзывает оба гейта и каков ход бисекции?
- 01Назовите три механизма сползания из урока с обнаружением и лечением каждого.
- 02Изложите три слоя обнаружения, слепую зону каждого и дисциплину бисекции, когда срабатывает последний слой.
Перф-регрессии — класс отказа, определяемый незаметностью: ни один дифф не двигает метрику настолько, чтобы человек возразил, а два квартала спустя p75 INP утроился без атрибутируемого релиза. Три механизма в React-обличье: расползание бандла, где нетряхаемые формы импорта — канон это именованный импорт CommonJS-lodash — добавляют десятки килобайтов однострочными PR с типичной бесконтрольной скоростью 5–15 кБ за спринт; рост гидрации, где одно переключение клиентской границы втягивает целый граф импортов в клиентский бандл ради одного булева, и лечится выделением крошечного клиентского листа; и распад мемоизации, где рефакторинг вносит инлайновый prop, контракт ссылочного равенства ломается и мемоизированное поддерево месяцами перерисовывается на каждое нажатие — ноль байтов, ноль упавших тестов, свидетели только Profiler «до/после» и перцентили INP. Защита — три слоя со взаимодополняющими слепыми зонами: жёстко падающие байтовые бюджеты CI (warn-only бюджеты надёжно умирают от нормализации — предупреждение оценивает регрессию в ноль), синтетика на фиксированном устройстве, видящая рантайм-стоимость, но в фикстурном масштабе, и RUM p75 по кольцам релиза, видящий реальный масштаб и реальное железо, но с отставанием и полом по трафику. Когда слой истины срабатывает, дисциплина бисекции — дифф релиза плюс дифф профиля, и частые деплои держат множество подозреваемых маленьким. А бюджет — контракт, не дашборд: на каждый роут цели по байтам, LCP и INP с командой-владельцем, храповик, фиксирующий выигрыши, и записанные исключения — так тихое сползание превращается в пятиминутные разговоры, пока контекст ещё длиной в одну строку. Теперь, когда видишь PR с невинным однострочным импортом или инлайновым style на строке списка, — спроси, какой из слоёв поймает это, если окажется регрессией, и стоит ли он в CI.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.