open atlas
↑ К треку
React с нуля до senior RCT · 14 · 03

Перф-регрессии: медленное сползание, которое никто не выкатывал нарочно

Расползание бандла, рост гидрации и распад мемоизации портят p75 без единого плохого релиза. Защита слоями: жёсткие байт-бюджеты в CI, синтетика на фиксированном устройстве, перцентили RUM по релизам. Бисекция — дифф релиза плюс дифф профиля; каждому роуту — бюджет с владельцем.

RCT Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Никто не выкатывал медленный релиз. Приложение всё равно стало медленным. Когда команда наконец посмотрела, 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% в новом кольце релиза. Какой класс регрессий проскальзывает оба гейта и каков ход бисекции?

Вспомните перед уходом
  1. 01
    Назовите три механизма сползания из урока с обнаружением и лечением каждого.
  2. 02
    Изложите три слоя обнаружения, слепую зону каждого и дисциплину бисекции, когда срабатывает последний слой.
Итог

Перф-регрессии — класс отказа, определяемый незаметностью: ни один дифф не двигает метрику настолько, чтобы человек возразил, а два квартала спустя p75 INP утроился без атрибутируемого релиза. Три механизма в React-обличье: расползание бандла, где нетряхаемые формы импорта — канон это именованный импорт CommonJS-lodash — добавляют десятки килобайтов однострочными PR с типичной бесконтрольной скоростью 5–15 кБ за спринт; рост гидрации, где одно переключение клиентской границы втягивает целый граф импортов в клиентский бандл ради одного булева, и лечится выделением крошечного клиентского листа; и распад мемоизации, где рефакторинг вносит инлайновый prop, контракт ссылочного равенства ломается и мемоизированное поддерево месяцами перерисовывается на каждое нажатие — ноль байтов, ноль упавших тестов, свидетели только Profiler «до/после» и перцентили INP. Защита — три слоя со взаимодополняющими слепыми зонами: жёстко падающие байтовые бюджеты CI (warn-only бюджеты надёжно умирают от нормализации — предупреждение оценивает регрессию в ноль), синтетика на фиксированном устройстве, видящая рантайм-стоимость, но в фикстурном масштабе, и RUM p75 по кольцам релиза, видящий реальный масштаб и реальное железо, но с отставанием и полом по трафику. Когда слой истины срабатывает, дисциплина бисекции — дифф релиза плюс дифф профиля, и частые деплои держат множество подозреваемых маленьким. А бюджет — контракт, не дашборд: на каждый роут цели по байтам, LCP и INP с командой-владельцем, храповик, фиксирующий выигрыши, и записанные исключения — так тихое сползание превращается в пятиминутные разговоры, пока контекст ещё длиной в одну строку. Теперь, когда видишь PR с невинным однострочным импортом или инлайновым style на строке списка, — спроси, какой из слоёв поймает это, если окажется регрессией, и стоит ли он в CI.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.